Compare commits
8
Commits
99124eaf1b
...
8c7c672675
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7c672675 | ||
|
|
ce1cff83ff | ||
|
|
5b87b2fe27 | ||
|
|
6e24a6033e | ||
|
|
6b068091c1 | ||
|
|
41045d4f88 | ||
|
|
dbdfa8ad67 | ||
|
|
cb10c2d26e |
Generated
+22
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
+73
-59
@@ -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) => {
|
||||
let key = group.read(cx).key().clone();
|
||||
this.reload(key, Some(event.clone()), cx);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(®istry, |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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&communities,
|
||||
window,
|
||||
|_this, _communities, event, window, cx| {
|
||||
if let CommunityEvent::Error(error) = event {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||
match event {
|
||||
GroupsEvent::Updated => {
|
||||
// TODO: find a better way?
|
||||
cx.notify();
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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,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"];
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user