8 Commits
24 changed files with 3009 additions and 495 deletions
Generated
+22
View File
@@ -4612,6 +4612,26 @@ dependencies = [
"state",
]
[[package]]
name = "nip29_ui"
version = "1.0.2"
dependencies = [
"anyhow",
"chat",
"chat_ui",
"common",
"gpui-pre",
"log",
"nip29",
"nostr-sdk",
"person",
"settings",
"smallvec",
"state",
"theme",
"ui",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -9504,6 +9524,8 @@ dependencies = [
"gpui-pre",
"instant",
"log",
"nip29",
"nip29_ui",
"nostr-connect",
"nostr-sdk",
"person",
+1 -1
View File
@@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock<Regex> =
mod actions;
mod file;
mod text;
pub mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
+30 -1
View File
@@ -5,7 +5,7 @@ use anyhow::{Result, anyhow, bail};
use chat::Message;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, Builder, HttpRequestExt, Method, RedirectPolicy, StatusCode};
use gpui::{App, AppContext, Context, EventEmitter, Task};
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
@@ -96,6 +96,23 @@ impl Group {
self.metadata.as_ref()
}
pub fn display_name(&self) -> SharedString {
let name = self
.metadata
.as_ref()
.and_then(|metadata| metadata.name())
.unwrap_or_else(|| self.key.id().as_str());
SharedString::from(name.to_owned())
}
pub fn display_image(&self) -> Option<SharedString> {
self.metadata
.as_ref()
.and_then(|metadata| metadata.picture.clone())
.map(SharedString::from)
}
pub fn members(&self) -> &GroupMembers {
&self.members
}
@@ -485,6 +502,18 @@ impl Group {
cx.notify();
}
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
self.window.remove(id);
if !self.index.remove(&id) {
return;
}
self.messages.retain(|message| message.id != id);
cx.emit(GroupEvent::Updated);
cx.notify();
}
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
self.loading = false;
cx.emit(GroupEvent::Eose);
+71 -57
View File
@@ -35,17 +35,12 @@ struct GlobalGroupsRegistry(Entity<GroupsRegistry>);
impl Global for GlobalGroupsRegistry {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub enum GroupsEvent {
Updated,
Error(String),
}
#[derive(Debug, Clone)]
pub struct Browse {
pub relay: RelayUrl,
pub loading: bool,
pub groups: Vec<GroupCandidate>,
Browsed(Box<GroupCandidate>),
BrowseSettled(RelayUrl),
}
#[derive(Debug)]
@@ -53,6 +48,7 @@ enum Signal {
Event(SubscriptionId, Event),
Eose(SubscriptionId),
Closed(SubscriptionId, String),
BrowseSettled(SubscriptionId),
Forks,
}
@@ -97,7 +93,7 @@ impl EventEmitter<GroupsEvent> for GroupsRegistry {}
pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
list: GroupList,
browse: HashMap<SubscriptionId, Browse>,
browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>,
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
@@ -246,19 +242,11 @@ impl GroupsRegistry {
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let id = browse_subscription(&relay);
let tx = self.signal_tx.clone();
self.browse.insert(
id.clone(),
Browse {
relay: relay.clone(),
loading: true,
groups: Vec::new(),
},
);
cx.emit(GroupsEvent::Updated);
cx.notify();
self.browse.insert(id.clone(), relay.clone());
self.tasks.push(cx.background_spawn(async move {
client.add_relay(relay.clone()).and_connect().await.ok();
@@ -269,18 +257,25 @@ impl GroupsRegistry {
.timeout(Some(BROWSE_TIMEOUT));
if let Err(error) = client
.subscribe(ReqTarget::single(relay, [filter]))
.with_id(id)
.subscribe(ReqTarget::single(relay.clone(), [filter]))
.with_id(id.clone())
.close_on(opts)
.await
{
log::warn!("nip29: browsing failed: {error}");
log::warn!("nip29: browsing {relay} failed: {error}");
}
if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await {
log::warn!("nip29: the browse settle signal was dropped: {error}");
}
}));
}
pub fn browsed(&self, relay: &RelayUrl) -> Option<&Browse> {
self.browse.get(&browse_subscription(relay))
fn settle_browse(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
let Some(relay) = self.browse.get(&id).cloned() else {
return;
};
cx.emit(GroupsEvent::BrowseSettled(relay));
}
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
@@ -506,12 +501,19 @@ impl GroupsRegistry {
key.clone(),
cx.subscribe(&group, |this, group, event, cx| match event {
GroupEvent::Sent(event) => {
if event.kind == Kind::GroupDeleteEvent {
this.forget_event(group, event.clone(), cx);
} else {
let key = group.read(cx).key().clone();
this.reload(key, Some(event.clone()), cx);
}
}
GroupEvent::Updated => {
this.refresh_entry(group, cx);
this.sync_forks(false, cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
}
_ => {}
}),
@@ -607,9 +609,6 @@ impl GroupsRegistry {
self.upsert_entry(&key, Some(name));
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.notify();
}
fn publish_list(&mut self, cx: &mut Context<Self>) {
@@ -676,6 +675,40 @@ impl GroupsRegistry {
}));
}
fn forget_event(&mut self, group: Entity<Group>, event: Event, cx: &mut Context<Self>) {
let Some(target) = event.tags.event_ids().next() else {
return;
};
let key = group.read(cx).key().clone();
let cache_tag = key.cache_tag();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let purge = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(target.to_hex())
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
client.database().delete(filter).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = purge.await {
log::warn!("nip29: dropping the deleted envelope failed: {error}");
}
this.update(cx, |this, cx| {
group.update(cx, |group, cx| {
group.forget(target, cx);
});
this.reload(key, Some(event), cx);
})
.ok();
}));
}
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
if id == list_subscription_id() {
match GroupList::parse(&event) {
@@ -703,15 +736,12 @@ impl GroupsRegistry {
}
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
let Some(browse) = self.browse.get_mut(id) else {
let Some(relay) = self.browse.get(id).cloned() else {
return;
};
let Ok(metadata) = GroupMetadata::parse(event) else {
log::warn!(
"nip29: ignoring malformed group metadata from {}",
browse.relay
);
log::warn!("nip29: ignoring malformed group metadata from {relay}");
return;
};
@@ -719,28 +749,13 @@ impl GroupsRegistry {
return;
};
let candidate = GroupCandidate {
key: GroupKey::new(browse.relay.clone(), group),
cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate {
key: GroupKey::new(relay, group),
metadata,
};
match browse.groups.iter_mut().find(|e| e.key == candidate.key) {
Some(existing) => *existing = candidate,
None => browse.groups.push(candidate),
}
cx.emit(GroupsEvent::Updated);
cx.notify();
})));
}
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
if let Some(browse) = self.browse.get_mut(&id) {
browse.loading = false;
cx.emit(GroupsEvent::Updated);
cx.notify();
return;
}
let Some(key) = self.routes.get(&id).cloned() else {
return;
};
@@ -769,12 +784,8 @@ impl GroupsRegistry {
}
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
if let Some(browse) = self.browse.get_mut(&id) {
log::warn!("nip29: browsing {} was refused: {reason}", browse.relay);
browse.loading = false;
cx.emit(GroupsEvent::Updated);
cx.notify();
if let Some(relay) = self.browse.get(&id) {
log::warn!("nip29: browsing {relay} was refused: {reason}");
return;
}
@@ -884,6 +895,9 @@ impl GroupsRegistry {
Signal::Closed(id, reason) => {
this.handle_closed(id, reason, cx);
}
Signal::BrowseSettled(id) => {
this.settle_browse(id, cx);
}
Signal::Forks => {
this.sync_forks(true, cx);
}
+16
View File
@@ -469,6 +469,18 @@ pub struct GroupCandidate {
pub metadata: GroupMetadata,
}
impl GroupCandidate {
pub fn display_name(&self) -> &str {
self.metadata
.name()
.unwrap_or_else(|| self.key.id().as_str())
}
pub fn display_image(&self) -> Option<&str> {
self.metadata.picture.as_deref()
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
@@ -819,6 +831,10 @@ impl TimelineWindow {
self.0.truncate(TIMELINE_WINDOW);
}
pub fn remove(&mut self, id: EventId) {
self.0.retain(|event| event.id != id);
}
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
let references: Vec<String> = self
.0
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "nip29_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
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
}
+59
View File
@@ -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<GroupPanel>, 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
})
});
}
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
use chat::Message;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled,
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, PopupMenu, PopupMenuItem};
use ui::message::MessageRow;
use crate::GroupPanel;
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
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);
let row = MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
.avatar(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0(),
)
.author(author.name())
.timestamp(message.created_at.to_human_time())
.child(content)
.when(!message.media.is_empty(), |this| {
this.child(media(&message.media, cx))
})
.into_any_element();
let id = message.id;
let admin = actions.admin;
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 menu = menu.submenu("React", window, cx, {
let group = group.clone();
let panel = panel.clone();
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
});
if !admin {
return menu;
}
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();
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<Group>,
panel: WeakEntity<GroupPanel>,
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()
.child(
img(only.clone())
.border_1()
.border_color(cx.theme().border_variant)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
)
.into_any_element();
}
div()
.w_full()
.flex_1()
.flex()
.flex_row()
.flex_wrap()
.gap_2()
.children(media.iter().enumerate().map(|(ix, item)| {
div().id(format!("media-{ix}")).child(
img(item.clone())
.h_32()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius),
)
}))
.into_any_element()
}
+176
View File
@@ -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<Group>,
panel: WeakEntity<GroupPanel>,
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<Flags>,
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<InputState>, cx: &App) -> Option<String> {
let value = input.read(cx).value().trim().to_owned();
(!value.is_empty()).then_some(value)
}
+209
View File
@@ -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<Group>,
panel: WeakEntity<GroupPanel>,
window: &mut Window,
cx: &mut App,
) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1…"));
let selected = cx.new(|_cx| Vec::<String>::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<GroupPanel>,
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<Vec<String>>, 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()
}
+2
View File
@@ -14,6 +14,8 @@ chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
community = { path = "../community" }
community_ui = { path = "../community_ui" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
@@ -0,0 +1,35 @@
use gpui::{App, AppContext, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry};
use ui::WindowExtension;
use ui::input::{Input, InputState};
use ui::notification::Notification;
pub fn open(window: &mut Window, cx: &mut App) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…"));
window.open_dialog(cx, move |this, _window, _cx| {
let input = input.clone();
this.width(px(380.))
.confirm()
.title("Join Group")
.child(Input::new(&input).cleanable(true).flex_1().text_sm())
.on_ok(move |_event, window, cx| {
let groups = GroupsRegistry::global(cx);
let reference = match GroupReference::parse(input.read(cx).value().trim()) {
Ok(reference) => reference,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
groups.update(cx, |registry, cx| {
registry.join(reference, cx);
});
true
})
});
}
+2 -1
View File
@@ -1,6 +1,7 @@
pub mod import;
pub mod join_group;
pub mod new_chat;
pub mod new_community;
pub mod new_group;
pub mod restore;
pub mod screening;
pub mod settings;
@@ -1,36 +0,0 @@
use community::{CommunityMetadata, CommunityRegistry};
use gpui::{App, AppContext, ParentElement, Window, px};
use ui::WindowExtension;
use ui::input::{Input, InputState};
pub fn open(window: &mut Window, cx: &mut App) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name"));
window.open_dialog(cx, move |this, _window, _cx| {
let name_input = name_input.clone();
this.width(px(380.))
.confirm()
.title("New community")
.child(Input::new(&name_input))
.on_ok(move |_event, _window, cx| {
let community = CommunityRegistry::global(cx);
let name = name_input.read(cx).value().trim().to_owned();
if name.is_empty() {
return false;
}
let metadata = CommunityMetadata {
name,
..CommunityMetadata::default()
};
community.update(cx, |registry, cx| {
registry.create(metadata, cx);
});
true
})
});
}
+106
View File
@@ -0,0 +1,106 @@
use gpui::{App, AppContext, Div, Entity, ParentElement, Styled, Window, px};
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name"));
let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id"));
let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
window.open_dialog(cx, move |this, _window, cx| {
let name = name.clone();
let id = id.clone();
let relay = relay.clone();
this.width(px(380.))
.confirm()
.title("New group")
.child(
v_flex()
.gap_3()
.child(field("Name", &name, cx))
.child(field("Group id", &id, cx))
.child(field("Relay", &relay, cx)),
)
.on_ok(move |_event, window, cx| create(&name, &id, &relay, window, cx))
});
}
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(label)
.child(Input::new(input))
}
fn create(
name: &Entity<InputState>,
id: &Entity<InputState>,
relay: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) -> bool {
let name_value = name.read(cx).value().trim().to_owned();
let raw_id = id.read(cx).value().trim().to_owned();
let raw_relay = relay.read(cx).value().trim().to_owned();
if name_value.is_empty() {
window.push_notification(Notification::error("Enter a group name"), cx);
return false;
}
let id = if raw_id.is_empty() {
slug(&name_value)
} else {
raw_id
};
let id = match GroupId::new(id) {
Ok(id) => id,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
let relay = match RelayUrl::parse(&raw_relay) {
Ok(relay) => relay,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
let mut metadata = GroupMetadata::default();
metadata.name = Some(name_value);
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.create(GroupKey::new(relay, id), metadata, cx);
});
true
}
fn slug(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
let mut separator = false;
for character in name.chars() {
if character.is_ascii_alphanumeric() {
slug.push(character.to_ascii_lowercase());
separator = false;
} else if !separator && !slug.is_empty() {
slug.push('-');
separator = true;
}
}
slug.trim_end_matches('-').to_owned()
}
+10 -5
View File
@@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind};
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{new_chat, new_community, settings};
use crate::dialogs::{join_group, new_chat, new_group, settings};
use crate::panels::{
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
search,
@@ -60,7 +60,8 @@ enum Command {
ShowBrowse,
ShowSearch,
NewChat,
NewCommunity,
NewGroup,
JoinGroup,
}
pub struct Workspace {
@@ -282,7 +283,8 @@ impl Workspace {
);
}
Command::ShowBrowse => {
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::ShowSearch => {
let panel = search::init(self.dock.downgrade(), window, cx);
@@ -291,8 +293,11 @@ impl Workspace {
Command::NewChat => {
new_chat::open(self.dock.downgrade(), window, cx);
}
Command::NewCommunity => {
new_community::open(window, cx);
Command::NewGroup => {
new_group::open(window, cx);
}
Command::JoinGroup => {
join_group::open(window, cx);
}
Command::ShowBackup => {
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
+265 -15
View File
@@ -1,32 +1,249 @@
use std::ops::Range;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window,
div, uniform_list,
};
use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::RelayUrl;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator;
use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(window, cx))
const TITLE: &str = "Browse";
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(dock, window, cx))
}
pub struct BrowsePanel {
name: SharedString,
focus_handle: FocusHandle,
dock: WeakEntity<DockArea>,
/// The relay input
input: Entity<InputState>,
/// The relay currently being browsed.
relay: Option<RelayUrl>,
/// The groups the relay has announced so far
candidates: Vec<GroupCandidate>,
/// Whether the relay is still answering
loading: bool,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl BrowsePanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER));
let registry = GroupsRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.browse(window, cx);
}
}),
);
subscriptions.push(
cx.subscribe(&registry, |this, _registry, event, cx| match event {
GroupsEvent::Browsed(candidate) => {
this.push_candidate(candidate.as_ref().clone(), cx)
}
GroupsEvent::BrowseSettled(relay) => {
this.settle(relay.clone(), cx);
}
_ => {}
}),
);
Self {
name: "Browse".into(),
focus_handle: cx.focus_handle(),
dock,
input,
relay: None,
candidates: Vec::new(),
loading: false,
_subscriptions: subscriptions,
}
}
/// Browse the relay in the input.
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = GroupsRegistry::global(cx);
let value = self.input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
let relay = match RelayUrl::parse(&value) {
Ok(relay) => relay,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return;
}
};
self.relay = Some(relay.clone());
self.candidates.clear();
self.loading = true;
registry.update(cx, |registry, cx| {
registry.browse(relay, cx);
});
cx.notify();
}
fn push_candidate(&mut self, candidate: GroupCandidate, cx: &mut Context<Self>) {
if self.relay.as_ref() != Some(candidate.key.relay()) {
return;
}
match self.candidates.iter_mut().find(|e| e.key == candidate.key) {
Some(existing) => *existing = candidate,
None => self.candidates.push(candidate),
}
cx.notify();
}
/// The relay stopped answering: EOSE, a refusal, or the auto-close timeout.
fn settle(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
if self.relay.as_ref() != Some(&relay) {
return;
}
self.loading = false;
cx.notify();
}
/// Join a browsed group and open it.
fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
let dock = self.dock.clone();
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.join_key(key.clone(), None, cx);
});
let Some(group) = registry.read(cx).group(&key, cx) else {
return;
};
ui::dock::add_panel_to(
&dock,
PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center,
window,
cx,
);
}
fn render_rows(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let panel = cx.entity().downgrade();
let registry = GroupsRegistry::global(cx);
let joined: Vec<GroupKey> = registry
.read(cx)
.groups()
.iter()
.map(|group| group.read(cx).key().clone())
.collect();
self.candidates
.get(range)
.into_iter()
.flatten()
.map(|candidate| {
let key = candidate.key.clone();
let name = SharedString::from(candidate.display_name().to_owned());
let picture = candidate
.display_image()
.map(|picture| SharedString::from(picture.to_owned()));
let seed = key.cache_tag();
let is_joined = joined.contains(&key);
let action = if is_joined {
Button::new(format!("browse-joined-{seed}"))
.label("Joined")
.small()
.disabled(true)
.into_any_element()
} else {
let panel = panel.clone();
let clicked = key.clone();
Button::new(format!("browse-join-{seed}"))
.label("Join")
.small()
.on_click(move |_event, window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.join(clicked.clone(), window, cx);
}) {
log::error!("Failed to join a browsed group: {error}");
}
})
.into_any_element()
};
h_flex()
.w_full()
.gap_2()
.px_1p5()
.py_1()
.when(!hide_avatar, |this| {
this.child(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
})
.child(
v_flex()
.flex_1()
.min_w_0()
.child(div().text_sm().font_medium().truncate().child(name))
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.truncate()
.child(SharedString::from(key.id().as_str().to_owned())),
),
)
.child(action)
.into_any_element()
})
.collect()
}
}
fn notice(text: &'static str, cx: &App) -> AnyElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(text)
.into_any_element()
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
TITLE.into()
}
fn title(&self, cx: &App) -> AnyElement {
@@ -37,7 +254,7 @@ impl Panel for BrowsePanel {
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.child(TITLE)
.into_any_element()
}
}
@@ -52,11 +269,44 @@ impl Focusable for BrowsePanel {
impl Render for BrowsePanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
let count = self.candidates.len();
let body = if self.relay.is_none() {
notice("Enter a relay to browse its groups.", cx)
} else if count > 0 {
uniform_list(
"browse-groups",
count,
cx.processor(|this, range, _window, cx| this.render_rows(range, cx)),
)
.h_full()
.into_any_element()
} else if self.loading {
notice("Loading groups…", cx)
} else {
notice("No groups found.", cx)
};
v_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
.child(
h_flex()
.gap_2()
.p_3()
.flex_shrink_0()
.child(Input::new(&self.input).cleanable(true).flex_1())
.child(
Button::new("browse-go")
.label("Go")
.primary()
.on_click(cx.listener(|this, _event, window, cx| {
this.browse(window, cx);
})),
)
.when(self.loading, |this| {
this.child(Indicator::new().small().color(cx.theme().icon_accent))
}),
)
.child(div().flex_1().min_h_0().child(body))
}
}
+72 -316
View File
@@ -6,7 +6,6 @@ use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt;
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
@@ -14,6 +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 nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -44,64 +44,37 @@ mod tab;
mod utils;
use tab::SidebarTab;
pub(crate) use utils::{nav_icon, pick_banner};
pub(crate) use utils::pick_banner;
pub enum SidebarRow {
Room { room: Entity<Room> },
Community { community: Entity<Community> },
Group { group: Entity<Group> },
Contact { public_key: PublicKey },
}
/// A collapsible group of rows in the sidebar's community view.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CommunitySection {
Channels,
Admins,
Members,
}
/// A row in the sidebar's community view.
pub enum CommunityRow {
Section(CommunitySection),
Channel {
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
},
Member {
public_key: PublicKey,
},
}
pub struct Sidebar {
focus_handle: FocusHandle,
scroll_handles: [UniformListScrollHandle; 2],
/// Scroll state of the community's channel and member lists
community_scroll: UniformListScrollHandle,
/// The dock the sidebar opens its panels in
dock: WeakEntity<DockArea>,
/// Background shown behind the signed-out screen, picked at random
/// Random background shown behind the signed-out screen.
banner: SharedString,
active_tab: SidebarTab,
/// The community the sidebar is browsing, if any
community: Option<WeakEntity<Community>>,
/// Whether the inbox list is showing contacts to start a chat with
/// Whether the inbox list is showing contacts to start a chat with.
contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens
/// The signed-in user's contacts, loaded when the contact picker opens.
contacts: Option<Vec<PublicKey>>,
/// Contacts selected in the contact picker
/// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list
/// Scroll state of the contact picker's list.
contacts_scroll: UniformListScrollHandle,
/// Whether the inbox list is showing chat requests instead of rooms
/// Whether the inbox list is showing chat requests instead of rooms.
requests_open: bool,
/// Scroll state of the requests list
/// Scroll state of the requests list.
requests_scroll: UniformListScrollHandle,
channels_open: bool,
admins_open: bool,
members_open: bool,
new_requests: bool,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
@@ -109,7 +82,7 @@ pub struct Sidebar {
impl Sidebar {
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let mut subscriptions = smallvec![];
@@ -123,23 +96,23 @@ impl Sidebar {
}),
);
// Keep the room and request counts in sync with the chat registry.
subscriptions.push(cx.observe(&chat, |_this, _chat, cx| {
subscriptions.push(
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
match event {
GroupsEvent::Updated => {
// TODO: find a better way?
cx.notify();
}));
subscriptions.push(cx.subscribe_in(
&communities,
window,
|_this, _communities, event, window, cx| {
if let CommunityEvent::Error(error) = event {
}
GroupsEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx);
}
},
));
_ => {}
};
}),
);
subscriptions.push(
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
cx.subscribe_in(&nostr, window, |this, _, event, _window, cx| {
// Re-pick the background each time the signed-out screen is shown.
if let StateEvent::NoSigner = event {
this.banner = pick_banner();
@@ -154,20 +127,15 @@ impl Sidebar {
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
],
community_scroll: UniformListScrollHandle::new(),
dock,
banner: pick_banner(),
active_tab: SidebarTab::Inbox,
community: None,
contacts_open: false,
contacts: None,
selected_contacts: HashSet::new(),
contacts_scroll: UniformListScrollHandle::new(),
requests_open: false,
requests_scroll: UniformListScrollHandle::new(),
channels_open: true,
admins_open: true,
members_open: true,
new_requests: false,
tasks: smallvec![],
_subscriptions: subscriptions,
@@ -328,14 +296,6 @@ impl Sidebar {
}));
}
/// Leave the community view, returning the sidebar to its tab list.
fn reset_community(&mut self, cx: &mut Context<Self>) {
if self.community.take().is_none() {
return;
}
cx.notify();
}
fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(current_user, cx);
@@ -418,18 +378,6 @@ impl Sidebar {
.when_some(AutoUpdater::try_global(cx), |this, updater| {
this.child(self.render_updater(updater, cx))
})
.when(self.community.is_some(), |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
.tooltip("Back")
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.reset_community(cx);
})),
)
})
}
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
@@ -504,7 +452,7 @@ impl Sidebar {
.segmented(true)
.selected_index(active_tab.index())
.child(Tab::new().label(SidebarTab::Inbox.label()))
.child(Tab::new().label(SidebarTab::Communities.label()))
.child(Tab::new().label(SidebarTab::Groups.label()))
.on_click({
let sidebar = sidebar.clone();
move |index, _window, cx| {
@@ -580,18 +528,28 @@ impl Sidebar {
})),
),
),
SidebarTab::Communities => this.child(
SidebarTab::Groups => this.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new(
"new-community",
"New Community",
"new-group",
"New Group",
Icon::new(IconName::Group).small(),
)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::NewCommunity), cx)
window.dispatch_action(Box::new(Command::NewGroup), cx)
}),
)
.child(
NavItem::new(
"join-group",
"Join Group",
Icon::new(IconName::Door).small(),
)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::JoinGroup), cx)
}),
)
.child(
@@ -668,127 +626,6 @@ impl Sidebar {
.into_any_element()
}
fn render_community(
&mut self,
community: Entity<Community>,
cx: &mut Context<Self>,
) -> AnyElement {
let (banner, rows) = {
let community = community.read(cx);
let owner = community.state().owner;
let mut admins = Vec::new();
let mut members = Vec::new();
for public_key in community.members() {
if community.control().roles.is_staff(public_key, &owner) {
admins.push(*public_key);
} else {
members.push(*public_key);
}
}
let active = community.active_channel();
let banner = community.banner();
let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)];
if self.channels_open {
rows.extend(
community
.channels()
.iter()
.map(|channel| CommunityRow::Channel {
id: channel.id,
name: channel.name.clone().into(),
private: channel.private,
selected: active == Some(channel.id),
}),
);
}
rows.push(CommunityRow::Section(CommunitySection::Admins));
if self.admins_open {
rows.extend(
admins
.into_iter()
.map(|public_key| CommunityRow::Member { public_key }),
);
}
rows.push(CommunityRow::Section(CommunitySection::Members));
if self.members_open {
rows.extend(
members
.into_iter()
.map(|public_key| CommunityRow::Member { public_key }),
);
}
(banner, rows)
};
let rows = Rc::new(rows);
v_flex()
.flex_1()
.min_h_0()
.w_full()
.gap_2()
.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()
.flex_1()
.child(
uniform_list(
"community-rows",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_community_rows(range, rows.as_slice(), &community, cx)
}),
)
.track_scroll(&self.community_scroll)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.community_scroll)),
)
.into_any_element()
}
fn render_community_rows(
&self,
range: Range<usize>,
rows: &[CommunityRow],
community: &Entity<Community>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
rows.get(range)
.into_iter()
.flatten()
.map(|row| match row {
CommunityRow::Section(section) => self.section_row(section, cx),
CommunityRow::Member { public_key } => self.member_row(public_key, cx),
CommunityRow::Channel {
id,
name,
private,
selected,
} => self.channel_row(*id, name.clone(), *private, *selected, community, cx),
})
.collect()
}
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab {
SidebarTab::Inbox if self.contacts_open => self
@@ -814,14 +651,14 @@ impl Sidebar {
.map(|room| SidebarRow::Room { room })
.collect()
}
SidebarTab::Communities => {
let registry = CommunityRegistry::global(cx);
SidebarTab::Groups => {
let registry = GroupsRegistry::global(cx);
registry
.read(cx)
.communities()
.groups()
.iter()
.cloned()
.map(|community| SidebarRow::Community { community })
.map(|group| SidebarRow::Group { group })
.collect()
}
}
@@ -929,17 +766,27 @@ impl Sidebar {
})
.into_any_element()
}
SidebarRow::Community { community } => {
let name = community.read(cx).name();
let seed = community.read(cx).id().to_hex();
let picture = community.read(cx).icon();
SidebarRow::Group { group } => {
let dock = self.dock.clone();
let sidebar = cx.entity().downgrade();
let community = community.clone();
let group = group.clone();
let name = group.read(cx).display_name();
let picture = group.read(cx).display_image();
let seed = group.read(cx).key().cache_tag();
let suffix = match group.read(cx).membership() {
Membership::Pending { .. } => Some("Requested"),
Membership::Refused { .. } => Some("Refused"),
Membership::Removed => Some("Removed"),
Membership::Unknown | Membership::Member => None,
}
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from);
Nav::new(index)
.label(name)
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
@@ -948,25 +795,23 @@ impl Sidebar {
.flex_shrink_0(),
)
})
.when_some(suffix, |this, suffix| {
this.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(suffix),
)
})
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(community_ui::init(
community.clone(),
window,
cx,
)),
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
DockPlacement::Center,
window,
cx,
);
if let Err(error) = sidebar.update(cx, |this, cx| {
this.community = Some(community.downgrade());
cx.notify();
}) {
log::error!("Failed to show community in sidebar: {error}");
}
})
.into_any_element()
}
@@ -974,87 +819,6 @@ impl Sidebar {
})
.collect()
}
fn section_row(&self, section: &CommunitySection, cx: &mut Context<Sidebar>) -> AnyElement {
let section = *section;
let (label, open) = match section {
CommunitySection::Channels => ("Channels", self.channels_open),
CommunitySection::Admins => ("Admins", self.admins_open),
CommunitySection::Members => ("Members", self.members_open),
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
Nav::new(label)
.label(label)
.suffix(nav_icon(icon, cx))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _ev, _window, cx| {
match section {
CommunitySection::Channels => this.channels_open = !this.channels_open,
CommunitySection::Admins => this.admins_open = !this.admins_open,
CommunitySection::Members => this.members_open = !this.members_open,
}
cx.notify();
}))
.into_any_element()
}
fn channel_row(
&self,
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
community: &Entity<Community>,
cx: &mut Context<Sidebar>,
) -> AnyElement {
let community = community.clone();
let icon = if private {
IconName::Lock
} else {
IconName::Hashtag
};
Nav::new(id.to_hex())
.label(name)
.prefix(nav_icon(icon, cx))
.text_sm()
.font_medium()
.selected(selected)
.on_click(cx.listener(move |_this, _event, _window, cx| {
community.update(cx, |community, cx| {
community.set_active_channel(id, cx);
});
cx.notify();
}))
.into_any_element()
}
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.into_any_element()
}
}
impl Panel for Sidebar {
@@ -1092,11 +856,6 @@ impl Render for Sidebar {
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
let community = self
.community
.clone()
.and_then(|community| community.upgrade());
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
@@ -1157,10 +916,7 @@ impl Render for Sidebar {
),
)
})
.map(|this| match community {
Some(community) => this.child(self.render_community(community, cx)),
None => this.child(self.render_tabs(cx)),
})
.child(self.render_tabs(cx))
.when(loading && logged_in, |this| {
this.child(
div()
+6 -6
View File
@@ -1,16 +1,16 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab {
Inbox,
Communities,
Groups,
}
impl SidebarTab {
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities];
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Groups];
pub fn label(self) -> &'static str {
match self {
Self::Inbox => "Inbox",
Self::Communities => "Communities",
Self::Groups => "Groups",
}
}
@@ -18,21 +18,21 @@ impl SidebarTab {
pub fn list_title(self) -> &'static str {
match self {
Self::Inbox => "Direct Messages",
Self::Communities => "Communities",
Self::Groups => "Groups",
}
}
pub fn list_id(self) -> &'static str {
match self {
Self::Inbox => "sidebar-inbox",
Self::Communities => "sidebar-communities",
Self::Groups => "sidebar-groups",
}
}
pub fn index(self) -> usize {
match self {
Self::Inbox => 0,
Self::Communities => 1,
Self::Groups => 1,
}
}
}
+1 -13
View File
@@ -1,18 +1,6 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px};
use theme::ActiveTheme;
use ui::{Icon, IconName, Sizable, h_flex};
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
h_flex()
.flex_shrink_0()
.w(px(20.))
.justify_center()
.text_color(cx.theme().icon_muted)
.child(Icon::new(icon).small())
.into_any_element()
}
use gpui::SharedString;
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
+384
View File
@@ -0,0 +1,384 @@
# NIP-29 migration plan
`crates/nip29` is complete and reviewed; `docs/nip29-usage.md` is its API contract. This
plan replaces the Concord stack — `crates/concord`, `crates/community` and
`crates/community_ui` — with it, and lays out the UI surfaces the backend was built for.
Every phase ends with the app building and running. The last phase deletes the old crates,
so nothing is removed until the replacement is in place.
## What is being replaced
| Today | Replacement |
| --- | --- |
| `crates/concord` (`cord01`–`cord06`, `state`, `derive`) | deleted — the relay owns identity, membership, roles and history |
| `crates/community` (`CommunityRegistry`, `Community`, `sync`, `cache`, `rekey`, `history`) | `crates/nip29` (`GroupsRegistry`, `Group`) — already built |
| `crates/community_ui` (`CommunityPanel`) | new `crates/nip29_ui` (`GroupPanel`) |
| Sidebar `Communities` tab + two-level community view (channels / admins / members) | one-level `Groups` tab; admins and members move into a details dialog |
| `dialogs/new_community.rs` | `dialogs/new_group.rs` and a new `dialogs/join_group.rs` |
| `panels/browse.rs` stub | a working browse panel over `GroupsRegistry::browse` |
| `settings.recent_communities` (dead, nothing reads it) | removed |
## Capability disposition
| Concord capability | On NIP-29 |
| --- | --- |
| End-to-end encrypted channels | Dropped. NIP-29 groups are relay-readable; `private` only means members-only read. |
| Channels inside a community | Dropped. A group is one timeline; subgroups are the only hierarchy. |
| Roles and control plane | Relay owns them (`39001`, `39003`); the client reads and displays, never derives authority. |
| Rekey, rotation, refounding, dissolution | Dropped with `cord06`; leaving and deleting are relay operations. |
| Invites | `9009` `create-invite` + the `?invite=` suffix on a group `naddr`. |
| Pins | `9010` → relay mirrors `39005`; the backend is ready. |
| Message deletion | `9005`; the local eviction lands in Phase 0. |
| Reactions | Kind `7` rows arrive as ordinary timeline rows; grouping them under the target is a later enhancement. |
| Members list | `39002` when the relay publishes it; may be absent or partial by design. |
| Community list | `10009`, owned entirely by the backend. |
| Multi-relay communities | One relay per group; migration and forks surface through `Group::elsewhere()`. |
| Attachments | Any kind the relay supports; v1 renders media URLs already parsed by `chat::Message`. |
## Rules the implementation keeps
Carried over from the backend review; they apply to every phase.
- New code goes into `crates/nip29` and `crates/nip29_ui`. Never create a `groups` crate.
- Implementation-block style: methods on the type they belong to, `self` first.
- Read model is subscribe → pump → envelope cache → hydrate. Never `fetch_events`.
- Short-lived subscriptions close on EOSE (`SubscribeAutoCloseOptions`), long-lived ones use a
prefix the pump recognises; dedup events by id before they mutate state.
- I/O runs on `cx.background_spawn`, called from a foreground `cx.spawn` task; tasks are
stored on the entity so they cancel with it.
- No comments, no docs, no tests inside the crates. `docs/` is the only place for prose.
- No premature optimization: scan the small vectors, don't build index maps.
- No `unwrap`, no `let _ =` on fallible calls; propagate with `?` and surface errors to the UI
(relay messages verbatim).
- Views read entities; protocol state never lives in a view. A panel re-reads on the event.
- UI follows the existing components (`ui::button`, `ui::input`, `ui::dock`, `ui::message`,
`ui::markdown`, `theme` tokens). Copy is sentence case; a dialog names its object
(`Leave "Pizza Lovers"?` with a `Leave` button).
- `Monitor` stays unadopted; the fork watch already works without relay status.
---
## Phase 0 — `9005` deletes reach local state
Today a `9005` stops the relay serving the event, but the envelope cache and the timeline keep
it. Everything else about deletion is done (the write, the `OK false` path).
Files: `crates/nip29/src/protocol.rs`, `crates/nip29/src/group.rs`, `crates/nip29/src/lib.rs`.
1. `TimelineWindow::remove(&mut self, id: EventId)` — drop the reference so a `previous` tag
never points at a deleted event.
2. `Group::forget(&mut self, id: EventId, cx: &mut Context<Self>)` (crate-private) —
`messages.retain`, `index.remove`, `window.remove`; when something was removed, emit
`GroupEvent::Updated` and `cx.notify()`. `hydrate` stays additive, so a later reload cannot
resurrect the row once its envelope is gone.
3. The registry's ingestion of our accepted delete: in `ensure`'s `GroupEvent::Sent` arm, when
`event.kind == Kind::Custom(9005)`, take the target from `event.tags.event_ids().next()`,
delete its envelope (`Kind::ApplicationSpecificData` + `Tag::identifier(target)` +
`r = key.cache_tag()`) on the background executor, wait for that to finish, then
`group.forget(target)` and the usual `reload(key, Some(event))`. The deletion must complete
before the reload reads the envelopes, or the reload would see the target still cached.
Remote deletes (another admin's `9005` arriving live) are out of scope; the standing REQ does
not carry them, and a relay that no longer serves the event cannot be asked for it.
Validation: `cargo clippy -p nip29 --all-targets`, `cargo fmt -p nip29`,
`cargo check -p coop -p coop_web`. Manually: as an admin, delete a message and confirm the row
disappears and stays gone after a restart.
---
## Phase 1 — `crates/nip29_ui`: the group panel
A new crate, modeled on `community_ui`, that renders one `Entity<Group>`.
Files: `crates/nip29_ui/Cargo.toml`, `crates/nip29_ui/src/lib.rs`,
`crates/nip29_ui/src/message.rs`; one line in `crates/chat_ui/src/lib.rs`.
```toml
# Cargo.toml
[dependencies]
nip29 = { path = "../nip29" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
```
Make `chat_ui`'s text helper shared by flipping `mod text;` to `pub mod text;`. It is the only
change to an existing UI crate in this phase.
```rust
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel>;
pub struct GroupPanel {
id: SharedString, // "group-<cache_tag>"
focus_handle: FocusHandle,
group: WeakEntity<Group>,
list_state: ListState,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
```
The panel keeps **no copy of the timeline**. Rows are `group.read(cx).messages()`; the only
local cache is parsed markdown keyed by event id. Deleting a message, a page arriving, or a
live tail all reduce to "the entity changed, re-read it" — no merge, no index.
Rows: `0` welcome, `1` load-older, then the messages.
- Welcome — `WelcomeMessage` with the group avatar; `Welcome to {name}` and
`This is the start of {name}.`
- Load older — visible when `group.oldest().is_some()`, disabled while
`group.loading_more()`, click → `GroupsRegistry::global(cx).update(|registry, cx| registry.load_more(&key, cx))`.
- Message — `MessageRow` with avatar, author (`PersonRegistry`), `created_at.to_human_time()`,
and `chat_ui::text::rendered_text(&message.content, &message.mentions, &persons, true, cx)`
cached in `rendered_texts_by_id`. Show the author on a run start (same author, gap under a
few minutes) like `community_ui` does. Render `message.media` as chat does: one image
full-width, several in a wrapped row.
Events: subscribe to the group and drive everything from it.
| `GroupEvent` | Panel |
| --- | --- |
| `Incoming` / `Updated` | sync `list_state` (`reset(item_count)` when the count changed) and `cx.notify()` |
| `Eose` | `cx.notify()` — `group.loading()` is now false |
| `Refused(reason)` | show the notice |
| `Sent` | ignore; the registry reloads through the same cache path |
Composer, gated exactly on the NIP's flags:
```rust
let metadata = group.metadata();
let supports_text = metadata.is_none_or(|m| m.supports(Kind::ChatMessage));
let writable = metadata.is_none_or(|m| !m.restricted) || matches!(group.membership(), Membership::Member);
let can_send = supports_text && writable;
```
Send mirrors `community_ui::send`: read `input.value()`, clear the textarea, spawn
`group.send_message(&text, vec![], cx)` and turn an `Err` into an
`autohide(false)` notification — that string is the relay's refusal, verbatim. The panel does
not reload anything; `GroupEvent::Sent` already routes the accepted event through the registry.
Notice above the composer (and centered when there are no messages):
| State | Notice | Action |
| --- | --- | --- |
| `group.refused()` | Relay message | none |
| not a member (`Unknown` / `Removed` / `Refused { reason }`) | `Request to join`, preceded by the refusal reason when there is one | `registry.join_key(key, None, cx)` |
| any of the above while `metadata.closed` | `This group is closed; an invite link is required.` | disabled |
| `Membership::Pending { .. }` | `Waiting for the relay to approve.` | disabled |
| `!supports_text` | `This group doesn't support text messages.` | disabled |
`Panel` impl: `panel_id` is `group-{cache_tag}` so a group opens once; `title` is the avatar
plus the name; `toolbar_buttons` starts empty (Phase 4 fills it).
Validation: `cargo clippy -p nip29_ui --all-targets`, `cargo fmt -p nip29_ui`,
`cargo check -p coop -p coop_web`. The crate is not reachable from the UI until Phase 2, so
review is by code and compile.
---
## Phase 2 — the sidebar and its dialogs
The `Communities` tab becomes one level: a list of groups, a `New Group` dialog and a
`Join Group` dialog. The two-level community view (channels, admins, members, the back
button) is removed; with no channels it has nothing to show, and details move to Phase 4's
dialog.
Files: `crates/workspace/src/sidebar/mod.rs`, `crates/workspace/src/sidebar/tab.rs`,
`crates/workspace/src/lib.rs`, `crates/workspace/src/dialogs/mod.rs`,
`crates/workspace/src/dialogs/new_group.rs` (replaces `new_community.rs`),
`crates/workspace/src/dialogs/join_group.rs`, `crates/workspace/Cargo.toml`.
Add `nip29` and `nip29_ui` to the workspace crate.
`tab.rs`: `Communities` → `Groups`, label and list title `Groups`, list id `sidebar-groups`.
`lib.rs`: `Command::NewCommunity` → `Command::NewGroup`; add `Command::JoinGroup`; both arms
open the dialogs below.
`mod.rs`:
- `SidebarRow::Community { community }` → `SidebarRow::Group { group: Entity<Group> }`.
- Delete `CommunitySection`, `CommunityRow`, `community_scroll`, `channels_open`,
`admins_open`, `members_open`, `Sidebar::community`, `reset_community`,
`render_community`, `render_community_rows`, `section_row`, `channel_row`, `member_row`,
and the back button in `render_user`. `render` loses its community branch.
- `rows_for(Groups)` reads `GroupsRegistry::global(cx).read(cx).groups()` in registry order
(the `10009` order, then joins).
- The group row: name from `metadata.name()`, falling back to `key.id().as_str()`; avatar from
`metadata.picture`, seeded with `key.cache_tag()`; suffix for the membership state —
`Requested` (`Pending`), `Refused`, `Removed`, and `Moved` when `elsewhere().is_some()`;
nothing for a plain member. Click opens
`PanelHandle::new(nip29_ui::init(group.clone(), window, cx))` in `DockPlacement::Center`.
- Subscriptions: replace the `CommunityRegistry` subscription with `GroupsRegistry` —
`GroupsEvent::Updated` → `cx.notify()`, `GroupsEvent::Error(error)` → notification.
- `render_tabs`'s Groups section: `New Group` (`Command::NewGroup`), `Join Group`
(`Command::JoinGroup`), `Browse` (`Command::ShowBrowse`, still the Phase 3 stub).
- Remove `dialogs/new_community.rs` and its `pub mod` line — nothing dispatches it any more.
The two dialogs, both modeled on `new_community.rs`:
**New group.** Three inputs — name, group id, relay — with ok/validation:
```rust
let id = GroupId::new(if raw_id.is_empty() { slug(&name) } else { raw_id })?;
let relay = RelayUrl::parse(relay_input)?;
registry.create(GroupKey::new(relay, id), GroupMetadata { name: Some(name), ..Default::default() }, cx);
```
A parse failure keeps the dialog open and pushes a notification. The relay's answer arrives
asynchronously as `GroupsEvent::Error(reason)` — the sidebar already shows it. There is no
default relay; the account has no "groups relay" setting yet, so the field starts empty.
**Join.** One naddr input:
```rust
let reference = GroupReference::parse(input)?; // accepts naddr1…?invite=CODE
GroupsRegistry::global(cx).update(cx, |registry, cx| registry.join(reference, cx));
```
Validation: `cargo check -p workspace`, `cargo clippy -p workspace --all-targets`,
`cargo fmt -p workspace`, then run the app: the tab lists whatever the account's `10009`
holds, no Concord screen is reachable from it, and a pasted naddr joins and opens.
---
## Phase 3 — browse panel
`panels/browse.rs` stops being a stub. `init` takes the dock (`WeakEntity<DockArea>`) the way
`search::init` does, so a joined group can be opened directly.
- Header: relay `Input` + a `Go` button; Enter submits. `RelayUrl::parse` → `registry.browse(relay, cx)`.
- Body: `registry.browsed(&relay)` — a spinner while `Browse::loading`, then rows of
`GroupCandidate`: avatar, name (or id), `id@relay`, and `Join`
(`registry.join_key(candidate.key, None, cx)`, then open the panel). Rows for keys already
in `registry.groups()` show `Joined` instead.
- Subscribes to `GroupsEvent::Updated` and re-renders. Empty state: `No groups found.`;
before a relay is entered: `Enter a relay to browse its groups.`
Validation: `cargo check -p workspace`; manually browse a public NIP-29 relay and join from a
result, and create a group on a relay that accepts `9007`.
---
## Phase 4 — details, delete, pins, leave
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 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
(`Group::pins()`). Read-only in this phase.
- **Delete a message** — admin-only entry in the message row's context menu (current user in
`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** — 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 `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.
---
## Phase 5 — admin writes
The remaining moderation surface, all through the calls the backend already exposes.
- **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** — 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.
---
## Phase 6 — delete the Concord stack
Nothing in the app may reference Concord before this phase starts.
1. Delete `crates/community`, `crates/concord`, `crates/community_ui`.
2. `crates/workspace/Cargo.toml`: drop `community` and `community_ui`.
`desktop/Cargo.toml` and `web/Cargo.toml`: drop `community`; `nip29` stays.
3. `desktop/src/main.rs` and `web/src/lib.rs`: drop `community::init(cx)` and its comment.
4. `crates/settings/src/lib.rs`: drop `recent_communities` and `record_recent_community` —
nothing reads them (verified), and the concept no longer exists.
5. `docs/nip29-usage.md`: replace the "Not wired up yet" section. Remaining known gaps then
are: LiveKit's response field names (a guess until checked against a real relay), the AV
room itself, local handling of remote `9005`s, the subgroup tree, and reaction grouping.
6. Confirm no `concord`/`community` string remains outside `docs/` history, and run a full
`cargo check -p coop -p coop_web` plus `cargo clippy --workspace --all-targets`.
---
## Later (not part of the migration)
- **LiveKit** — `Group::livekit_supported` and `Group::livekit_session` are ready; check
`metadata.livekit`, then build the AV room UI. Verify the token response fields first.
- **Subgroups** — `parent`/`children` are parsed; assemble the tree in the sidebar when the
relay advertises `nip29.subgroups`.
- **Replies and threads** — `send_message` already takes reply ids and `chat::Message` reads
them back; the panel needs a reply affordance and a quoted row.
- **Remote deletions** — fold a `9005` seen live into `Group::forget`, the same eviction as
Phase 0.
- **Recent groups** — if a local recency list is wanted, store group keys, not the removed
`recent_communities` strings.
## Validation checklist (every phase)
```sh
cargo clippy -p <touched-crates> --all-targets
cargo fmt -p <touched-crates> # check the exit code, not the output
cargo check -p coop -p coop_web
```
Plus the manual smoke for the phase, run in the app. The wasm target is not build-verified in
this repo; `coop_web` check is the proxy.
## Decisions taken
- The tab is renamed `Groups`; the vocabulary is the NIP's.
- The sidebar keeps one level; admins and members move to the details dialog.
- The panel reads `Group` directly and keeps no timeline copy.
- Mentions are rendered (shared `chat_ui::text::rendered_text`); replies and attachments are
not offered for sending in v1.
- `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.
- 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.
+65 -39
View File
@@ -58,16 +58,20 @@ subscribes to `GroupsEvent` and renders `groups()` — pending and refused group
are in that list alongside members, and their `Group::membership()` says which.
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub enum GroupsEvent {
Updated,
Error(String),
Browsed(Box<GroupCandidate>),
BrowseSettled(RelayUrl),
}
```
`Updated` fires on any change to `groups()` or to a browse result; `Error` is only
emitted when publishing a `9007` (create) is refused, and carries the relay's
message.
`Updated` fires on any change to `groups()`; `Error` is only emitted when
publishing a `9007` (create) is refused, and carries the relay's message.
`Browsed` and `BrowseSettled` carry a browse: one group the relay announced, and
the end of the answer (see [Browsing a relay](#browsing-a-relay)). They are
fan-out, not stored — whatever opened the browse owns the candidates.
## Value types
@@ -198,7 +202,8 @@ cx.subscribe(&group, |this, group, event, cx| match event {
The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`,
`participants`, `pins`, `membership`, `elsewhere`, `messages`, `loading`,
`loading_more`, `oldest`, `refused`.
`loading_more`, `oldest`, `refused`. `display_name` reads the group's name and
falls back to its id; `display_image` is the picture, for a row or avatar.
### The read model
@@ -353,10 +358,9 @@ reject anything it does not want.
must carry every child tag, because a `9002` that drops one is rejected by the
relay; the builder re-emits them from the struct.
`delete_event` is a **relay** instruction only. The relay stops serving the event,
but the local timeline keeps it: the backend does not evict the envelope or the
message, so a deletion the relay accepted is still visible locally until the UI
phase drops it. That is the one moderation write left incomplete (see below).
`delete_event` sends `9005`, and once the relay accepts it the registry drops the
target's cached envelope, evicts the row and re-reads the group, so the timeline stops
showing it. Remote deletions are not folded in: the standing REQ does not carry `9005`.
## Pins
@@ -435,23 +439,35 @@ check runs with the relay gone, just at the next tick.
## Browsing a relay
```rust
use nip29::Browse;
registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx));
let browse: Option<&Browse> = registry.read(cx).browsed(&relay_url);
// then, in a GroupsEvent handler:
match event {
GroupsEvent::Browsed(candidate) => push(candidate),
GroupsEvent::BrowseSettled(relay) => settled(relay),
_ => {}
}
```
`Browse` is `{ relay, loading, groups: Vec<GroupCandidate> }`, where a candidate
is a `GroupKey` plus its parsed `GroupMetadata`. `browse` connects the relay,
sends one auto-closing `39000` REQ (limit 500, EOSE or 10s) and returns
immediately; the pump folds each relay-signed metadata event into the result. Read
it back in a `GroupsEvent::Updated` handler, which also fires when the browse
settles (`loading` flips false on EOSE or a refusal). Join a result with
`join_key(candidate.key, None, cx)`.
A `GroupCandidate` is a `GroupKey` plus its parsed `GroupMetadata`; `display_name`
falls back to the id and `display_image` is the picture, the same pair `Group`
offers. `browse` connects the relay, sends one auto-closing `39000` REQ (limit
500, EOSE or 10s) and returns immediately; the pump parses each relay-signed
metadata event and re-emits it as `Browsed`.
One caveat: `Browse::loading` can stick if the relay accepts the REQ and never
EOSEs, because the auto-close timeout closes the subscription locally with no
notification. Treat it as "still trying", not as an error.
**The registry stores no browse result.** It keeps only which relay each `brw-`
REQ belongs to — the same routing bookkeeping as `routes`, and the reason
`Browsed` can carry a boxed candidate instead of the registry holding a `Vec`:
the only copy is the caller's. A browse is transient by nature, so the surface
that asked for it owns it (the browse panel keeps `relay`, `candidates` and
`loading` in its own state, and drops them with the panel).
`BrowseSettled` is driven by the subscribe task itself, not by `EOSE`/`CLOSED`
notifications: it is sent once `subscribe(..).await` returns, which happens on
EOSE, on a refusal, or when the auto-close timeout fires. So the answer always
ends, even against a relay that never EOSEs. A candidate can still arrive after
the settle — the pump and the subscribe task are different producers — which is
why the relay stays mapped. Join a result with `join_key(candidate.key, None, cx)`.
## Creating a group
@@ -496,14 +512,22 @@ impl GroupsRegistry {
}
```
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`, the
live `browse` results, `routes` (subscription id → `GroupKey`), one
`Subscription` per group, the fork state, and its tasks.
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`,
`routes` (subscription id → `GroupKey`), the relay behind each live `brw-` REQ,
one `Subscription` per group, the fork state, and its tasks. It holds no browse
result: a browse is fan-out, and the caller keeps the candidates.
- `Group` owns everything the panel draws: metadata, admins/members/roles,
participants, pins, membership, the `Vec<Message>` timeline, the `previous`
window, `loading`/`loading_more`/`refused`.
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
lives in a view.
- The registry re-broadcasts every `GroupEvent::Updated` as `GroupsEvent::Updated`
and notifies itself, so a view that watches only the registry (the sidebar,
which renders `groups()` as rows) stays in sync when a group's metadata,
membership, pins or fork state change. `GroupEvent::Updated` fires for state and
membership only, never for an arriving message — that is `GroupEvent::Incoming`,
and it is why a rebuild of the row list does not happen per message. A view that
renders the timeline watches the `Group` itself.
### Foreground and background
@@ -545,24 +569,26 @@ before they reach the consumer, so a replay cannot double-hydrate.
## Not wired up yet
- **No screen consumes this crate yet.** The only references outside
`crates/nip29` are the two `nip29::init(cx)` calls, so the sidebar section, the
group panel and the join dialog are still to be built. The panel needs
`chat::Message` rendering lifted (or copied) out of `ChatPanel`: the data type
is already shared, only the row rendering is not. `crates/workspace/src/panels/browse.rs`
is the stub for browsing.
- **`9005` delete does not touch local state.** The relay stops serving the
event, but the envelope and the row stay until a reload. Dropping it needs a
filter on the target's `d` tag plus the group's `r` cache tag, and an eviction
from `messages` and the id index; nothing does that today.
- **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 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
`LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and
fails loudly on anything else. Re-check it against the relay actually targeted
before building the AV UI, or the join button fails silently.
- **A relay that never EOSEs sticks a spinner.** `Browse::loading` and
`Group::loading_more` both rely on EOSE or CLOSED, and the auto-close timeout
closes the subscription with no notification at all. Bind "load more" to
`Group::loading_more()` and give the user a way out.
- **A relay that never EOSEs bounds how long "load more" spins.**
`Group::loading_more` relies on EOSE or CLOSED, and the auto-close timeout
closes the subscription with no notification at all; bind "load more" to
`Group::loading_more()` and give the user a way out. A browse is fine by
contrast: `BrowseSettled` comes from the subscribe task returning, so it
always lands.
- **Subgroups are parsed, not assembled.** `parent`/`children` are on
`GroupMetadata` and the writes carry them; there is no tree builder, because
the tree is scoped to a relay and that is a view concern.