12 Commits
Author SHA1 Message Date
reya 29f3c74d06 update ui 2026-09-20 09:53:39 +07:00
reya 3bbdc5ad34 add community ui 2026-09-20 09:45:15 +07:00
reya 10e53b9fcc update community backend 2026-09-20 09:32:39 +07:00
reya 5c9005d473 update concord bcakend 2026-09-20 09:23:27 +07:00
reya 9c735febc1 update sidebar 2026-09-20 09:02:57 +07:00
reya 8401628412 update sidebar for onboarding 2026-09-20 08:35:45 +07:00
reya 974f8302a1 update 2026-09-20 08:03:47 +07:00
reya 564382756d add recents tab 2026-09-20 07:54:25 +07:00
reya 52f92c226f wip 2026-09-19 20:58:56 +07:00
reya 323545ce16 wip 2026-09-19 20:46:07 +07:00
reya 9e47882fb1 remove pin 2026-09-19 18:34:39 +07:00
reya bd8bff4693 update 2026-09-19 18:14:18 +07:00
32 changed files with 2027 additions and 1744 deletions
Generated
+19
View File
@@ -1291,6 +1291,24 @@ dependencies = [
"state", "state",
] ]
[[package]]
name = "community_ui"
version = "1.0.2"
dependencies = [
"anyhow",
"common",
"community",
"gpui-pre",
"log",
"nostr-sdk",
"person",
"settings",
"smallvec",
"state",
"theme",
"ui",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.43" version = "0.4.43"
@@ -9345,6 +9363,7 @@ dependencies = [
"chat_ui", "chat_ui",
"common", "common",
"community", "community",
"community_ui",
"device", "device",
"gpui-pre", "gpui-pre",
"instant", "instant",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

+134 -2
View File
@@ -3,14 +3,19 @@ use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use concord::cord02::{ControlFold, ImageRef}; use concord::cord02::{ControlFold, ImageRef};
use concord::store::{ChannelKeyRef, CommunityState}; use concord::cord03::{self, ChatMessage, ReplyRef};
use concord::cord04::roles::{Permissions, citation_ok};
use concord::derive::channel_group_key;
use concord::store::{self, ChannelKeyRef, CommunityState};
use concord::{ChannelId, CommunityId, Epoch}; use concord::{ChannelId, CommunityId, Epoch};
use gpui::{AppContext, Context, EventEmitter, Task}; use gpui::{App, AppContext, Context, EventEmitter, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::NostrRegistry; use state::NostrRegistry;
use crate::sync::{self, Snapshot}; use crate::sync::{self, Snapshot};
const MESSAGE_LIMIT: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionKey { pub struct SubscriptionKey {
control_pks: BTreeMap<u64, PublicKey>, control_pks: BTreeMap<u64, PublicKey>,
@@ -39,6 +44,7 @@ impl SubscriptionKey {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum CommunityEvent { pub enum CommunityEvent {
Updated(CommunityId), Updated(CommunityId),
Open(CommunityId),
Error(String), Error(String),
} }
@@ -109,6 +115,132 @@ impl Community {
SubscriptionKey::of(&self.state) SubscriptionKey::of(&self.state)
} }
/// A public channel derives its plane from the community root; a private one uses its granted key.
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> {
let held = self
.state
.channels
.iter()
.find(|held| held.id == *channel)?;
if held.private {
return held.key.map(|key| (held.epoch, key));
}
Some((held.epoch, self.state.community_root))
}
/// Page a channel's history into the local cache, once per channel.
pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task<Result<()>> {
let Some((epoch, secret)) = self.channel_secret(channel) else {
return Task::ready(Ok(()));
};
let client = NostrRegistry::global(cx).read(cx).client();
let channel = *channel;
cx.background_spawn(async move {
if !store::query_rumors(&client, &channel, None, 1)
.await?
.is_empty()
{
return Ok(());
}
store::backfill(&client, &channel, &[(epoch, secret)], None, MESSAGE_LIMIT).await?;
Ok(())
})
}
/// The channel's timeline, folded from the local cache, oldest first.
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>> {
let client = NostrRegistry::global(cx).read(cx).client();
let channel = *channel;
let owner = self.state.owner;
let community_id = self.state.id;
let floors = self.state.floors();
let roles = self.control.roles.clone();
cx.background_spawn(async move {
let cached = store::query_rumors(&client, &channel, None, MESSAGE_LIMIT).await?;
let mut rumors = Vec::with_capacity(cached.len());
for rumor in &cached {
match cord03::parse_rumor(rumor) {
Ok(chat) => rumors.push(chat),
Err(error) => {
log::warn!("community: skipping an unreadable cached rumor: {error}")
}
}
}
let mut messages =
cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| {
citation_ok(&owner, &community_id, actor, citation, &floors)
&& roles.can_act_on_member(
actor,
&owner,
author,
Permissions::MANAGE_MESSAGES,
)
});
messages.reverse();
Ok(messages)
})
}
/// Seal a message to the channel plane, cache it, then publish it to the relays.
pub fn send(
&self,
channel: &ChannelId,
content: &str,
reply_to: Option<ReplyRef>,
cx: &App,
) -> Option<Task<Result<EventId>>> {
let (epoch, secret) = self.channel_secret(channel)?;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let author = nostr.read(cx).current_user()?;
let channel = *channel;
let relays = self.state.relays.clone();
let timer = self
.control
.community
.as_ref()
.and_then(|metadata| metadata.message_expiration);
let content = content.to_owned();
Some(cx.background_spawn(async move {
let group = channel_group_key(&secret, &channel, epoch)?;
let at_ms = Timestamp::now().as_secs().saturating_mul(1000);
let rumor = cord03::build_message(
author,
&channel,
epoch,
&content,
reply_to.as_ref(),
at_ms,
timer,
);
let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?;
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
store::cache_rumor(&client, &channel, &opened).await?;
sync::connect_relays(&client, &relays).await;
sync::publish_wrap(&client, &wrap, &relays).await;
Ok(opened.rumor_id)
}))
}
/// Rebuilds the community from the wraps in the local database. /// Rebuilds the community from the wraps in the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh_task.is_some() { if self.refresh_task.is_some() {
+17 -2
View File
@@ -1,11 +1,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use anyhow::Result; use anyhow::Result;
use concord::CommunityId;
use concord::cord01::KIND_WRAP; use concord::cord01::KIND_WRAP;
pub use concord::cord02::CommunityMetadata; pub use concord::cord02::CommunityMetadata;
pub use concord::cord03::{ChatMessage, ReplyRef};
use concord::store::CommunityState; use concord::store::CommunityState;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; pub use concord::{ChannelId, CommunityId};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
@@ -107,6 +108,20 @@ impl CommunityRegistry {
self.index.get(id).cloned() self.index.get(id).cloned()
} }
/// Ask the workspace to open a community's panel.
pub fn emit_community(
&mut self,
community: &Entity<Community>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = community.read(cx).id();
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(CommunityEvent::Open(id));
});
}
/// Create a community owned by the current account and begin tracking it. /// Create a community owned by the current account and begin tracking it.
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) { pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
+28 -18
View File
@@ -142,31 +142,41 @@ where
/// Best-effort publication of the genesis wraps to the community's relays. /// Best-effort publication of the genesis wraps to the community's relays.
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
for url in relays { connect_relays(client, relays).await;
if let Err(error) = client.add_relay(url).and_connect().await {
log::warn!("community genesis: failed to add relay {url}: {error}");
}
}
for wrap in wraps { for wrap in wraps {
let sent = if relays.is_empty() { publish_wrap(client, wrap, relays).await;
client.send_event(wrap).broadcast().await }
} else { }
client.send_event(wrap).to(relays.iter().cloned()).await
};
match sent { /// Bring the community's relays into the pool before anything is sent through them.
Ok(output) if output.failed.is_empty() => {} pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) {
Ok(output) => log::warn!( for url in relays {
"community genesis: {} relay(s) rejected {}", if let Err(error) = client.add_relay(url).and_connect().await {
output.failed.len(), log::warn!("community: failed to add relay {url}: {error}");
wrap.id
),
Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id),
} }
} }
} }
/// Best-effort publication of a single wrap to the community's relays.
pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) {
let sent = if relays.is_empty() {
client.send_event(wrap).broadcast().await
} else {
client.send_event(wrap).to(relays.iter().cloned()).await
};
match sent {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!(
"community: {} relay(s) rejected {}",
output.failed.len(),
wrap.id
),
Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id),
}
}
async fn record_membership<S>( async fn record_membership<S>(
client: &Client, client: &Client,
signer: &S, signer: &S,
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "community_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
community = { path = "../community" }
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
log.workspace = true
+482
View File
@@ -0,0 +1,482 @@
use anyhow::Result;
use community::{ChannelId, ChatMessage, Community, CommunityEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{InputEvent, Textarea, TextareaState};
use ui::notification::Notification;
use ui::scroll::{ScrollableElement, Scrollbar};
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
mod message;
pub fn init(
community: Entity<Community>,
window: &mut Window,
cx: &mut App,
) -> Entity<CommunityPanel> {
cx.new(|cx| CommunityPanel::new(community, window, cx))
}
/// Community Panel
pub struct CommunityPanel {
id: SharedString,
focus_handle: FocusHandle,
/// Community
community: WeakEntity<Community>,
/// The selected channel
channel: Option<ChannelId>,
/// The selected channel's timeline (oldest first)
messages: Vec<ChatMessage>,
/// Message list state
list_state: ListState,
/// Message input state
input: Entity<TextareaState>,
/// Async operations
tasks: Vec<Task<Result<()>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl CommunityPanel {
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let (id, name, channel) = {
let community = community.read(cx);
(
SharedString::from(format!("community-{}", community.id().to_hex())),
community.name(),
community.channels().first().map(|channel| channel.id),
)
};
let input = cx.new(|cx| {
TextareaState::new(window, cx)
.placeholder(format!("Message {name}"))
.auto_grow(1, 20)
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.send(window, cx);
}
}),
);
subscriptions.push(cx.subscribe_in(
&community,
window,
|_this, _community, event, window, cx| match event {
// The fold holds the community, so reload once it is released.
CommunityEvent::Updated(_) => {
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
}
CommunityEvent::Error(error) => {
window
.push_notification(Notification::error(error.clone()).autohide(false), cx);
}
CommunityEvent::Open(_) => {}
},
));
let panel = Self {
id,
focus_handle: cx.focus_handle(),
community: community.downgrade(),
channel,
messages: Vec::new(),
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
input,
tasks: Vec::new(),
_subscriptions: subscriptions,
};
cx.defer_in(window, |this, window, cx| this.load(window, cx));
panel
}
/// Page the selected channel's history into the cache, then read it back.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(channel) = self.channel else {
return;
};
let Ok(backfill) = self
.community
.read_with(cx, |community, cx| community.backfill(&channel, cx))
else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = backfill.await {
log::warn!("community panel: backfill failed: {error}");
}
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
Ok(())
}));
}
/// Replace the timeline with the selected channel's folded messages.
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(channel) = self.channel else {
return;
};
let Ok(messages) = self
.community
.read_with(cx, |community, cx| community.messages(&channel, cx))
else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match messages.await {
Ok(messages) => {
this.update(cx, |this, cx| {
this.messages = messages;
this.list_state.reset(this.messages.len());
this.list_state.scroll_to_end();
cx.notify();
})?;
}
Err(error) => {
this.update_in(cx, |_this, window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
}));
}
fn select_channel(&mut self, channel: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
if self.channel == Some(channel) {
return;
}
self.channel = Some(channel);
self.messages.clear();
self.list_state.reset(0);
cx.notify();
self.load(window, cx);
}
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let content = self.input.read(cx).value().trim().to_owned();
if content.is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
let Some(channel) = self.channel else {
return;
};
let Ok(send) = self.community.read_with(cx, |community, cx| {
community.send(&channel, &content, None, cx)
}) else {
return;
};
let Some(send) = send else {
window.push_notification(Notification::error("Failed to send the message"), cx);
return;
};
self.input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match send.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
}
Err(error) => {
this.update_in(cx, |_this, window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
}));
}
fn render_channel(
&self,
id: ChannelId,
name: &str,
private: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let selected = self.channel == Some(id);
h_flex()
.id(SharedString::from(format!(
"community-channel-{}",
id.to_hex()
)))
.w_full()
.h_8()
.flex_shrink_0()
.gap_2()
.px_2()
.rounded(cx.theme().radius)
.cursor_pointer()
.when(selected, |this| this.bg(cx.theme().ghost_element_selected))
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
Icon::new(if private {
IconName::Lock
} else {
IconName::Message
})
.small()
.text_color(cx.theme().icon_muted),
)
.child(
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.child(SharedString::from(name.to_owned())),
)
.on_click(cx.listener(move |this, _event, window, cx| {
this.select_channel(id, window, cx);
}))
.into_any_element()
}
fn render_timeline(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
v_flex()
.flex_1()
.min_h_0()
.relative()
.map(|this| {
if self.messages.is_empty() {
this.child(
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child("No messages yet"),
)
} else {
this.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
this.render_message(ix, window, cx)
}),
)
.size_full(),
)
}
})
.child(Scrollbar::vertical(&self.list_state)),
)
.child(self.render_composer(cx))
}
fn render_message(
&mut self,
ix: usize,
_window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let Some(message) = self.messages.get(ix) else {
return div().into_any_element();
};
message::render(ix, message, cx)
}
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.flex_shrink_0()
.w_full()
.p_2()
.gap_1()
.items_end()
.border_t_1()
.border_color(cx.theme().border)
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
)
}
}
impl Panel for CommunityPanel {
fn panel_id(&self) -> SharedString {
self.id.clone()
}
fn title(&self, cx: &App) -> AnyElement {
self.community
.read_with(cx, |community, _cx| {
let seed = community.id().to_hex();
let avatar = match community.icon() {
Some(path) => Avatar::from_source(path).seed(seed).xsmall(),
None => Avatar::new(None).seed(seed).xsmall(),
};
h_flex()
.gap_1p5()
.child(avatar)
.child(SharedString::from(community.name()))
.into_any_element()
})
.unwrap_or_else(|_| div().child("Unknown").into_any_element())
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for CommunityPanel {}
impl Focusable for CommunityPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CommunityPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(community) = self.community.upgrade() else {
return div().size_full();
};
let (channels, members) = {
let community = community.read(cx);
(
community
.channels()
.iter()
.map(|channel| (channel.id, channel.name.clone(), channel.private))
.collect::<Vec<_>>(),
community.members().iter().copied().collect::<Vec<_>>(),
)
};
v_flex()
.size_full()
.flex_row()
.child(
v_flex()
.id("community-sidebar")
.w(px(220.))
.h_full()
.flex_shrink_0()
.gap_1()
.p_2()
.border_r_1()
.border_color(cx.theme().border)
.overflow_y_scrollbar()
.child(section_label("Channels", cx))
.children(
channels.iter().map(|(id, name, private)| {
self.render_channel(*id, name, *private, cx)
}),
)
.child(section_label("Members", cx))
.children(
members
.iter()
.map(|public_key| render_member(*public_key, cx)),
),
)
.child(self.render_timeline(cx))
}
}
fn section_label(label: &str, cx: &App) -> impl IntoElement {
div()
.px_2()
.pt_2()
.pb_1()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from(label.to_owned()))
}
fn render_member(public_key: PublicKey, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(&public_key, cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
h_flex()
.w_full()
.h_8()
.flex_shrink_0()
.gap_2()
.px_2()
.when(!hide_avatar, |this| {
this.child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.xsmall(),
)
})
.child(
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.child(person.name()),
)
.into_any_element()
}
+108
View File
@@ -0,0 +1,108 @@
use std::collections::BTreeMap;
use common::TimestampExt;
use community::ChatMessage;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::{StyledExt, h_flex, v_flex};
pub(crate) fn render(ix: usize, message: &ChatMessage, 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);
div()
.id(ix)
.w_full()
.py_1()
.px_3()
.hover(|this| this.bg(cx.theme().surface_background))
.child(
h_flex()
.items_start()
.gap_3()
.when(!hide_avatar, |this| {
this.child(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0(),
)
})
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.child(
h_flex()
.gap_2()
.text_sm()
.child(div().font_semibold().child(author.name()))
.child(
div()
.text_color(cx.theme().text_placeholder)
.child(Timestamp::from_secs(message.at_ms / 1000).to_ago()),
)
.when(message.edited_at.is_some(), |this| {
this.child(
div()
.text_color(cx.theme().text_placeholder)
.child("(edited)"),
)
}),
)
.child(content(message, cx))
.when(!message.reactions.is_empty(), |this| {
this.child(reactions(message, cx))
}),
),
)
.into_any_element()
}
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
if message.deleted {
return div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child("Message deleted")
.into_any_element();
}
div()
.text_sm()
.child(SharedString::from(message.content.clone()))
.into_any_element()
}
fn reactions(message: &ChatMessage, cx: &App) -> AnyElement {
let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
for emoji in message.reactions.values() {
*grouped.entry(emoji.as_str()).or_default() += 1;
}
h_flex()
.mt_1()
.gap_1()
.children(grouped.into_iter().map(|(emoji, count)| {
h_flex()
.gap_1()
.py_0p5()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.text_xs()
.child(SharedString::from(emoji))
.child(SharedString::from(count.to_string()))
}))
.into_any_element()
}
+6 -3
View File
@@ -17,8 +17,8 @@ pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535; pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
const TAG_MS: &str = "ms"; const TAG_MS: &str = "ms";
const TAG_CHANNEL: &str = "channel"; pub(crate) const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch"; pub(crate) const TAG_EPOCH: &str = "epoch";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealForm { pub enum SealForm {
@@ -420,7 +420,10 @@ fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
Ok(()) Ok(())
} }
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> { pub(crate) fn unique_tag(
rumor: &UnsignedEvent,
name: &'static str,
) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None; let mut found: Option<String> = None;
for tag in rumor.tags.iter() { for tag in rumor.tags.iter() {
+17 -2
View File
@@ -5,8 +5,9 @@ use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::cord01::{ use crate::cord01::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal, KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, TAG_CHANNEL, TAG_EPOCH, build_rumor_ms,
channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal, build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
unique_tag, wrap_seal,
}; };
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as ChatError; pub use crate::cords::rumor::RumorError as ChatError;
@@ -313,6 +314,20 @@ pub fn open(
Ok((opened, chat)) Ok((opened, chat))
} }
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
.parse()
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
let epoch = unique_tag(rumor, TAG_EPOCH)?
.ok_or(ChatError::MissingTag(TAG_EPOCH))
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
typed(rumor, &channel, Epoch(epoch))
}
/// `secret` is the `community_root` for a public channel, its own key for a private one. /// `secret` is the `community_root` for a public channel, its own key for a private one.
pub fn plane_keys( pub fn plane_keys(
held: &[(Epoch, [u8; 32])], held: &[(Epoch, [u8; 32])],
+16 -13
View File
@@ -46,10 +46,11 @@ setting_accessors! {
pub nip4e: bool, pub nip4e: bool,
pub trusted_relays: Vec<String>, pub trusted_relays: Vec<String>,
pub file_server: Url, pub file_server: Url,
pub pinned_rooms: Vec<u64>, pub recent_communities: Vec<String>,
pub expanded_sections: Option<Vec<String>>,
} }
const RECENT_COMMUNITIES_CAP: usize = 10;
/// Signer kind /// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind { pub enum SignerKind {
@@ -133,13 +134,9 @@ pub struct Settings {
/// Server for blossom media attachments /// Server for blossom media attachments
pub file_server: Url, pub file_server: Url,
/// Pinned sidebar room ids, in pin order /// Recently opened community ids, newest first
#[serde(default)] #[serde(default)]
pub pinned_rooms: Vec<u64>, pub recent_communities: Vec<String>,
/// Expanded sidebar tree sections; `None` means the default sections
#[serde(default)]
pub expanded_sections: Option<Vec<String>>,
} }
impl Default for Settings { impl Default for Settings {
@@ -152,8 +149,7 @@ impl Default for Settings {
nip4e: false, nip4e: false,
trusted_relays: vec![], trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
pinned_rooms: vec![], recent_communities: vec![],
expanded_sections: None,
} }
} }
} }
@@ -172,7 +168,6 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings { pub struct AppSettings {
/// Settings /// Settings
inner: Entity<Settings>, inner: Entity<Settings>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
@@ -184,8 +179,6 @@ impl AppSettings {
} }
/// The underlying settings entity, which notifies whenever any field changes. /// The underlying settings entity, which notifies whenever any field changes.
/// Settings load asynchronously, so observers can watch it to pick up values
/// that arrive after construction.
pub fn entity(&self) -> &Entity<Settings> { pub fn entity(&self) -> &Entity<Settings> {
&self.inner &self.inner
} }
@@ -339,4 +332,14 @@ impl AppSettings {
} }
}); });
} }
/// Move a community to the front of the recently opened list
pub fn record_recent_community(&mut self, id: String, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
this.recent_communities.retain(|existing| existing != &id);
this.recent_communities.insert(0, id);
this.recent_communities.truncate(RECENT_COMMUNITIES_CAP);
cx.notify();
});
}
} }
+25 -1
View File
@@ -87,6 +87,9 @@ pub struct NostrRegistry {
/// Current user's public key /// Current user's public key
current_user: Option<PublicKey>, current_user: Option<PublicKey>,
/// Whether the initial credential check has concluded
ready: bool,
/// Tasks for asynchronous operations /// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -140,6 +143,7 @@ impl NostrRegistry {
this.connect_bootstrap_relays(cx); this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
this.mark_ready(cx);
cx.emit(StateEvent::NoSigner); cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key { } else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential // Use CLI-provided key -- same path as get_user_credential
@@ -156,6 +160,7 @@ impl NostrRegistry {
client, client,
signer, signer,
current_user: None, current_user: None,
ready: false,
tasks: vec![], tasks: vec![],
} }
} }
@@ -175,6 +180,20 @@ impl NostrRegistry {
self.current_user self.current_user
} }
/// Whether the initial credential check has concluded
pub fn ready(&self) -> bool {
self.ready
}
fn mark_ready(&mut self, cx: &mut Context<Self>) {
if self.ready {
return;
}
self.ready = true;
cx.notify();
}
/// Update the signer /// Update the signer
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>) pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where where
@@ -189,6 +208,7 @@ impl NostrRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.signer.swap_inner(new_signer); this.signer.swap_inner(new_signer);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.mark_ready(cx);
cx.emit(StateEvent::SignerChanged); cx.emit(StateEvent::SignerChanged);
cx.notify(); cx.notify();
})?; })?;
@@ -275,12 +295,16 @@ impl NostrRegistry {
} else if content == "proxy" { } else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.mark_ready(cx);
this.connect_proxy(cx); this.connect_proxy(cx);
})?; })?;
} else {
this.update(cx, |this, cx| this.mark_ready(cx))?;
} }
} }
_ => { _ => {
this.update(cx, |_, cx| { this.update(cx, |this, cx| {
this.mark_ready(cx);
cx.emit(StateEvent::NoSigner); cx.emit(StateEvent::NoSigner);
})?; })?;
} }
+1 -1
View File
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
pub const TITLEBAR_HEIGHT: Pixels = px(36.0); pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height /// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(44.0); pub const TABBAR_HEIGHT: Pixels = px(36.0);
/// Defines default sidebar width /// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.); pub const SIDEBAR_WIDTH: Pixels = px(240.);
-6
View File
@@ -116,26 +116,20 @@ pub trait ButtonVariants: Sized {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub struct Button { pub struct Button {
base: BaseButton, base: BaseButton,
icon: Option<Icon>, icon: Option<Icon>,
label: Option<SharedString>, label: Option<SharedString>,
tooltip: Option<SharedString>, tooltip: Option<SharedString>,
children: Vec<AnyElement>, children: Vec<AnyElement>,
variant: ButtonVariant, variant: ButtonVariant,
size: Size, size: Size,
disabled: bool, disabled: bool,
loading: bool, loading: bool,
rounded: bool, rounded: bool,
compact: bool, compact: bool,
caret: bool, caret: bool,
indicator: bool, indicator: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>, on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
tab_index: isize, tab_index: isize,
tab_stop: bool, tab_stop: bool,
+53 -1
View File
@@ -24,7 +24,7 @@ use crate::menu::DropdownMenu as _;
use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab; use crate::tab::Tab;
use crate::tab::tab_bar::TabBar; use crate::tab::tab_bar::TabBar;
use crate::title_bar::{title_bar_drag_handlers, window_controls}; use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
mod panel; mod panel;
@@ -429,6 +429,42 @@ impl TabGroupSkin {
== Some(group.node()) == Some(group.node())
} }
/// Whether this group is the left dock's root with a single panel.
///
/// Such a group draws no tab bar, so its panel owns the window's top-left
/// corner — including the space the macOS traffic lights overlay.
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
let area = area.read(cx);
area.layout(DockPlacement::Left)
.map(|tree| tree.root().id())
== Some(group.node())
&& group.panels().len() == 1
}
/// Whether this group is the topmost-left group on screen, which sits under
/// the native macOS traffic lights. The left dock's group is leftmost while
/// it is open and holds a panel; the center's is leftmost otherwise.
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
let area = area.read(cx);
let left_open =
area.is_dock_open(DockPlacement::Left) && !area.is_empty(DockPlacement::Left, cx);
let tree = if left_open {
area.layout(DockPlacement::Left)
} else {
area.layout(DockPlacement::Center)
};
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
}
fn render_toolbar( fn render_toolbar(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -509,6 +545,8 @@ impl TabGroupSkin {
let has_leading = left_button.is_some() || bottom_button.is_some(); let has_leading = left_button.is_some() || bottom_button.is_some();
let drag = tab_drag(group, ix, cx); let drag = tab_drag(group, ix, cx);
let is_title_bar = self.is_title_bar_group(group, cx); let is_title_bar = self.is_title_bar_group(group, cx);
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx)) .then(|| self.shared.chrome.trailing(window, cx))
.flatten(); .flatten();
@@ -532,6 +570,9 @@ impl TabGroupSkin {
.children(bottom_button), .children(bottom_button),
) )
}) })
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.child( .child(
div() div()
.id("tab") .id("tab")
@@ -612,6 +653,8 @@ impl TabGroupSkin {
.position(|panel| panel.panel_id(cx) == displayed) .position(|panel| panel.panel_id(cx) == displayed)
}); });
let is_title_bar = self.is_title_bar_group(group, cx); let is_title_bar = self.is_title_bar_group(group, cx);
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx)) .then(|| self.shared.chrome.trailing(window, cx))
.flatten(); .flatten();
@@ -640,6 +683,9 @@ impl TabGroupSkin {
.track_scroll(&self.scroll_handle) .track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.when(is_title_bar || has_leading, |this| { .when(is_title_bar || has_leading, |this| {
this.prefix( this.prefix(
h_flex() h_flex()
@@ -834,6 +880,12 @@ impl TabGroupRenderer for TabGroupSkin {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> AnyElement {
// The left dock's only panel draws bare, so its content can own the
// window's top-left corner instead of a tab bar doing so.
if self.is_plain_left_group(group, cx) {
return Empty.into_any_element();
}
let visible: Vec<usize> = group let visible: Vec<usize> = group
.panels() .panels()
.iter() .iter()
+2
View File
@@ -66,6 +66,7 @@ pub enum IconName {
Ship, Ship,
Shield, Shield,
Group, Group,
History,
UserKey, UserKey,
Upload, Upload,
Usb, Usb,
@@ -145,6 +146,7 @@ impl IconNamed for IconName {
Self::Upload => "icons/upload.svg", Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg", Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg", Self::Group => "icons/group.svg",
Self::History => "icons/history.svg",
Self::PanelLeft => "icons/panel-left.svg", Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg", Self::PanelRight => "icons/panel-right.svg",
+1
View File
@@ -13,6 +13,7 @@ device = { path = "../device" }
chat = { path = "../chat" } chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" } chat_ui = { path = "../chat_ui" }
community = { path = "../community" } community = { path = "../community" }
community_ui = { path = "../community_ui" }
settings = { path = "../settings" } settings = { path = "../settings" }
person = { path = "../person" } person = { path = "../person" }
auto_update = { path = "../auto_update" } auto_update = { path = "../auto_update" }
+15 -2
View File
@@ -1,8 +1,8 @@
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, Subscription, Task, Window, div, px,
}; };
use instant::Duration; use instant::Duration;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
@@ -12,6 +12,19 @@ use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState}; use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex}; use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Onboarding")
.child(import.clone())
});
}
#[derive(Debug)] #[derive(Debug)]
pub struct ImportIdentity { pub struct ImportIdentity {
/// Secret key input /// Secret key input
+2 -1
View File
@@ -1,4 +1,5 @@
pub mod import; pub mod import;
pub mod new_chat;
pub mod new_community;
pub mod restore; pub mod restore;
pub mod screening;
pub mod settings; pub mod settings;
+118
View File
@@ -0,0 +1,118 @@
use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window, div, px,
};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{StyledExt, WindowExtension, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| NewChat::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(420.)).title("New chat").child(view.clone())
});
}
pub struct NewChat {
/// Public key input
input: Entity<InputState>,
/// Error message
error: Option<SharedString>,
/// Input subscription
_subscription: Option<Subscription>,
}
impl NewChat {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.start_chat(window, cx);
}
});
Self {
input,
error: None,
_subscription: Some(subscription),
}
}
fn start_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
let Ok(peer) = PublicKey::parse(&value) else {
self.set_error("Public key is invalid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let Some(current_user) = nostr.read(cx).current_user() else {
self.set_error("You are not signed in", cx);
return;
};
if peer == current_user {
self.set_error("You cannot chat with yourself", cx);
return;
}
let room = Room::new(current_user, [peer])
.organize(&current_user)
.kind(RoomKind::Ongoing);
let chat = ChatRegistry::global(cx);
chat.update(cx, |chat, cx| {
let room = cx.new(|_| room);
chat.emit_room(&room, window, cx);
});
window.close_modal(cx);
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
}
impl Render for NewChat {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Public key of the person you want to chat with")
.child(Input::new(&self.input)),
)
.child(
Button::new("start-chat")
.label("Start chat")
.primary()
.font_semibold()
.on_click(cx.listener(|this, _event, window, cx| {
this.start_chat(window, cx);
})),
)
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.child(error),
)
})
}
}
@@ -0,0 +1,34 @@
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_modal(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 name = name_input.read(cx).value().trim().to_owned();
if name.is_empty() {
return false;
}
let metadata = CommunityMetadata {
name,
..CommunityMetadata::default()
};
CommunityRegistry::global(cx)
.update(cx, |registry, cx| registry.create(metadata, cx));
true
})
});
}
-553
View File
@@ -1,553 +0,0 @@
use std::collections::HashMap;
use anyhow::Error;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
}
/// Screening
pub struct Screening {
/// Public Key of the person being screened.
public_key: PublicKey,
/// Whether the person's address is verified.
verified: bool,
/// Whether the person is followed by current user.
followed: bool,
/// Last time the person was active.
last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
this.tasks.clear();
window.close_all_modals(cx);
}));
cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
this.verify_identifier(cx);
});
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
fn check_contact(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
Ok(followed)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.followed = result;
cx.notify();
})
.ok();
}));
}
fn check_wot(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
}));
}
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
if let Ok(mut stream) = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
activity = Some(event.created_at);
}
}
}
activity
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.last_active = result;
cx.notify();
})
.ok();
}));
}
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let public_key = self.public_key;
// Skip if the user doesn't have a NIP-05 identifier
let Some(address) = self.address(cx) else {
return;
};
let task: Task<Result<bool, Error>> =
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.verified = result;
cx.notify();
})
.ok();
}));
}
fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Tag::from(Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
});
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
.finalize_async(&signer)
.await?;
// Send the report to the public relays
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
window.close_modal(cx);
window.push_notification("Report submitted successfully", cx);
})
.ok();
}
}));
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
window.open_modal(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
for ix in range {
let Some(contact) = contacts.get(ix) else {
continue;
};
let profile = persons.read(cx).get(contact, cx);
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()),
);
}
items
})
.h(px(300.)),
),
)
});
}
}
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const CONTACT: &str = "This person is one of your contacts.";
const NOT_CONTACT: &str = "This person is not one of your contacts.";
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
)
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.name()),
),
)
.child(
h_flex()
.gap_3()
.child(
h_flex()
.p_1()
.flex_1()
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
.text_center()
.line_height(relative(1.))
.child(shorten_pubkey),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Warning)
.small()
.warning()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_3()
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip(RELAY_INFO),
),
)
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(t) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
t.to_human_time()
)))
} else {
this.child(SharedString::from(NO_ACTIVITY))
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
.child({
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
} else {
SharedString::from(
"Friendly Address (NIP-05) validation",
)
}
})
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
} else {
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from(NO_NIP05)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
.text_sm()
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if mutuals > 0 {
SharedString::from(mutuals_str)
} else {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
)
}
}
fn status_badge(status: Option<bool>, cx: &App) -> Div {
h_flex()
.size_6()
.justify_center()
.flex_shrink_0()
.map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircle).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
} else {
this.child(Indicator::new().small())
}
})
}
+49 -48
View File
@@ -6,6 +6,7 @@ use anyhow::Error;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry}; use chat::{ChatEvent, ChatRegistry};
use common::download_dir; use common::download_dir;
use community::{CommunityEvent, CommunityRegistry};
use device::{DeviceEvent, DeviceRegistry}; use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -24,9 +25,8 @@ use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::{Notification, NotificationKind}; use ui::notification::{Notification, NotificationKind};
use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::settings; use crate::dialogs::{new_chat, new_community, settings};
use crate::panels::{ use crate::panels::{
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests, backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
search, search,
@@ -64,24 +64,23 @@ enum Command {
ShowRequests, ShowRequests,
ShowBrowse, ShowBrowse,
ShowSearch, ShowSearch,
NewChat,
NewCommunity,
} }
pub struct Workspace { pub struct Workspace {
sidebar: Entity<Sidebar>,
/// App's Dock Area
dock: Entity<DockArea>, dock: Entity<DockArea>,
title_bar_chrome: Rc<dock::TitleBarChrome>, title_bar_chrome: Rc<dock::TitleBarChrome>,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 6]>, _subscriptions: SmallVec<[Subscription; 7]>,
} }
impl Workspace { impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
let device = DeviceRegistry::global(cx); let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -99,16 +98,10 @@ impl Workspace {
subscriptions.push( subscriptions.push(
// Subscribe to the nostr events // Subscribe to the nostr events
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| { cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
match event { if let StateEvent::SignerChanged = event {
StateEvent::SignerChanged => { window.close_all_modals(cx);
window.close_all_modals(cx); }
}
StateEvent::NoSigner => {
this.import_identity(window, cx);
}
_ => {}
};
}), }),
); );
@@ -221,17 +214,45 @@ impl Workspace {
}), }),
); );
cx.defer_in(window, |this, window, cx| { subscriptions.push(
// Observe all events emitted by the community registry
cx.subscribe_in(
&communities,
window,
move |this, communities, event, window, cx| {
if let CommunityEvent::Open(id) = event
&& let Some(community) = communities.read(cx).community(id)
{
this.add_panel_to_dock(
community_ui::init(community, window, cx),
DockPlacement::Center,
window,
cx,
);
}
},
),
);
cx.defer_in(window, move |this, window, cx| {
let sidebar = PanelHandle::new(sidebar);
this.dock.update(cx, |area, cx| {
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
area.set_dock(DockPlacement::Left, left, window, cx);
area.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
});
let greeter = PanelHandle::new(greeter::init(window, cx)); let greeter = PanelHandle::new(greeter::init(window, cx));
let center = DockLayout::v_split() let center = DockLayout::v_split()
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None); .child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
this.dock this.dock.update(cx, |area, cx| {
.update(cx, |area, cx| area.set_center(center, window, cx)); area.set_center(center, window, cx);
});
}); });
Self { Self {
sidebar,
dock, dock,
title_bar_chrome, title_bar_chrome,
tasks: vec![], tasks: vec![],
@@ -320,6 +341,12 @@ impl Workspace {
Command::ShowSearch => { Command::ShowSearch => {
self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx); self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx);
} }
Command::NewChat => {
new_chat::open(window, cx);
}
Command::NewCommunity => {
new_community::open(window, cx);
}
Command::ShowBackup => { Command::ShowBackup => {
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
} }
@@ -451,19 +478,6 @@ impl Workspace {
}); });
} }
fn import_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Onboarding")
.child(import.clone())
});
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| { window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx); let registry = ThemeRegistry::global(cx);
@@ -725,20 +739,7 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.relative() .relative()
.size_full() .size_full()
.child( .child(self.dock.clone())
h_flex()
.size_full()
.child(
div()
.flex_shrink_0()
.h_full()
.w(SIDEBAR_WIDTH)
.border_r_1()
.border_color(cx.theme().border_variant)
.child(self.sidebar.clone()),
)
.child(self.dock.clone()),
)
// Notifications // Notifications
.children(notification_layer) .children(notification_layer)
// Modals // Modals
+5 -2
View File
@@ -157,8 +157,11 @@ impl Render for GreeterPanel {
.label("Change theme") .label("Change theme")
.ghost() .ghost()
.small() .small()
.on_click(cx.listener(move |_, _, _, cx| { .on_click(cx.listener(move |_, _, window, cx| {
cx.dispatch_action(&Command::ToggleTheme); window.dispatch_action(
Box::new(Command::ToggleTheme),
cx,
);
})), })),
), ),
), ),
+383 -341
View File
@@ -1,11 +1,10 @@
use std::collections::BTreeSet;
use std::ops::Range; use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, RoomKind};
use common::TimestampExt; use common::TimestampExt;
use community::{CommunityEvent, CommunityRegistry}; use community::{Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
@@ -19,11 +18,11 @@ use state::NostrRegistry;
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{ClosePanel, Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::modal::ModalButtonProps;
use ui::nav_item::NavItem; use ui::nav_item::NavItem;
use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::scroll::Scrollbar;
use ui::{ use ui::{
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex,
@@ -31,30 +30,29 @@ use ui::{
}; };
use crate::Command; use crate::Command;
use crate::dialogs::screening;
mod onboarding;
mod tab;
mod tree; mod tree;
use tree::{SidebarRow, TreeSection}; use tab::{SidebarTab, TabBar};
use tree::SidebarRow;
pub(crate) use tree::{TreeRow, TreeRowKind}; pub(crate) use tree::{TreeRow, TreeRowKind};
pub struct Sidebar { pub struct Sidebar {
focus_handle: FocusHandle, focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle, scroll_handles: [UniformListScrollHandle; 3],
active_tab: SidebarTab,
/// Whether there are new chat requests /// Whether there are new chat requests
new_requests: bool, new_requests: bool,
/// Expanded tree sections _subscriptions: SmallVec<[Subscription; 3]>,
expanded: BTreeSet<TreeSection>,
/// Pinned room ids, in pin order
pinned_rooms: Vec<u64>,
_subscriptions: SmallVec<[Subscription; 2]>,
} }
impl Sidebar { impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = AppSettings::global(cx).read(cx).entity().clone();
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx); let communities = CommunityRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -67,289 +65,59 @@ impl Sidebar {
}), }),
); );
subscriptions.push(cx.observe(&settings, move |this, _settings, cx| { subscriptions.push(cx.subscribe_in(
this.restore_state(cx); &communities,
})); window,
|_this, _communities, event, window, cx| {
subscriptions.push(
cx.subscribe(&communities, |_this, _communities, event, _cx| {
if let CommunityEvent::Error(error) = event { if let CommunityEvent::Error(error) = event {
log::error!("community: {error}"); window
.push_notification(Notification::error(error.clone()).autohide(false), cx);
} }
}), },
); ));
subscriptions.push(cx.observe(&nostr, |_this, _nostr, cx| cx.notify()));
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(), scroll_handles: [
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
],
active_tab: SidebarTab::Recents,
new_requests: false, new_requests: false,
expanded: load_expanded(cx),
pinned_rooms: AppSettings::get_pinned_rooms(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>) { fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
if !self.expanded.remove(&section) { if self.active_tab == tab {
self.expanded.insert(section);
}
self.save_expanded(cx);
cx.notify();
}
fn is_expanded(&self, section: TreeSection) -> bool {
self.expanded.contains(&section)
}
fn restore_state(&mut self, cx: &mut Context<Self>) {
let pinned_rooms = AppSettings::get_pinned_rooms(cx);
let expanded = load_expanded(cx);
if self.pinned_rooms == pinned_rooms && self.expanded == expanded {
return; return;
} }
self.pinned_rooms = pinned_rooms; self.active_tab = tab;
self.expanded = expanded;
cx.notify(); cx.notify();
} }
fn save_expanded(&self, cx: &mut App) { fn open_community(
let keys = self &mut self,
.expanded community: Entity<Community>,
.iter() window: &mut Window,
.map(|section| section.key().to_string()) cx: &mut Context<Self>,
.collect(); ) {
AppSettings::update_expanded_sections(Some(keys), cx); let id = community.read(cx).id().to_hex();
} let settings = AppSettings::global(cx);
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>) { settings.update(cx, |settings, cx| {
if !self.pinned_rooms.contains(&room_id) { settings.record_recent_community(id, cx);
self.pinned_rooms.push(room_id);
}
self.expanded.insert(TreeSection::Pins);
AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx);
self.save_expanded(cx);
cx.notify();
}
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>) {
self.pinned_rooms.retain(|id| *id != room_id);
AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx);
cx.notify();
}
fn is_pinned(&self, room_id: u64) -> bool {
self.pinned_rooms.contains(&room_id)
}
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow> {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
let mut rows = Vec::new();
let pinned: Vec<Entity<Room>> = self
.pinned_rooms
.iter()
.filter_map(|room_id| chat.room(room_id, cx))
.filter_map(|room| room.upgrade())
.collect();
if !pinned.is_empty() {
rows.push(SidebarRow::Section {
section: TreeSection::Pins,
count: pinned.len(),
});
if self.is_expanded(TreeSection::Pins) {
rows.extend(
pinned
.into_iter()
.map(|room| SidebarRow::Room { room, pinned: true }),
);
}
}
let registry = CommunityRegistry::global(cx);
let communities = registry.read(cx).communities();
rows.push(SidebarRow::Section {
section: TreeSection::Community,
count: communities.len(),
}); });
if self.is_expanded(TreeSection::Community) { CommunityRegistry::global(cx).update(cx, |registry, cx| {
if communities.is_empty() { registry.emit_community(&community, window, cx);
rows.push(SidebarRow::Hint {
text: "No communities yet".into(),
});
} else {
rows.extend(
communities
.iter()
.cloned()
.map(|community| SidebarRow::Community { community }),
);
}
}
let messages = chat.rooms(&RoomKind::Ongoing, cx);
rows.push(SidebarRow::Section {
section: TreeSection::Messages,
count: messages.len(),
}); });
if self.is_expanded(TreeSection::Messages) { cx.notify();
if messages.is_empty() {
rows.push(SidebarRow::Hint {
text: "No conversations yet".into(),
});
} else {
rows.extend(messages.into_iter().map(|room| {
let pinned = self.is_pinned(room.read(cx).id);
SidebarRow::Room { room, pinned }
}));
}
}
rows
}
fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Self>,
) -> Vec<AnyElement> {
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section { section, count } => {
let section = *section;
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Section,
section.label(),
)
.caret(if self.is_expanded(section) {
IconName::CaretDown
} else {
IconName::CaretRight
})
.count(*count)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_section(section, cx);
}))
.into_any_element()
}
SidebarRow::Room { room, pinned } => {
let pinned = *pinned;
let room_id = room.read(cx).id;
let public_key = room.read(cx).display_member(cx).public_key();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let kind = room.read(cx).kind;
let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone();
let sidebar = cx.entity().downgrade();
let handler = cx.listener(move |_this, _event, window, cx| {
ChatRegistry::global(cx).update(cx, |chat, cx| {
chat.emit_room(&room_clone, window, cx);
});
if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
let screening = screening::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.confirm()
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
true
})
});
}
});
let entry = TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Room,
name,
)
.avatar(seed)
.picture(picture)
.created_at(created_at)
.on_click(handler);
ContextMenu::new(
ElementId::NamedInteger("room-context-menu".into(), index as u64),
entry,
move |this, _window, _cx| {
let sidebar = sidebar.clone();
if pinned {
this.item(PopupMenuItem::new("Unpin").on_click(
move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |sidebar, cx| {
sidebar.unpin_room(room_id, cx);
}) {
log::error!("Failed to unpin room: {error}");
}
},
))
} else {
this.item(PopupMenuItem::new("Pin").on_click(
move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |sidebar, cx| {
sidebar.pin_room(room_id, cx);
}) {
log::error!("Failed to pin room: {error}");
}
},
))
}
},
)
.into_any_element()
}
SidebarRow::Community { community } => {
let community = community.read(cx);
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
community.name(),
)
.avatar(community.id().to_hex())
.picture(community.icon())
.into_any_element()
}
SidebarRow::Hint { text } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Hint,
text.clone(),
)
.into_any_element(),
}
})
.collect()
} }
fn render_user(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render_user(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
@@ -368,14 +136,6 @@ impl Sidebar {
.when(cfg!(target_os = "macos"), |this| { .when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING)) this.pl(px(TRAFFIC_LIGHT_PADDING))
}) })
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Import your identity to continue")),
)
})
.when_some(current_user.as_ref(), |this, public_key| { .when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx); let profile = persons.read(cx).get(public_key, cx);
@@ -413,6 +173,16 @@ impl Sidebar {
.child(name.clone()) .child(name.clone())
})) }))
.separator() .separator()
.menu_with_icon(
"Inbox",
IconName::Inbox,
Box::new(Command::ShowInbox),
)
.menu_with_icon(
"Search",
IconName::Search,
Box::new(Command::ShowSearch),
)
.menu_with_icon( .menu_with_icon(
"Profile", "Profile",
IconName::Profile, IconName::Profile,
@@ -454,13 +224,210 @@ impl Sidebar {
} }
} }
fn load_expanded(cx: &App) -> BTreeSet<TreeSection> { fn recent_communities(cx: &App) -> Vec<Entity<Community>> {
let Some(keys) = AppSettings::get_expanded_sections(cx) else { const LIMIT: usize = 3;
return BTreeSet::from([TreeSection::Community, TreeSection::Messages]);
};
keys.iter() let registry = CommunityRegistry::global(cx);
.filter_map(|key| TreeSection::from_key(key.as_str())) let communities = registry.read(cx).communities();
let recent = AppSettings::get_recent_communities(cx);
let mut rows: Vec<Entity<Community>> = recent
.iter()
.filter_map(|id| {
communities
.iter()
.find(|community| community.read(cx).id().to_hex() == *id)
})
.take(LIMIT)
.cloned()
.collect();
if rows.is_empty() {
rows = communities.iter().take(LIMIT).cloned().collect();
}
rows
}
fn rows_for(tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab {
SidebarTab::Recents => {
let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, cx);
let registry = CommunityRegistry::global(cx);
let community_count = registry.read(cx).communities().len();
let communities = recent_communities(cx);
if communities.is_empty() && rooms.is_empty() {
return vec![SidebarRow::Hint {
text: "Nothing recent yet".into(),
}];
}
let mut rows = Vec::new();
if !communities.is_empty() {
rows.push(SidebarRow::Section {
label: "Communities".into(),
count: community_count,
});
rows.extend(
communities
.into_iter()
.map(|community| SidebarRow::Community { community }),
);
rows.push(SidebarRow::Action {
label: "Show all communities".into(),
tab: SidebarTab::Communities,
});
}
if !rooms.is_empty() {
rows.push(SidebarRow::Section {
label: "Chats".into(),
count: rooms.len(),
});
rows.extend(
rooms
.into_iter()
.take(5)
.map(|room| SidebarRow::Room { room }),
);
rows.push(SidebarRow::Action {
label: "Show all chats".into(),
tab: SidebarTab::Chats,
});
}
rows
}
SidebarTab::Chats => {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
let messages = chat.rooms(&RoomKind::Ongoing, cx);
let mut rows = vec![SidebarRow::Section {
label: "Chats".into(),
count: messages.len(),
}];
if messages.is_empty() {
rows.push(SidebarRow::Hint {
text: "No conversations yet".into(),
});
} else {
rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room }));
}
rows
}
SidebarTab::Communities => {
let registry = CommunityRegistry::global(cx);
let communities = registry.read(cx).communities();
let mut rows = vec![SidebarRow::Section {
label: "Communities".into(),
count: communities.len(),
}];
if communities.is_empty() {
rows.push(SidebarRow::Hint {
text: "No communities yet".into(),
});
} else {
rows.extend(
communities
.iter()
.cloned()
.map(|community| SidebarRow::Community { community }),
);
}
rows
}
}
}
fn render_rows(range: Range<usize>, rows: &[SidebarRow], cx: &Context<Sidebar>) -> Vec<AnyElement> {
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section { label, count } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Section,
label.clone(),
)
.count(*count)
.into_any_element(),
SidebarRow::Room { room } => {
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone();
let handler = cx.listener(move |_this, _event, window, cx| {
ChatRegistry::global(cx).update(cx, |chat, cx| {
chat.emit_room(&room_clone, window, cx);
});
});
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Room,
name,
)
.avatar(seed)
.picture(picture)
.created_at(created_at)
.on_click(handler)
.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();
let community = community.clone();
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
name,
)
.avatar(seed)
.picture(picture)
.on_click(cx.listener(move |this, _event, window, cx| {
this.open_community(community.clone(), window, cx);
}))
.into_any_element()
}
SidebarRow::Action { label, tab } => {
let tab = *tab;
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Action,
label.clone(),
)
.icon(IconName::ArrowRight)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.select_tab(tab, cx);
}))
.into_any_element()
}
SidebarRow::Hint { text } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Hint,
text.clone(),
)
.into_any_element(),
}
})
.collect() .collect()
} }
@@ -468,6 +435,18 @@ impl Panel for Sidebar {
fn panel_id(&self) -> SharedString { fn panel_id(&self) -> SharedString {
"Sidebar".into() "Sidebar".into()
} }
fn title(&self, _cx: &App) -> AnyElement {
SharedString::from("Sidebar").into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
false
}
fn zoomable(&self, _cx: &App) -> bool {
false
}
} }
impl EventEmitter<PanelEvent> for Sidebar {} impl EventEmitter<PanelEvent> for Sidebar {}
@@ -481,90 +460,152 @@ impl Focusable for Sidebar {
impl Render for Sidebar { impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some(); let (logged_in, ready) = {
let nostr = nostr.read(cx);
(nostr.current_user().is_some(), nostr.ready())
};
if !logged_in {
if !ready {
return v_flex()
.size_full()
.bg(cx.theme().surface_background)
.border_r_1()
.border_color(cx.theme().border_variant)
.into_any_element();
}
return onboarding::render(window, cx).into_any_element();
}
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading() && logged_in; let loading = chat.read(cx).loading();
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();
let rows = Rc::new(self.tree_rows(cx)); let active_tab = self.active_tab;
let rows = Rc::new(rows_for(active_tab, cx));
let scroll_handle = &self.scroll_handles[active_tab.index()];
v_flex() v_flex()
.image_cache(retain_all("sidebar")) .image_cache(retain_all("sidebar"))
.size_full() .size_full()
.relative()
.gap_2() .gap_2()
.bg(cx.theme().surface_background)
.border_r_1()
.border_color(cx.theme().border_variant)
.child(self.render_user(window, cx)) .child(self.render_user(window, cx))
.child( .when(active_tab.chat(), |this| {
v_flex() this.child(
.px_2() v_flex()
.py_1() .px_2()
.gap_1() .gap_1()
.child( .child(
NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small()) NavItem::new(
.on_click(|_event, _window, cx| { "nav-contacts",
cx.dispatch_action(&Command::ShowInbox) "Contacts",
Icon::new(IconName::Book).small(),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowContactList), cx)
}), }),
)
.child(
NavItem::new(
"nav-requests",
"Requests",
Icon::new(IconName::Invite).small(),
) )
.when(self.new_requests, |this| { .child(
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) NavItem::new(
}) "nav-requests",
.on_click({ "Requests",
let sidebar = sidebar.clone(); Icon::new(IconName::Invite).small(),
move |_event, _window, cx| { )
if let Err(error) = sidebar.update(cx, |this, cx| { .when(self.new_requests, |this| {
this.new_requests = false; this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
cx.notify(); })
}) { .on_click({
log::error!("Failed to clear new requests: {error}"); let sidebar = sidebar.clone();
move |_event, window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.new_requests = false;
cx.notify();
}) {
log::error!("Failed to clear new requests: {error}");
}
window.dispatch_action(Box::new(Command::ShowRequests), cx);
} }
cx.dispatch_action(&Command::ShowRequests);
}
}),
)
.child(
NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_event, _window, cx| {
cx.dispatch_action(&Command::ShowBrowse)
}), }),
) )
.child( .child(
NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small()) NavItem::new(
.on_click(|_event, _window, cx| { "nav-new-chat",
cx.dispatch_action(&Command::ShowSearch) "New chat",
Icon::new(IconName::Plus).small(),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::NewChat), cx)
}), }),
), ),
) )
})
.when(active_tab.community(), |this| {
this.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new(
"nav-browse",
"Browse",
Icon::new(IconName::Compass).small(),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
)
.child(
NavItem::new(
"nav-new-community",
"New community",
Icon::new(IconName::Plus).small(),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::NewCommunity), cx)
}),
),
)
})
.child( .child(
v_flex() v_flex()
.size_full() .size_full()
.flex_1() .flex_1()
.min_h_0()
.gap_1() .gap_1()
.pb_12()
.child( .child(
uniform_list( uniform_list(
"sidebar-tree", active_tab.list_id(),
rows.len(), rows.len(),
cx.processor(move |this, range, _window, cx| { cx.processor(move |_this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx) render_rows(range, rows.as_slice(), cx)
}), }),
) )
.track_scroll(&self.scroll_handle) .track_scroll(scroll_handle)
.flex_1() .flex_1()
.h_full() .h_full()
.px_2(), .px_2(),
) )
.child(Scrollbar::vertical(&self.scroll_handle)), .child(Scrollbar::vertical(scroll_handle)),
) )
.child(TabBar::new(active_tab).on_select({
let sidebar = sidebar.clone();
move |tab, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) {
log::error!("Failed to switch sidebar tab: {error}");
}
}
}))
.when(loading, |this| { .when(loading, |this| {
this.child( this.child(
div() div()
.absolute() .absolute()
.bottom_2() .bottom_16()
.left_0() .left_0()
.h_9() .h_9()
.w_full() .w_full()
@@ -586,5 +627,6 @@ impl Render for Sidebar {
), ),
) )
}) })
.into_any_element()
} }
} }
@@ -0,0 +1,74 @@
use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex};
use crate::dialogs::import;
const TITLE: &str = "Welcome to Coop!";
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.size_full()
.relative()
.bg(cx.theme().surface_background)
.child(title_bar_drag_handlers(
div()
.id("onboarding-drag")
.absolute()
.top_0()
.left_0()
.h(TABBAR_HEIGHT)
.w_full(),
window,
cx,
))
.child(
v_flex()
.size_full()
.justify_end()
.gap_4()
.p_4()
.child(
h_flex()
.gap_2()
.child(
svg()
.path("brand/coop.svg")
.size_8()
.text_color(cx.theme().icon_muted),
)
.child(
v_flex().child(div().font_semibold().child(TITLE)).child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(DESCRIPTION),
),
),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("join-now")
.label("Join now")
.primary()
.font_semibold()
.h_8()
.w_full(),
)
.child(
Button::new("import-identity")
.label("Import identity")
.secondary()
.font_semibold()
.h_8()
.w_full()
.on_click(|_event, window, cx| import::open(window, cx)),
),
),
)
}
+121
View File
@@ -0,0 +1,121 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::{IconName, Selectable, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab {
Recents,
Chats,
Communities,
}
impl SidebarTab {
pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
pub fn label(self) -> &'static str {
match self {
Self::Recents => "Recents",
Self::Chats => "Chats",
Self::Communities => "Communities",
}
}
pub fn icon(self) -> IconName {
match self {
Self::Recents => IconName::History,
Self::Chats => IconName::Message,
Self::Communities => IconName::Group,
}
}
pub fn list_id(self) -> &'static str {
match self {
Self::Recents => "sidebar-recents",
Self::Chats => "sidebar-chats",
Self::Communities => "sidebar-communities",
}
}
pub fn index(self) -> usize {
match self {
Self::Recents => 0,
Self::Chats => 1,
Self::Communities => 2,
}
}
pub fn chat(self) -> bool {
matches!(self, Self::Chats)
}
pub fn community(self) -> bool {
matches!(self, Self::Communities)
}
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct TabBar {
active: SidebarTab,
on_select: Option<Rc<dyn Fn(SidebarTab, &mut Window, &mut App)>>,
}
impl TabBar {
pub fn new(active: SidebarTab) -> Self {
Self {
active,
on_select: None,
}
}
pub fn on_select(
mut self,
handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TabBar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self { active, on_select } = self;
div()
.id("sidebar-tabs")
.absolute()
.bottom_3()
.left_0()
.w_full()
.px_4()
.child(
h_flex()
.w_full()
.p_1()
.gap_1()
.rounded_full()
.bg(cx.theme().background)
.when(cx.theme().shadow, |this| this.shadow_md())
.children(SidebarTab::ALL.into_iter().map(|tab| {
let on_select = on_select.clone();
Button::new(format!("tab-{}", tab.list_id()))
.icon(tab.icon())
.ghost()
.flex_1()
.rounded()
.selected(tab == active)
.tooltip(tab.label())
.on_click(move |_event, window, cx| {
if let Some(on_select) = on_select.as_ref() {
on_select(tab, window, cx);
}
})
})),
)
}
}
+47 -51
View File
@@ -5,52 +5,33 @@ use community::Community;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar}; use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] use super::tab::SidebarTab;
pub enum TreeSection {
Pins,
Community,
Messages,
}
impl TreeSection {
pub fn label(self) -> &'static str {
match self {
Self::Pins => "Pinned",
Self::Community => "Community",
Self::Messages => "Messages",
}
}
pub fn key(self) -> &'static str {
match self {
Self::Pins => "pins",
Self::Community => "community",
Self::Messages => "messages",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"pins" => Some(Self::Pins),
"community" => Some(Self::Community),
"messages" => Some(Self::Messages),
_ => None,
}
}
}
pub enum SidebarRow { pub enum SidebarRow {
Section { section: TreeSection, count: usize }, Section {
Room { room: Entity<Room>, pinned: bool }, label: SharedString,
Community { community: Entity<Community> }, count: usize,
Hint { text: SharedString }, },
Room {
room: Entity<Room>,
},
Community {
community: Entity<Community>,
},
Action {
label: SharedString,
tab: SidebarTab,
},
Hint {
text: SharedString,
},
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -58,6 +39,7 @@ pub enum TreeRowKind {
Section, Section,
Room, Room,
Community, Community,
Action,
Hint, Hint,
} }
@@ -68,7 +50,7 @@ pub struct TreeRow {
label: SharedString, label: SharedString,
avatar: Option<SharedString>, avatar: Option<SharedString>,
picture: Option<ImageSource>, picture: Option<ImageSource>,
caret: Option<IconName>, icon: Option<IconName>,
count: Option<usize>, count: Option<usize>,
created_at: Option<SharedString>, created_at: Option<SharedString>,
selected: bool, selected: bool,
@@ -88,7 +70,7 @@ impl TreeRow {
label: label.into(), label: label.into(),
avatar: None, avatar: None,
picture: None, picture: None,
caret: None, icon: None,
count: None, count: None,
created_at: None, created_at: None,
selected: false, selected: false,
@@ -96,11 +78,6 @@ impl TreeRow {
} }
} }
pub fn caret(mut self, caret: IconName) -> Self {
self.caret = Some(caret);
self
}
/// Sets the seed for the row's generated avatar. /// Sets the seed for the row's generated avatar.
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self { pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into()); self.avatar = Some(seed.into());
@@ -113,6 +90,12 @@ impl TreeRow {
self self
} }
/// Shows `icon` in the avatar slot when the row has no avatar or picture.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn count(mut self, count: usize) -> Self { pub fn count(mut self, count: usize) -> Self {
self.count = Some(count); self.count = Some(count);
self self
@@ -150,6 +133,7 @@ impl RenderOnce for TreeRow {
let is_section = self.kind == TreeRowKind::Section; let is_section = self.kind == TreeRowKind::Section;
let is_room = self.kind == TreeRowKind::Room; let is_room = self.kind == TreeRowKind::Room;
let is_community = self.kind == TreeRowKind::Community; let is_community = self.kind == TreeRowKind::Community;
let is_action = self.kind == TreeRowKind::Action;
let is_hint = self.kind == TreeRowKind::Hint; let is_hint = self.kind == TreeRowKind::Hint;
let is_selected = self.selected; let is_selected = self.selected;
@@ -174,6 +158,18 @@ impl RenderOnce for TreeRow {
} }
}; };
let avatar = avatar.or_else(|| {
self.icon.map(|icon| {
h_flex()
.flex_shrink_0()
.w(px(20.))
.justify_center()
.text_color(cx.theme().icon_muted)
.child(Icon::new(icon).small())
.into_any_element()
})
});
h_flex() h_flex()
.id(self.id) .id(self.id)
.h_8() .h_8()
@@ -183,10 +179,13 @@ impl RenderOnce for TreeRow {
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(is_section, |this| { .when(is_section, |this| {
this.text_xs() this.text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_placeholder)
.font_semibold() .font_semibold()
}) })
.when(is_room || is_community, |this| this.text_sm()) .when(is_room || is_community, |this| this.text_sm())
.when(is_action, |this| {
this.text_sm().text_color(cx.theme().text_muted)
})
.when(is_hint, |this| { .when(is_hint, |this| {
this.text_xs() this.text_xs()
.font_normal() .font_normal()
@@ -216,18 +215,15 @@ impl RenderOnce for TreeRow {
this.child(div().flex_shrink_0().font_normal().child(count.to_string())) this.child(div().flex_shrink_0().font_normal().child(count.to_string()))
}) })
.when_some(self.created_at, |this, created_at| { .when_some(self.created_at, |this, created_at| {
this.child( this.child(div().flex_1()).child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.text_xs()
.child(created_at), .child(created_at),
) )
}), }),
) )
.when_some(self.caret, |this, caret| {
this.child(Icon::new(caret).small().text_color(cx.theme().icon_muted))
})
.when_some(self.on_click, |this, handler| { .when_some(self.on_click, |this, handler| {
this.cursor_pointer() this.cursor_pointer()
.when(!is_section, |this| { .when(!is_section, |this| {
+243
View File
@@ -0,0 +1,243 @@
# Community messages panel
A community opens as a panel in the center dock when its sidebar row is clicked,
shaped like every Discord-style client (Vector, Armada):
```
+----------------+----------------------------------+
| Channels | |
| # general | messages |
| # random | |
+----------------+ |
| Members | |
| @alice +----------------------------------+
| @bob | [ composer ] |
+----------------+----------------------------------+
```
One panel holds both columns. The left column scrolls its two sections; the right
column is the timeline plus the composer. The panel is per community, so its
`panel_id` is `community-<community_id hex>` and re-clicking a community focuses
the existing panel (`ui::dock::add_panel` already does this by `panel_id`).
## What already exists
- `sync::planes` already derives a `Plane` for the Control plane, the Guestbook,
and every public channel; `CommunityRegistry::sync_subscriptions` subscribes to
all of them as one filter, so live channel wraps already reach the client.
- `sync::fold` already opens channel wraps and calls `store::cache_rumor`, so the
local DB already holds the timeline; it folds the Control Plane and the member
list and emits `CommunityEvent::Updated` on every inbound wrap.
- `cord03::fold` turns cached rumors into `ChatMessage`s with edits, deletes and
reactions resolved; `store::query_rumors` / `store::backfill` are the read paths.
- `Community` exposes `channels()`, `members()`, `control()`, `name()`, `icon()`.
- `community::init` is already called by `desktop/src/main.rs`.
So the panel needs no new protocol work: one reader, one writer, and an event that
asks the workspace to open the panel.
## 1. `concord`: a cached rumor is a `ChatRumor`
`store::query_rumors` hands back `UnsignedEvent`s, and `cord03::typed` (already
used by `open`) is private. Add one public wrapper in `crates/concord/src/cords/cord03.rs`:
```rust
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
.parse()
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
let epoch = unique_tag(rumor, TAG_EPOCH)?
.ok_or(ChatError::MissingTag(TAG_EPOCH))
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
typed(rumor, &channel, Epoch(epoch))
}
```
The tags are read with `cord01::unique_tag`, the same reader `check_channel_binding`
uses (both become `pub(crate)`), so a cached copy is parsed by exactly the rule that
accepted it at ingest. `canonical_decimal` and `typed` are already in this file.
## 2. `community`: channel history and sending
All in `crates/community/src/community.rs` on `Community`, mirroring `Room`.
```rust
const MESSAGE_LIMIT: usize = 200;
/// The secret a channel's plane derives from, and the epoch it is held at.
/// A private channel uses the key it was granted; a public one the community root.
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])>;
/// Page a channel's history into the local cache, once, when the channel is opened.
pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task<Result<()>>;
/// The channel's timeline, folded from the local cache.
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>>;
/// Seal a message to the channel plane, cache it, then publish it to the relays.
pub fn send(
&self,
channel: &ChannelId,
content: &str,
reply_to: Option<ReplyRef>,
cx: &App,
) -> Option<Task<Result<EventId>>>;
```
- `backfill`: `store::backfill(&client, channel, &[(epoch, secret)], None, MESSAGE_LIMIT)`,
skipped when `store::query_rumors` already finds wraps for the channel, so it runs
once per channel. `store::backfill` walks up to `MAX_PAGES` pages itself. It
fetches through the client, so the community's relays must be in the pool —
`sync_subscriptions` already adds them on load.
- `messages`: `store::query_rumors(&client, channel, None, MESSAGE_LIMIT)`, then
`cord03::parse_rumor` over each, then `cord03::fold(&rumors, Timestamp::now(), can_delete)`.
The closure is the community's own policy:
`citation_ok(&owner, &id, actor, citation, &floors) && roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)`,
built from `self.state.owner`, `self.state.id`, `self.state.floors()` and
`self.control.roles` cloned into the background task. `cord03::fold` returns
newest-first, so reverse it for the bottom-aligned list.
- `send`: `cord03::build_message(author, channel, epoch, content, reply_to.as_ref(), at_ms, timer)`
where `timer` is `control.community.message_expiration` and `at_ms` is now in ms;
`cord03::seal_rumor(&rumor, &plane, &signer, false)`; then — the order matters —
`cord03::open(&wrap, &plane, channel, epoch)` and `store::cache_rumor` *before*
publishing, so the author's own row exists whether or not a relay answers. The
publish is `pub(crate) sync::connect_relays(client, &relays)` (the
`add_relay(..).and_connect()` loop the genesis path already ran) followed by
`pub(crate) sync::publish_wrap(client, &wrap, &relays)`, so one copy serves both
paths; failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey`
from `derive::channel_group_key(secret, channel, epoch)`, and the epoch from
`channel_secret`. Returns `None` without a signer or a held secret, and the rumor
id so the panel can reload.
`CommunityEvent` gains one variant, and the registry a way to request an open,
mirroring `ChatRegistry::emit_room`:
```rust
pub enum CommunityEvent {
Updated(CommunityId),
Open(CommunityId),
Error(String),
}
impl CommunityRegistry {
/// Ask the workspace to open a community's panel.
pub fn emit_community(&mut self, community: &Entity<Community>, window: &mut Window, cx: &mut Context<Self>);
}
```
`emit_community` reads the id and emits `CommunityEvent::Open` through
`cx.defer_in(window, ...)` so the click never re-enters the registry.
Private channels stay out of this pass: `sync::planes` does not subscribe them and
`CommunityState` has no room for a rotated key yet, so `channel_secret` returning
the granted `key` is the only support they get.
## 3. `community_ui`: the new crate
`crates/community_ui`, shaped like `chat_ui` (which is the reference for every
detail: `Panel` impl, notification routing, input handling, message list).
```
crates/community_ui/Cargo.toml deps: community, state, ui, theme, common, person, settings, gpui, nostr-sdk, smallvec, anyhow, log
crates/community_ui/src/lib.rs init + CommunityPanel
crates/community_ui/src/message.rs one message row's rendering
```
`community` re-exports the types the panel names, as `chat` already does for
`Message`: `ChatMessage`, `ReplyRef`, `ChannelId`, `CommunityId`.
```rust
pub fn init(community: Entity<Community>, window: &mut Window, cx: &mut App) -> Entity<CommunityPanel>;
pub struct CommunityPanel {
id: SharedString, // "community-<hex>"
focus_handle: FocusHandle,
community: WeakEntity<Community>,
channel: Option<ChannelId>, // the selected channel
messages: Vec<ChatMessage>, // ascending, bottom-aligned list
list_state: ListState,
input: Entity<TextareaState>,
tasks: Vec<Task<Result<()>>>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
```
- `new` takes the strong `Entity<Community>`, subscribes with
`cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the
weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split).
It picks `channels().first()` (the genesis `#general`) and, in the subscription,
`CommunityEvent::Updated(id)` reloads the open channel through `cx.defer_in`,
because the emit sits inside the community's own update; `CommunityEvent::Error(error)`
becomes a window notification. A `cx.defer_in` does the first `backfill` + `messages`
load, exactly as `ChatPanel::new` defers `connect`.
- The channel and member lists are read live in `render` through the weak entity
(as the sidebar reads `Community::channels()`), so a new channel or member needs no
invalidation; a dropped entity renders an empty state instead.
- `select_channel(channel, window, cx)` swaps the selection, resets the list and
loads: `backfill` once per channel, then `messages`.
- `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages` and
`list_state.reset(len)` (then `scroll_to_end`). Edits, deletes and reactions are
folded server-side of the UI, so a full replace is the honest update and stays
small at `MESSAGE_LIMIT`. A failed load becomes a window notification.
- `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the
input, and reloads when the task resolves. Empty input is refused with a
notification, like `ChatPanel`.
- `render`: `v_flex` holding `h_flex`
- left: `w(px(220.))`, `border_r_1`, `.overflow_y_scrollbar()` column with a
`Channels` section (row = icon `IconName::Message`, or `Lock` when private, plus
`ChannelKeyRef.name`; the selected row takes `cx.theme().ghost_element_selected`) and
a `Members` section (row = `Avatar` from
`PersonRegistry::global(cx).read(cx).get(&pk, cx)` plus the profile name,
honouring `AppSettings::get_hide_avatar` like `TreeRow`).
- right: `v_flex().flex_1().min_w_0()` with `gpui::list(self.list_state, ...)` over
`message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the
composer row: `Textarea` (`InputEvent::PressEnter` sends) and a
`Button::new("send").icon(IconName::PaperPlaneFill)`.
- A message row: author name (person profile, "Unknown" fallback), `to_ago()` from
`common::TimestampExt`, the content as plain text (no markdown, media or file
rendering in this pass), a muted `(edited)` marker when `edited_at` is set, an
emoji summary line from `reactions`, and `"Message deleted"` in
`cx.theme().text_placeholder` when `deleted`.
- `Panel`: `panel_id` = the id above, `title` = the community icon (`Avatar`) plus
`community.name()`, `closable` = true, no toolbar buttons.
## 4. `workspace`: open the panel from the sidebar
- `crates/workspace/Cargo.toml`: add `community_ui = { path = "../community_ui" }`.
- `crates/workspace/src/lib.rs`: subscribe to `CommunityRegistry` beside the chat
subscription and, on `CommunityEvent::Open(id)`, look the community up with
`registry.read(cx).community(&id)` and
`add_panel_to_dock(community_ui::init(community, window, cx), DockPlacement::Center, window, cx)`.
`CommunityEvent::Error` keeps its single handler in the sidebar.
- `crates/workspace/src/sidebar/mod.rs`: `open_community` keeps recording the
recent community and now ends with
`CommunityRegistry::global(cx).update(cx, |registry, cx| registry.emit_community(&community, window, cx))`,
so the row's click handler needs the `window`.
## 5. Order of work
1. `cord03::parse_rumor`.
2. `community`: `channel_secret`, `backfill`, `messages`, `send`, `CommunityEvent::Open`,
`emit_community`.
3. `community_ui`: `message.rs`, then the panel with the channel list, the timeline
and the composer, then the member list.
4. `workspace`: the dependency, the registry subscription, the sidebar click.
5. `cargo check -p workspace` (the panel only compiles through it), then a manual
run: create a community, click its sidebar row, send a message and see it through
a second account.
No tests: the crate follows the "no `unwrap`, errors to the UI" rule and validation
is the manual run above.
## Out of scope
Files, reactions as a composer action, edits, threads, pins, typing indicators,
unread badges, notifications, message expiration purging (`store::purge_expired`),
private-channel subscriptions (a rekey cannot be persisted yet), moderation actions,
and community management (metadata, roles, invites). Also unchanged:
`crates/chat/src/lib.rs::handle_notifications` already routes kind 1059 wraps by
subscription id, so concord traffic does not land in the DM trash.
-336
View File
@@ -1,336 +0,0 @@
# Concord discovery: why no community ever reaches `subscribe`
Audit + fix plan. Read alongside `docs/concord-usage.md` and
`docs/concord-simplification-plan.md`.
## Symptom
`crates/community/src/lib.rs::subscribe` is never called, so no wrap is ever
subscribed to and the sidebar stays empty. `community load: 0 state document(s)
found` is the only clue.
## Root cause
`CommunityRegistry::load` only ever reads the **local database**. Nothing in the
discovery path touches a relay.
```
community::init
└─ SignerChanged → load
└─ sync::load
├─ store::load_states(client) → client.database().query(..) // local only
└─ load_list(client, ..) → client.database().query(..) // local only, .limit(1)
→ track([])
→ sync_subscriptions: `for community in self.communities` runs zero times
→ subscribe never called
→ no relay is ever queried
→ the database never fills
→ load stays empty forever
```
The loop is self-reinforcing: the local database is populated *by* the
subscriptions that the empty load prevents. That is why an account which belongs
to several communities in another client still shows nothing — a fresh install
has no `concord/*` state document, and coop has no way to ask for one.
Confirmed by inspection:
| Location | What it does |
| --- | --- |
| `crates/community/src/lib.rs:167-191` | `load` → `sync::load`, then `track(states)` |
| `crates/community/src/sync.rs:125-137` | `load` = `store::load_states` + `load_list` |
| `crates/concord/src/store.rs:314-341` | `load_states` queries `client.database()` only |
| `crates/community/src/sync.rs:139-153` | `load_list` queries `client.database()` only, `.limit(1)` |
| `crates/community/src/lib.rs:233-276` | `sync_subscriptions` skips everything when `communities` is empty |
`subscribe` itself is correct. Do not debug it.
## What the protocol actually says
Read from the spec (`concord-protocol/concord`, the submodule referenced by
accordion.chat): `02.md` §8 and `examples.md` §6.2.
A member's memberships live in the **Community List**, on relays:
- **Kind `33302`**, addressable, NIP-44-encrypted to self, signed by the
member's real key, one event per **fragment** with `d` = the fragment index in
decimal (`"0"`, `"1"`, …). `13302` is explicitly **retired** ("the
single-event Community List, superseded by `33302` once it outgrew one event —
a replaceable kind cannot fragment", `02.md:314`).
- Every 32-byte value at **any depth** is unpadded base64url, not hex. This is
section-scoped: CORD-05 invite fields stay hex (`examples.md` §6.3).
- Join material is the membership subset — `owner, owner_salt, community_root,
root_epoch, control_pk, channels, relays, name`, plus `control_root` when
held. It is the *only* durable home of a member's keys.
- The two snapshots solve opposite problems: `seed` is the earliest epoch held
(backfill anchor), `current` the latest ("so a fresh device reconstructs the
Community instantly with no epoch-by-epoch walk"). `seed` is omitted when
equal to `current`; embedded snapshots omit `community_id` (inherited).
- A client holds the complete List when it holds a fragment at every index below
`frags`; it unions fragments and merges, so a partial read is safe.
Two consequences for coop:
1. **The state document is a coop invention.** `store::{save_state, load_state,
load_states}` write kind `30078` with `d = concord/<id>`, signed by a
per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists
anywhere in the spec. It is a local cache and must never be treated as the
discovery source.
2. **Discovery is: subscribe to my `33302` → materialize a community from
`current` join material → subscribe to its planes → fold.** The fold produces
the authoritative state; the List only supplies the keys to start.
## Divergences (coop vs spec)
| # | Spec | coop today |
| --- | --- | --- |
| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** |
| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)` — **fixed in Phase A** |
| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key` — **fixed in Phase A** |
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** |
| 5 | fetch from relays | local database only — **fixed in Phase C** |
| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis` — **fixed in Phase B** |
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** |
| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed |
Divergences 1–7 are resolved. 8 remains, in the narrow sense that `planes()`
still skips private channels rather than deriving their addresses from the
granted key.
## Plan
Ordered so each phase is independently reviewable and testable. Nothing here
touches the frozen HKDF derivations or `cord01` envelope semantics.
### Phase A — make the List interoperable (pure, no I/O) — DONE
`crates/concord/src/cords/cord02/list.rs`
1. `KIND_COMMUNITY_LIST` → `33302`; add `frags: u64` to `CommunityList` and
`is_complete(&self, frags) -> bool`.
2. Add a base64url codec for the §8 value set and apply it to every 32-byte
field at every depth. Because `JoinMaterial` currently types `owner` and
`control_pk` as `PublicKey` (nostr's hex serde), this needs either wire
newtypes or `serialize_with`/`deserialize_with` helpers. Keep it local to the
List: `cord05` stays hex.
3. Implement the two §8 MUSTs: omit `community_id` on an embedded snapshot,
omit `seed` when it byte-equals `current`, and rewrite `seed`'s cosmetic
fields (`name`, `relays`, each channel's `name`) from `current` on every
serialization.
4. `build_list_event`/`parse_list_event` take the fragment index and emit/read
the `d` tag.
Tests: round-trip the `examples.md` §6.2 payload verbatim; `merge` convergence
for two devices and mixed-age fragments; `frags` disagreement resolves to the
larger value; a repack does not shed unknown fields.
**As built.** The §8 rules live behind private wire structs (`WireList`,
`WireEntry`, `WireSnapshot`, `WireChannel`), so a writer re-encodes on every
serialization while the public types keep their internal hex/`PublicKey`
spellings and `cord05` stays hex. Three deviations from the sketch above:
- `is_complete` takes the set of fragment indices a client holds, not a count:
a count is wrong when the indices are sparse.
- The reader tolerates non-zero base64url trailing bits. The spec's own §6.2
example has five such values, so a strict decoder rejects the worked example;
the writer still emits the canonical spelling.
- The third omission MUST was implemented too: an entry whose `added_at` does
not outrun its tombstone is not written. It is a serialization rule exactly
like the other two, so it belongs here rather than in Phase D.
`parse_list_event` validates the `d` tag but returns just the `CommunityList`;
`fragment_index(event)` reads the index, which kept `sync.rs` untouched until
Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no
membership limit, and removing the cap needs write-time fragmentation.
### Phase B — materialize a community from join material (pure) — DONE
`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs`
1. `CommunityState::from_join_material(material: &JoinMaterial, added_at_ms:
u64) -> Result<Self>`: identity/owner/salt/root/root_epoch from the material;
`control_pks = { root_epoch → control_pk }`; `relays` parsed; `channels` from
the grants; `control_root` when present; `heads` empty (the first control
fold fills them); `banned` empty; `dissolved` false.
2. Carry the private channel key: add `key: Option<[u8; 32]>` to
`ChannelKeyRef` (or a parallel map) so a grant's `key` has a home. Without
this, a private channel is silently read-only-until-rekey.
Tests: a material with and without `control_root`; a private grant's key
survives; `from_join_material` then `planes()` yields the control `control_pk`
plus the guestbook and public channels, i.e. a subscription filter that
addresses real planes.
**As built.** `from_join_material` does not verify `community_id` against
`owner`/`owner_salt`: the List is signed by the member's own key and encrypted
to self, and the invite path already validates that binding in
`CommunityInvite::validate`. `private` on a materialized channel is simply
`key.is_some()` — the spec's `channels` carry only the Private Channel keys a
member was granted, so a grant with no key is a public channel. Nothing else
changed: `from_genesis` and `apply_fold` construct every channel with
`key: None`, and `planes()` still skips private channels, whose address derives
from the granted key rather than the `community_root`. Carrying the key is what
makes subscribing to them possible later; it is not needed to fix discovery.
Two tests. In `concord`, `from_join_material` (with and without `control_root`,
a granted key surviving, a public grant staying keyless). In `community`,
`planes()` plus `subscription_filter` over a state built field-by-field (control
+ guestbook + public channel addressed, private skipped) — `JoinMaterial` and
`ChannelGrant` cannot be constructed from `community` because their `extra`
field's type is crate-private, so the materialization and the plane derivation
are each proved where they live.
### Phase C — the List drives `load` — DONE
`crates/community/src/sync.rs`, `crates/community/src/lib.rs`
1. `subscribe_list(client, self_pk)` subscribes to `Kind::Custom(33302)`
`author(self_pk)` under a dedicated `concord/list` subscription id, using
`ReqTarget::auto`. With gossip enabled, `auto` breaks the filter down by
author, so it queries the account's NIP-65 write relays and adds/connects
them itself — bootstrap relays alone would miss a List published elsewhere.
2. `CommunityRegistry` calls `subscribe_list` once per signer (signer change and
the initial defer). It is deliberately **not** called from `load`:
re-subscribing on every List event would re-deliver the List and loop. `reset`
does not unsubscribe it either — `subscribe_list` replaces the subscription
itself, and a `reset`-issued unsubscribe could race the replacement and cancel
discovery.
3. The notification listener routes a `concord/list` event to a new `Signal::List`,
whose consumer re-runs `load`. Community planes keep using `Signal::Event(id)`.
4. `load_list` reads every `33302` event by `self_pk` from the database, keeps the
newest event per fragment index, decrypts and `merge`s them. `.limit(1)` is gone.
An incomplete List is read normally — a missing fragment is news not yet heard.
5. `load` unions two sources: every live List entry (materialized with
`from_join_material`, or refreshed if a state document already exists) and every
held local state the List does not mention. A held membership is dropped only
when a tombstone outranks its `added_at_ms`; absence from the List is never a
fact. Each list-derived state is `save_state`d, so the next `load` is warm.
6. `refresh(held, fresh)` keeps the fold's authority (`heads`, `banned`,
`dissolved`) and the control planes it learned, and takes the List's identity,
relays, and channel keys. Channels are merged by id rather than replaced, so a
public channel the fold discovered is not shed by a List snapshot that predates
it.
**As built, deviating from the sketch above.** The plan called for
`client.fetch_events(..)`; the SDK's own recommendation is to keep the request
path on a subscription and read the database. This is safer than it sounds: a
relay's event is persisted at `nostr-sdk/src/relay/inner.rs:1291` **before** the
notification is emitted, so a subscription plus a database read loses nothing and
needs no explicit save. The subscription is set up with `ReqTarget::auto` rather
than a hand-built NIP-65 relay map, because gossip already resolves the author's
write relays and connects them on demand.
Tests (no network, in `crates/community/src/sync.rs`): a membership the List
carries materializes a community even though no state document was ever written
for it, and discovery writes the document so the next load is warm; a held
membership the List never mentions is kept alongside the one it does; a tombstone
outranks a held membership and drops it; and the `concord/list` id is not read as
a community subscription. Fragment events are built with `store::list_entry` +
`CommunityList::joined` + `build_list_event` and saved straight into a memory
database, so the tests exercise the real seal/parse/merge path without a relay.
### Phase D — publish — DONE
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`,
`crates/concord/src/cords/cord02/list.rs`
1. `create` mints the genesis, folds it into a state, and saves that state locally
as before, then announces the community: the genesis wraps to its relay set,
and the membership to the account's own List. Both publishes are best-effort —
a relay that is down is a warning, not a failed create.
2. The List write is a read-modify-write over the copy already held (§8). `create`
reads the newest held fragment, unions its own entry in with
`CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves
it locally as a side effect of `send_event`, before any relay is resolved, so
the fragment survives a relay that is down and no explicit database write is
needed.
3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable
relay can never quietly keep the copy the write meant to replace.
**As built, deviating from the sketch above.** Three decisions the sketch did not
cover:
- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the
community's metadata relays. The List is the member's own document, and it is
the same relay set `subscribe_list` resolves for its `author` filter — the two
halves must agree or a write can land where nothing reads. The genesis wraps,
which belong to the community and not the member, do go to the metadata relays.
- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`'
`extra` field is crate-private, so the community crate cannot build one; `name`
is passed in because the state does not carry it — the name lives in the Control
fold, and a created community has it in the metadata.
- A List that already spans more than one fragment is **left alone**: placing a
new membership needs a repack (which fragment does it belong in?), and §8 allows
a repack only against the complete List. `load` keeps a membership the List
never mentions, so the community is still tracked locally; the remote write is
deferred with a warning rather than performed wrongly.
Tests: `create` records a membership the List round-trips, and a second create
unions into the same document instead of replacing it.
### Phase E — verify live
`RUST_LOG=info cargo run -p coop`, sign in with the accordion account that
already belongs to communities. Expect `community {id}: subscribing to ..` and
rows in the sidebar. This is the first time the path can be exercised at all.
## Validation per phase
- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D).
- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`.
- A is provable against the spec's worked example, so it needs no relay.
- C is provable with `nostr-memory`: fragments are built with `build_list_event`
and saved as the subscription would have, then `load` reads them. No relay,
no `LocalRelay`.
- E is the only step that needs real relays.
## Risks and open decisions
- **Base64url is case-significant and coop's ids are hex everywhere else.**
Confine the codec to `cord02::list`; any normalisation that case-folds will
silently corrupt §8 values. **Resolved in Phase A**: the codec is private to
`list.rs` and never case-folds.
- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its
only bound is the 65,536-byte *encoded event*. `fits()` still measures the
NIP-44 plaintext, which understates that by roughly a third. Phase D kept the
count cap and added a guard: a List that already spans more than one fragment is
not appended to, because placing a new membership needs a repack. So a member
with more than one fragment gets no remote write until fragmentation lands; the
community stays local and visible.
- **Relay selection is the difference between finding the account's List and
not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the
filter's author to their NIP-65 write relays and connects them. A List
published only to relays with no NIP-65 entry is still unreachable; that is a
user-visible relay setting if it ever bites.
- **Private channels stay unsubscribed until `planes()` derives their address
from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the
discovery fix does not need it). Public discovery works regardless.
- **Two writers, one key.** Once coop publishes `33302`, an account used from
both accordion and coop has both clients writing the List. §8's
read-modify-write is what keeps that from losing memberships — it is not
optional.
- **A create racing the first list sync can publish over an unseen List.**
`record_membership` unions into what the local database holds, and on a fresh
sign-in that is empty until the `concord/list` subscription has delivered. A
create in that window writes a one-entry fragment 0, and an addressable relay
then replaces the account's fuller List with it. The window is the ordinary
sign-in-to-create interval, so it is small but not zero. The honest fix is to
treat the List write as part of the sync loop — republish `list ∪ local
memberships` whenever the subscription settles — rather than doing it inside
`create`; an EOSE flag is not enough on its own, because an account with no
NIP-65 relays never reaches EOSE and would then never write at all.
- **`store::save_state` signs with a per-process random key.** Harmless while it
stays local, but it means the state document can never be published or
compared; if a future phase wants it on the wire, it needs the account signer.
- **The deployed reference client still writes the retired kind `13302`.** The
spec this plan implements (`concord-protocol/concord` `main`) moved the List to
`33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that
accordion.chat builds against still declares `13302`, single-event, capped at 50
memberships, at its head of **2026-08-05**; accordion's pin predates even that
(`0.0.0-concord-20260804145327`). So an account whose memberships were written
by that build stores them under a kind coop deliberately does not read, and will
show an empty sidebar until the client is updated to the fragmented kind. This
is not a bug in the discovery path — Phases C and D are correct against the
current spec — but it is the first thing to check if a live sign-in still shows
nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the
reference client moves.
-358
View File
@@ -1,358 +0,0 @@
# Concord backend audit and simplification plan
Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching
`subscribe`: `sync::load` found zero community state documents. Tracing that
surfaced two separate things: the app only uses a fraction of the crate, and the
crate's writers take a concrete `nostr::Keys`, which the app's signer can never
produce.
Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests.
## Decisions taken
- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging
stay in the tree for future use. No mass deletion. (Findings are recorded in
§4 for reference only.)
- **D2 — Replace `&Keys` with a signer boundary** for account-key operations.
Verified feasible against the pinned SDK; design in §2.
---
## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer
The original question was whether functions like `genesis` only need
`signer.get_public_key_async()`. They do not: they sign.
- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) →
`build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`,
`cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap.
- Self-addressed documents use NIP-44 to self: `seal_to_self`
(`cord01.rs:201`) derives a conversation key from `keys.secret_key()`.
A public key can produce neither a Schnorr signature nor an ECDH key, so
"public-key-only" is impossible. The real defect is the **concrete type**: the
app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys`
can never be conjured from it. `docs/concord-usage.md:535-536` already records
this as a deliberate migration pass.
### What the pinned SDK actually provides
Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2):
- There is **no `NostrSigner` trait in this revision.** The async signer surface
is three traits, all in the `nostr` crate:
- `AsyncGetPublicKey` — `nostr/src/key/public_key.rs:39`
- `AsyncSignEvent` — `nostr/src/event/mod.rs:366`
- `AsyncNip44` — `nostr/src/nips/nip44/traits.rs:30`
- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and
local key holders keep working.
- `UniversalSigner` already implements all three with
`Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`).
- SDK helpers accept them:
- `EventBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(`nostr/src/event/builder.rs:171-193`)
- `GiftWrapBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44`
(`nostr/src/nips/nip59.rs:334-355`)
- `UnwrappedGift::from_gift_wrap_async` — `T: AsyncNip44` (`nip59.rs:84-90`)
So the answer is yes: pass a signer. `UniversalSigner` works as-is.
### Per-function bounds, not a bundle
Each function should request only the capabilities it uses. The SDK itself is
designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`,
`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`,
NIP-59 takes all three).
| Operation | Bounds |
| --- | --- |
| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` |
| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` |
| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event |
| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` |
| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) |
| Rekey blob open (`open_blob`) | `AsyncNip44` |
| Direct invite build (`GiftWrapBuilder`) | all three |
Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry
associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error
at every call site (`dyn AsyncSignEvent<Error = UniversalSignerError>`),
defeating the abstraction. The SDK uses generics throughout for this reason.
Do **not** define a supertrait bundle
`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three
supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous,
and the bundle forces NIP-44 onto purely-signing callers (and vice versa).
Inside concord, replace `builder.finalize(&keys)` with
`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's
public key and uses it as the event author, exactly as `finalize` did, so the
bytes are unchanged for every caller that passes a matching signer.
We deliberately **do not** pre-check the author against `rumor.pubkey` inside
`build_seal`. The seal's author is the signer's own public key, matching the old
`finalize` semantics; a signer that does not match the rumor is still caught by
`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also
make it impossible to construct the hostile seals the cord suite relies on as
test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`).
If the repeated `<S as ...>::Error: Error + Send + Sync + 'static` bounds
become too noisy, the only stable-Rust way to shorten them is an owned
error-erased trait (as the app already does with
`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep
per-function bounds unless the noise proves unmanageable.
### What must NOT go through the signer
- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal,
wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from
HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group
encryption stays on `ConversationKey` / `GroupKey::keys()`.
- **Wrap signatures.** Wraps are signed by the derived group signer key
(`GroupKey::keys()`), not the account.
- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}`
take a generated `link_signer` whose secret the app stores as
`signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the
app has the secret itself.
- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the
internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved.
### Call-site inventory
Account-key sites to migrate:
| Site | Today | After | Bounds |
| --- | --- | --- | --- |
| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` |
| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three |
| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` |
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) |
| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` |
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` |
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` |
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all
`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`.
### Async ripple and tests
Every migrated function becomes `async`. `smol` is already a dev-dependency of
concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become
`smol::block_on(...)` wrappers. The app's call sites are already async
background tasks.
### Known constraints
- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async`
are generic over `S: Sized` (no `?Sized`), so those functions must stay
generic, never `&dyn`.
- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the
SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for
`anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`,
`Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError`
(`crates/state/src/signer.rs:10-32`) all qualify.
- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the
author is embedded in the payload: `sign_event` signs the id of the given
unsigned event without rewriting its pubkey, so a mismatched signer is only
caught later by signature verification.
---
## 2. Migration plan
### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE
1. No new module: change the signatures listed in the inventory table to
generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`,
`S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`).
2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self,
open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook::
seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`.
3. Update `docs/concord-usage.md` examples to take a signer.
4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it
implements all three traits.
Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior
change from the plan sketch is the dropped up-front author check in §1.
`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String`
(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext
with `serde_json::from_str`.
**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are
shared helpers, so the unwired callers had to be migrated in the same pass to
keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59
and group-key paths, not the migrated helpers — and were migrated in Phase 3.
### Phase 2 — app uses the signer — DONE
1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs
`cord02::genesis`, opens the genesis editions, persists the state with
`store::save_state`, and also stores the genesis wraps so the control plane
folds locally. It is generic over `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(the bounds `genesis` needs and no more, per D2); the app passes its
`UniversalSigner`, so no secret material is exposed and NIP-46 accounts work
too.
`CommunityRegistry::create(metadata, cx)` (`crates/community/src/lib.rs`) is
the GPUI wrapper: it refuses when no account is signed in, otherwise runs the
task off-thread and refreshes tracking, so `sync::load` now returns one state
and `subscribe` finally fires.
2. The app-side reimplementation of `list::parse_list_event`
(`crates/community/src/sync.rs:154-156`) is deleted; `load_list` calls the
real `cord02::list::parse_list_event`.
3. Relays in the metadata are persisted but the genesis is **not** published yet;
`create` is local-only. Wiring genesis/broadcast through the relay pool is the
next app step, not part of this phase.
Validation: `cargo test -p community` (1 passed), `cargo test -p concord`
(46 passed), `cargo clippy -p community --all-targets`, `cargo fmt -p community
--check`, and `cargo check --workspace --all-targets` are all clean.
**Deviation from the plan sketch:** the planned "drive `CommunityRegistry`" test
is instead a `sync`-layer test, `sync::tests::
creating_a_community_persists_a_state_that_subscribes_and_folds`. A GPUI-level
test cannot construct a `NostrRegistry` — it opens LMDB at `config_dir()` and
connects bootstrap relays in `NostrRegistry::new`, which is private and not
injectable — so the test drives a `Client` on an in-memory database
(`nostr-memory`, already a dev-dependency) directly. It asserts the whole
contract the registry depends on: `create` persists a state `load` returns, the
subscription filter addresses the genesis wraps, `fold` yields the created
community, and an inbound control edit folds over it.
### Phase 3 — migrate the remaining unwired writers — DONE
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a
`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build,
`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and
`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is
`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`.
The blobs forced one behavior change, because a signer's NIP-44 is text-only
(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a
fixed-width binary record. `build_blob` now carries that record base64-encoded
inside the NIP-44 envelope and `open_blob` decodes it again. The record layout,
the `locator`, and the envelope are unchanged; only the bytes inside the envelope
differ. There are no golden vectors for blobs and no producer or consumer other
than these two functions, so the round-trip stays self-consistent; cord06 remains
unwired and persists nothing.
Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under
the base64 record). `cargo clippy -p concord --all-targets` and
`cargo fmt -p concord --check` are clean.
### Phase 4 — duplication and hygiene (independent, low risk) — DONE
1. DONE — `store::load_states(client)` added (with a direct `store` test), the
app-side state-document scan in `sync::load` is gone.
2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
gone, and subscription ids reuse the exported prefix.
3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
`optional_citation`), re-exported as `cord03::ChatError` and
`cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
`RekeyError`, which the plan scoped out.
4. RETAINED — none of the "never-varied parameters" were removed. Each is
load-bearing for a flow the fold or a writer already implements (D1):
- `complete_memberlist`'s `banned_at` is read by the fold and is exercised
with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
`docs/concord-usage.md` already promises to fill it once the banlist head's
timestamp is plumbed through.
- `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
- `coalesce`'s `snapshot_authority` gates which snapshots apply; passing
`None` today is a policy, not a dead parameter.
- `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors`
select protocol modes and paging.
5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`,
`bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no
longer `pub`. `HeadSelection` stays `pub` because the public `fold_head`
returns it.
6. DONE — doc drift fixed: the store takes `&Client` throughout (including
`load_state`/`load_states`/`query_rumors`, not just the writers), `backfill`
arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and
registry names, and the "Not wired up yet" registry bullet.
### Phase 5 — sidebar calls `create` — DONE
The last blocker was that nothing invoked `CommunityRegistry::create`; the
running app logged `community load: 0 state document(s) found` and `subscribe`
never ran. The sidebar now:
1. Renders `CommunityRegistry::communities()` instead of the hardcoded
`dummy_communities()`. `SidebarRow::Community` carries an `Entity<Community>`,
labelled with `Community::name()` (control-fold metadata, falling back to the
community id until the first fold).
2. Adds a "New community" row to the Community section that opens a name prompt
and calls `CommunityRegistry::create` with default metadata. Relays stay empty,
so the subscription resolves through `ReqTarget::auto` against the pool's
relays rather than a manual target that `add_relay` might not have connected.
3. Observes the registry, so a `track` or fold re-render reaches the list, and
subscribes to `CommunityEvent::Error`, which is now logged
(`log::error!("community: {error}")`) instead of vanishing. A `cx.notify()` in
the registry's per-community observer propagates the fold that fills in the
name.
Validation: `cargo check -p workspace -p community --all-targets`,
`cargo test -p community` (1 passed), `cargo clippy -p workspace -p community
--all-targets`, and `cargo fmt -p workspace -p community --check` are clean.
Still local-only: the genesis is persisted but not published to relays, so a
second account cannot discover the community yet.
---
## 3. Retained-by-decision surface (reference only)
Per D1 these stay, but they should be understood as unwired, not live:
| Module | Approx. prod LOC | App use |
| --- | --- | --- |
| `cord06` rotation/refounding/dissolution | ~850 | none |
| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) |
| `cord04::pins` | ~550 | none |
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` |
Truly unreferenced even by tests (safe candidates, but kept per D1):
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
`CommunityRoles::{roles, is_empty}`.
---
## 4. Non-goals
- No mass deletion of unwired modules (D1).
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01`
envelope semantics. The one exception Phase 3 forced is the blob plaintext
encoding (base64 inside the envelope, see Phase 3); the blob record layout and
`locator` are untouched.
- No group-key encryption through the signer.
- Tests move only alongside the code they cover.
## 5. Validation
- `cargo test -p concord` after each phase; `cargo test --workspace` before
landing.
- Phase 1 is behavior-preserving: the existing cord test suite is the oracle.
- Phase 2 adds the app-level test: seed a `CommunityState` via
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
and an inbound wrap folds into the community.
- Phase 4: `cargo test -p concord -p community` (47 + 1 passed),
`cargo clippy -p concord -p community --all-targets`, and
`cargo fmt -p concord -p community --check` are all clean.
## 6. Immediate unblock
Option 2 (the clean path, using `UniversalSigner`) landed in Phase 2. Option 1
(exposing the local `Keys` from `crates/state/src/lib.rs:254`) is obsolete.
+4 -3
View File
@@ -602,9 +602,10 @@ client.subscribe(filter).with_id(sub_id).await?;
`crates/community`.** `concord` has no subscriptions, no `init`, and no `crates/community`.** `concord` has no subscriptions, no `init`, and no
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>` `Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
per state document, subscribes when a community's plane set changes, and per state document, subscribes when a community's plane set changes, and
re-folds on an inbound wrap. The sidebar observes the registry, logs re-folds on an inbound wrap. The sidebar subscribes to the registry, surfaces
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens `CommunityEvent::Error` as a window notification, and its "New community" row
a name prompt that calls `CommunityRegistry::create`. `create` persists the in the Communities tab dispatches `Command::NewCommunity`, whose name prompt
calls `CommunityRegistry::create`. `create` persists the
genesis locally, publishes the wraps to the community's relays, and records the genesis locally, publishes the wraps to the community's relays, and records the
membership in the account's Community List — all best-effort, so a relay that is membership in the account's Community List — all best-effort, so a relay that is
down warns without losing the community. Discovery down warns without losing the community. Discovery