Compare commits
12
Commits
b8f8737dc5
...
29f3c74d06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f3c74d06 | ||
|
|
3bbdc5ad34 | ||
|
|
10e53b9fcc | ||
|
|
5c9005d473 | ||
|
|
9c735febc1 | ||
|
|
8401628412 | ||
|
|
974f8302a1 | ||
|
|
564382756d | ||
|
|
52f92c226f | ||
|
|
323545ce16 | ||
|
|
9e47882fb1 | ||
|
|
bd8bff4693 |
Generated
+19
@@ -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",
|
||||||
|
|||||||
@@ -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 |
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¤t_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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(§ion) {
|
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(§ion)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(¤t_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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user