Compare commits
7
Commits
0ad491cb92
...
70140b2454
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70140b2454 | ||
|
|
2def7548ef | ||
|
|
1320a2c361 | ||
|
|
88005fbc41 | ||
|
|
41bd0ce345 | ||
|
|
492e50746f | ||
|
|
e75b1b9f10 |
Generated
+17
@@ -1274,6 +1274,23 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "community"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"concord",
|
||||
"flume 0.11.1",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.43"
|
||||
|
||||
+14
-5
@@ -289,12 +289,21 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar()
|
||||
/// Gets the display picture for the room, if it has one
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if self.is_group() {
|
||||
None
|
||||
} else {
|
||||
SharedString::from("brand/group.png")
|
||||
self.display_member(cx).avatar()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable seed for the room's generated avatar
|
||||
pub fn display_image_seed(&self, cx: &App) -> SharedString {
|
||||
if self.is_group() {
|
||||
SharedString::from(self.id.to_string())
|
||||
} else {
|
||||
self.display_member(cx).avatar_seed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1203,6 +1203,7 @@ impl ChatPanel {
|
||||
if show_author {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
@@ -1470,7 +1471,7 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.font_semibold()
|
||||
.child(Avatar::new(avatar).small())
|
||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||
.child(name.clone()),
|
||||
),
|
||||
)
|
||||
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
|
||||
self.room
|
||||
.read_with(cx, |this, cx| {
|
||||
let label = this.display_name(cx);
|
||||
let url = this.display_image(cx);
|
||||
let picture = this.display_image(cx);
|
||||
let seed = this.display_image_seed(cx);
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(Avatar::new(url).xsmall())
|
||||
.child(Avatar::new(picture).seed(seed).xsmall())
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "community"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
concord = { path = "../concord" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
serde_json.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
smol.workspace = true
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use concord::cord02::ControlFold;
|
||||
use concord::store::{ChannelKeyRef, CommunityState};
|
||||
use concord::{ChannelId, CommunityId, Epoch};
|
||||
use gpui::{AppContext, Context, EventEmitter, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::sync::{self, Snapshot};
|
||||
|
||||
/// Everything that decides which planes a community is subscribed to. The
|
||||
/// registry re-subscribes only when this changes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionKey {
|
||||
control_pks: BTreeMap<u64, PublicKey>,
|
||||
channels: Vec<(ChannelId, Epoch, bool)>,
|
||||
relays: Vec<RelayUrl>,
|
||||
}
|
||||
|
||||
impl SubscriptionKey {
|
||||
fn of(state: &CommunityState) -> Self {
|
||||
Self {
|
||||
control_pks: state.control_pks.clone(),
|
||||
channels: state
|
||||
.channels
|
||||
.iter()
|
||||
.map(|channel| (channel.id, channel.epoch, channel.private))
|
||||
.collect(),
|
||||
relays: state.relays.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommunityEvent {
|
||||
Updated(CommunityId),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct Community {
|
||||
state: CommunityState,
|
||||
control: ControlFold,
|
||||
members: BTreeSet<PublicKey>,
|
||||
database: Arc<dyn NostrDatabase>,
|
||||
dirty: bool,
|
||||
refresh_task: Option<Task<Result<()>>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<CommunityEvent> for Community {}
|
||||
|
||||
impl Community {
|
||||
pub fn new(state: CommunityState, database: Arc<dyn NostrDatabase>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
control: ControlFold::default(),
|
||||
members: BTreeSet::new(),
|
||||
database,
|
||||
dirty: false,
|
||||
refresh_task: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> CommunityId {
|
||||
self.state.id
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &CommunityState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub fn control(&self) -> &ControlFold {
|
||||
&self.control
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &BTreeSet<PublicKey> {
|
||||
&self.members
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> &[ChannelKeyRef] {
|
||||
&self.state.channels
|
||||
}
|
||||
|
||||
pub fn subscription_key(&self) -> SubscriptionKey {
|
||||
SubscriptionKey::of(&self.state)
|
||||
}
|
||||
|
||||
/// Rebuilds the community from the wraps in the local database. A burst of
|
||||
/// signals produces at most two folds: one running, one owed.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh_task.is_some() {
|
||||
self.dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let database = self.database.clone();
|
||||
let state = self.state.clone();
|
||||
let folded =
|
||||
cx.background_spawn(async move { sync::fold(database.as_ref(), &state).await });
|
||||
|
||||
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||
let result = folded.await;
|
||||
this.update(cx, |this, cx| this.apply(result, cx))?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn apply(&mut self, result: Result<Option<Snapshot>>, cx: &mut Context<Self>) {
|
||||
self.refresh_task = None;
|
||||
|
||||
match result {
|
||||
Ok(Some(snapshot)) => {
|
||||
self.state = snapshot.state;
|
||||
self.control = snapshot.control;
|
||||
self.members = snapshot.members;
|
||||
cx.emit(CommunityEvent::Updated(self.state.id));
|
||||
cx.notify();
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
|
||||
}
|
||||
|
||||
if self.dirty {
|
||||
self.dirty = false;
|
||||
self.refresh(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use concord::cord02::GENERAL_CHANNEL;
|
||||
use concord::cord02::guestbook::{build_join, build_leave, seal_rumor};
|
||||
use concord::derive::guestbook_group_key;
|
||||
use concord::store::{load_state, save_state};
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::sync::fixtures::{AT_MS, community};
|
||||
|
||||
#[test]
|
||||
fn genesis_folds_into_metadata_channels_and_the_owner() {
|
||||
smol::block_on(async {
|
||||
let owner = Keys::generate();
|
||||
let (genesis, state) = community(&owner);
|
||||
let database = MemoryDatabase::unbounded();
|
||||
|
||||
for wrap in &genesis.wraps {
|
||||
database.save_event(wrap).await.expect("saves wrap");
|
||||
}
|
||||
save_state(&database, &state).await.expect("saves state");
|
||||
|
||||
let snapshot = sync::fold(&database, &state)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("genesis is a control edition");
|
||||
|
||||
let metadata = snapshot.control.community.as_ref().expect("metadata");
|
||||
assert_eq!(metadata.name, "Room");
|
||||
|
||||
let channel = snapshot
|
||||
.control
|
||||
.channels
|
||||
.get(&genesis.channel_id)
|
||||
.expect("general channel");
|
||||
assert_eq!(channel.name, GENERAL_CHANNEL);
|
||||
assert!(!channel.private);
|
||||
|
||||
assert_eq!(snapshot.members, BTreeSet::from([owner.public_key()]));
|
||||
|
||||
let persisted = load_state(&database, &state.id)
|
||||
.await
|
||||
.expect("loads")
|
||||
.expect("persisted");
|
||||
assert_eq!(persisted.id, state.id);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_adds_a_member_and_a_later_leave_removes_them() {
|
||||
smol::block_on(async {
|
||||
let owner = Keys::generate();
|
||||
let member = Keys::generate();
|
||||
let (genesis, state) = community(&owner);
|
||||
let database = MemoryDatabase::unbounded();
|
||||
|
||||
for wrap in &genesis.wraps {
|
||||
database.save_event(wrap).await.expect("saves wrap");
|
||||
}
|
||||
save_state(&database, &state).await.expect("saves state");
|
||||
|
||||
let guestbook = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)
|
||||
.expect("guestbook key");
|
||||
|
||||
let join = seal_rumor(
|
||||
&build_join(member.public_key(), None, AT_MS + 1_000),
|
||||
&guestbook,
|
||||
&member,
|
||||
)
|
||||
.expect("seals join")
|
||||
.0;
|
||||
database.save_event(&join).await.expect("saves join");
|
||||
|
||||
let joined = sync::fold(&database, &state)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("control is still held");
|
||||
assert!(joined.members.contains(&member.public_key()));
|
||||
assert_eq!(joined.members.len(), 2);
|
||||
|
||||
let leave = seal_rumor(
|
||||
&build_leave(member.public_key(), AT_MS + 2_000),
|
||||
&guestbook,
|
||||
&member,
|
||||
)
|
||||
.expect("seals leave")
|
||||
.0;
|
||||
database.save_event(&leave).await.expect("saves leave");
|
||||
|
||||
let left = sync::fold(&database, &joined.state)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("control is still held");
|
||||
assert!(!left.members.contains(&member.public_key()));
|
||||
assert_eq!(left.members, BTreeSet::from([owner.public_key()]));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use gpui::{App, Window};
|
||||
|
||||
mod community;
|
||||
mod sync;
|
||||
|
||||
pub use community::*;
|
||||
pub use sync::*;
|
||||
|
||||
pub fn init(_window: &mut Window, _cx: &mut App) {}
|
||||
@@ -0,0 +1,421 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Result;
|
||||
use concord::cord01::KIND_WRAP;
|
||||
use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
|
||||
use concord::cord02::{self, ControlFold};
|
||||
use concord::cord04::AuthorityCitation;
|
||||
use concord::cord04::roles::{Permissions, citation_ok};
|
||||
use concord::derive::{channel_group_key, control_group_key, guestbook_group_key};
|
||||
use concord::store::{self, CommunityState};
|
||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
|
||||
const SUBSCRIPTION_PREFIX: &str = "concord/";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PlaneKind {
|
||||
Control(Epoch),
|
||||
Guestbook,
|
||||
Channel(ChannelId, Epoch),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Plane {
|
||||
pub kind: PlaneKind,
|
||||
/// The wrap's author: the control signer for Control, the group's own key otherwise.
|
||||
pub address: PublicKey,
|
||||
pub group: GroupKey,
|
||||
}
|
||||
|
||||
pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
||||
let mut planes = Vec::new();
|
||||
|
||||
for (epoch, address) in &state.control_pks {
|
||||
let epoch = Epoch(*epoch);
|
||||
let group = control_group_key(&state.community_root, &state.id, epoch)?;
|
||||
planes.push(Plane {
|
||||
kind: PlaneKind::Control(epoch),
|
||||
address: *address,
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
|
||||
planes.push(Plane {
|
||||
kind: PlaneKind::Guestbook,
|
||||
address: group.pk(),
|
||||
group,
|
||||
});
|
||||
|
||||
for channel in &state.channels {
|
||||
if channel.private {
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = channel_group_key(&state.community_root, &channel.id, channel.epoch)?;
|
||||
planes.push(Plane {
|
||||
kind: PlaneKind::Channel(channel.id, channel.epoch),
|
||||
address: group.pk(),
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(planes)
|
||||
}
|
||||
|
||||
/// One `Filter` covering every held plane. The address is the event author,
|
||||
/// not a `p` tag: a Concord wrap's `p` tag carries a random ephemeral key.
|
||||
pub fn subscription_filter(planes: &[Plane]) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::from(KIND_WRAP)])
|
||||
.authors(planes.iter().map(|plane| plane.address))
|
||||
}
|
||||
|
||||
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex()))
|
||||
}
|
||||
|
||||
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
|
||||
subscription_id
|
||||
.as_str()
|
||||
.strip_prefix(SUBSCRIPTION_PREFIX)?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub state: CommunityState,
|
||||
pub control: ControlFold,
|
||||
pub members: BTreeSet<PublicKey>,
|
||||
}
|
||||
|
||||
/// Discovers the current account's communities from the local database.
|
||||
pub async fn load(
|
||||
database: &dyn NostrDatabase,
|
||||
signer: &UniversalSigner,
|
||||
self_pk: PublicKey,
|
||||
) -> Result<Vec<CommunityState>> {
|
||||
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
|
||||
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
|
||||
|
||||
for event in database.query(filter).await? {
|
||||
let Some(id) = state_document_of(&event) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some(existing) if existing.created_at >= event.created_at => {}
|
||||
_ => {
|
||||
newest.insert(id, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut states = Vec::with_capacity(newest.len());
|
||||
|
||||
for event in newest.into_values() {
|
||||
match serde_json::from_str::<CommunityState>(&event.content) {
|
||||
Ok(state) => states.push(state),
|
||||
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(list) = load_list(database, signer, self_pk).await? {
|
||||
states.retain(|state| list.is_live(&state.id));
|
||||
}
|
||||
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
fn state_document_of(event: &Event) -> Option<CommunityId> {
|
||||
let identifier = event.tags.identifier()?;
|
||||
let hex = identifier.strip_prefix(STATE_PREFIX)?;
|
||||
hex.parse().ok()
|
||||
}
|
||||
|
||||
async fn load_list(
|
||||
database: &dyn NostrDatabase,
|
||||
signer: &UniversalSigner,
|
||||
self_pk: PublicKey,
|
||||
) -> Result<Option<CommunityList>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
||||
.author(self_pk)
|
||||
.limit(1);
|
||||
|
||||
let Some(event) = database.query(filter).await?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?;
|
||||
|
||||
Ok(Some(serde_json::from_str(&json)?))
|
||||
}
|
||||
|
||||
/// Rebuilds a community from the wraps already in the local database.
|
||||
pub async fn fold(
|
||||
database: &dyn NostrDatabase,
|
||||
state: &CommunityState,
|
||||
) -> Result<Option<Snapshot>> {
|
||||
let planes = planes(state)?;
|
||||
|
||||
if planes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let wraps = database.query(subscription_filter(&planes)).await?;
|
||||
let mut editions = Vec::new();
|
||||
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
|
||||
let mut guestbook_rumors = Vec::new();
|
||||
|
||||
for wrap in &wraps {
|
||||
let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match plane.kind {
|
||||
PlaneKind::Control(_) => {
|
||||
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
||||
{
|
||||
editions.push(edition);
|
||||
}
|
||||
}
|
||||
PlaneKind::Guestbook => {
|
||||
if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) {
|
||||
observe(&mut observed, rumor.author, rumor.at_ms);
|
||||
guestbook_rumors.push(rumor);
|
||||
}
|
||||
}
|
||||
PlaneKind::Channel(channel, epoch) => {
|
||||
if let Ok((opened, rumor)) =
|
||||
concord::cord03::open(wrap, &plane.group, &channel, epoch)
|
||||
{
|
||||
store::cache_rumor(database, &channel, &opened).await?;
|
||||
observe(&mut observed, rumor.author, rumor.at_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if editions.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let control = cord02::fold_control(
|
||||
&state.owner,
|
||||
&state.id,
|
||||
&editions,
|
||||
&state.floors(),
|
||||
&state.banned,
|
||||
);
|
||||
|
||||
let granted: BTreeSet<PublicKey> = control
|
||||
.roles
|
||||
.grants()
|
||||
.filter(|grant| !grant.role_ids.is_empty())
|
||||
.map(|grant| grant.member)
|
||||
.collect();
|
||||
|
||||
let floors = state.floors();
|
||||
let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
|
||||
citation_ok(&state.owner, &state.id, actor, citation, &floors)
|
||||
&& control
|
||||
.roles
|
||||
.can_act_on_member(actor, &state.owner, target, Permissions::KICK)
|
||||
};
|
||||
|
||||
let now_ms = Timestamp::now().as_secs().saturating_mul(1000);
|
||||
let coalesced = cord02::guestbook::coalesce(&guestbook_rumors, now_ms, None, can_kick);
|
||||
let mut members = cord02::guestbook::complete_memberlist(
|
||||
&coalesced,
|
||||
&observed,
|
||||
&granted,
|
||||
&control.banned,
|
||||
&BTreeMap::new(),
|
||||
);
|
||||
|
||||
// The roster has no grant for the owner, so membership is stated here.
|
||||
members.insert(state.owner);
|
||||
|
||||
let mut state = state.clone();
|
||||
state.apply_fold(&control);
|
||||
store::save_state(database, &state).await?;
|
||||
|
||||
Ok(Some(Snapshot {
|
||||
state,
|
||||
control,
|
||||
members,
|
||||
}))
|
||||
}
|
||||
|
||||
fn observe(observed: &mut BTreeMap<PublicKey, u64>, author: PublicKey, at_ms: u64) {
|
||||
observed
|
||||
.entry(author)
|
||||
.and_modify(|seen| *seen = (*seen).max(at_ms))
|
||||
.or_insert(at_ms);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fixtures {
|
||||
use concord::cord02::{CommunityGenesis, CommunityMetadata, ROOT_EPOCH};
|
||||
use concord::cord04::ParsedEdition;
|
||||
use concord::derive::control_signer_group_key;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const AT_MS: u64 = 1_719_800_000_000;
|
||||
|
||||
/// A genesis and the state it folds into, ready for a test database.
|
||||
pub fn community(owner: &Keys) -> (CommunityGenesis, CommunityState) {
|
||||
let metadata = CommunityMetadata {
|
||||
name: "Room".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
let genesis = cord02::genesis(owner, &metadata, AT_MS / 1000).expect("genesis");
|
||||
let read = control_group_key(
|
||||
&genesis.community_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)
|
||||
.expect("read key");
|
||||
let address = control_signer_group_key(
|
||||
&genesis.control_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)
|
||||
.expect("signer key")
|
||||
.pk();
|
||||
|
||||
let editions: Vec<ParsedEdition> = genesis
|
||||
.wraps
|
||||
.iter()
|
||||
.map(|wrap| cord02::open_edition(wrap, &read, &address, true).expect("opens"))
|
||||
.collect();
|
||||
|
||||
let state = CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state");
|
||||
|
||||
(genesis, state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use concord::cord02::ROOT_EPOCH;
|
||||
use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event};
|
||||
use concord::store::save_state;
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::fixtures::{AT_MS, community};
|
||||
use super::*;
|
||||
|
||||
fn material(state: &CommunityState) -> JoinMaterial {
|
||||
JoinMaterial {
|
||||
community_id: state.id,
|
||||
owner: state.owner,
|
||||
owner_salt: "00".repeat(32),
|
||||
community_root: "11".repeat(32),
|
||||
root_epoch: ROOT_EPOCH,
|
||||
control_pk: None,
|
||||
control_root: None,
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
name: "Room".to_owned(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(state: &CommunityState, added_at: u64) -> CommunityListEntry {
|
||||
let material = material(state);
|
||||
|
||||
CommunityListEntry {
|
||||
community_id: state.id,
|
||||
seed: material.clone(),
|
||||
current: material,
|
||||
added_at,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_held_plane_routes_by_its_wrap_author() {
|
||||
let owner = Keys::generate();
|
||||
let state = community(&owner).1;
|
||||
let planes = planes(&state).expect("planes");
|
||||
|
||||
assert_eq!(
|
||||
planes.len(),
|
||||
3,
|
||||
"the control epoch, the guestbook and #general"
|
||||
);
|
||||
|
||||
let filter = subscription_filter(&planes);
|
||||
let expected: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
|
||||
assert_eq!(filter.authors, Some(expected));
|
||||
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)])));
|
||||
|
||||
assert_eq!(community_of(&subscription_id(&state.id)), Some(state.id));
|
||||
assert_eq!(community_of(&SubscriptionId::new("device-giftwrap")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_scans_state_documents_and_honours_the_list() {
|
||||
smol::block_on(async {
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
let owner = Keys::generate();
|
||||
let state = community(&owner).1;
|
||||
|
||||
// With no list event, every state document is a community.
|
||||
let no_list = MemoryDatabase::unbounded();
|
||||
save_state(&no_list, &state).await.expect("saves");
|
||||
let loaded = load(&no_list, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, state.id);
|
||||
|
||||
// A live entry keeps it.
|
||||
let event = build_list_event(
|
||||
&keys,
|
||||
&CommunityList {
|
||||
entries: vec![entry(&state, AT_MS)],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("builds");
|
||||
let live = MemoryDatabase::unbounded();
|
||||
save_state(&live, &state).await.expect("saves");
|
||||
live.save_event(&event).await.expect("saves list");
|
||||
let loaded = load(&live, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
assert_eq!(loaded.len(), 1);
|
||||
|
||||
// A newer tombstone than the entry retires it.
|
||||
let event = build_list_event(
|
||||
&keys,
|
||||
&CommunityList {
|
||||
entries: vec![entry(&state, AT_MS)],
|
||||
tombstones: vec![Tombstone {
|
||||
community_id: state.id,
|
||||
removed_at: AT_MS + 1,
|
||||
extra: Default::default(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("builds");
|
||||
let retired = MemoryDatabase::unbounded();
|
||||
save_state(&retired, &state).await.expect("saves");
|
||||
retired.save_event(&event).await.expect("saves list");
|
||||
let loaded = load(&retired, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
assert!(loaded.is_empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ fn state_identifier(id: &CommunityId) -> String {
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
D: NostrDatabase + ?Sized,
|
||||
{
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
@@ -304,7 +304,7 @@ where
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
D: NostrDatabase + ?Sized,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
|
||||
@@ -655,7 +655,11 @@ impl DeviceRegistry {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).xsmall())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -103,14 +103,18 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
/// Get profile picture, if the profile has one
|
||||
pub fn avatar(&self) -> Option<SharedString> {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| picture.into())
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
/// A stable seed for this profile's generated avatar
|
||||
pub fn avatar_seed(&self) -> SharedString {
|
||||
SharedString::from(self.public_key().to_hex())
|
||||
}
|
||||
|
||||
/// Get profile name
|
||||
|
||||
@@ -46,6 +46,8 @@ setting_accessors! {
|
||||
pub nip4e: bool,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub expanded_sections: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
@@ -130,6 +132,14 @@ pub struct Settings {
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Pinned sidebar room ids, in pin order
|
||||
#[serde(default)]
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
|
||||
/// Expanded sidebar tree sections; `None` means the default sections
|
||||
#[serde(default)]
|
||||
pub expanded_sections: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -142,6 +152,8 @@ impl Default for Settings {
|
||||
nip4e: false,
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
pinned_rooms: vec![],
|
||||
expanded_sections: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +183,13 @@ impl AppSettings {
|
||||
cx.global::<GlobalAppSettings>().0.clone()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// Set the global settings instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAppSettings(state));
|
||||
|
||||
+464
-29
@@ -1,12 +1,25 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
||||
Window, div, img, px,
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity,
|
||||
IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Selectable, Sizable, Size};
|
||||
use crate::{Selectable, Sizable, Size, StyledExt};
|
||||
|
||||
/// Number of rows and columns in the generated pixel grid.
|
||||
const PIXEL_GRID: usize = 8;
|
||||
/// Probability that a cell in the left half of the grid is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
|
||||
const MIN_FILLED: usize = 5;
|
||||
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
|
||||
const FALLBACK_SEED: &str = "coop";
|
||||
/// Number of segments used to approximate the avatar circle.
|
||||
const CIRCLE_SEGMENTS: usize = 32;
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size`].
|
||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
@@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
||||
///
|
||||
/// Use it for entities that have no profile picture: the same seed always
|
||||
/// renders the same pattern, so identities stay recognizable without a
|
||||
/// network round trip. The pattern is painted as geometry and cropped to a
|
||||
/// circle, at the same sizes as [`Avatar`].
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::{Avatar};
|
||||
/// use ui::avatar::PixelAvatar;
|
||||
///
|
||||
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
|
||||
/// PixelAvatar::new("alice");
|
||||
/// ```
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Creates a pixel avatar from `seed`.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: Size::Medium,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for PixelAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let side = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let seed = self.seed;
|
||||
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| seed,
|
||||
move |bounds, seed, window, cx| {
|
||||
let theme = cx.theme();
|
||||
let main = Hsla {
|
||||
h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1.,
|
||||
s: 0.6,
|
||||
l: if theme.is_dark() { 0.6 } else { 0.45 },
|
||||
a: 1.,
|
||||
};
|
||||
let shade = if theme.is_dark() {
|
||||
Hsla {
|
||||
l: (main.l * 1.6).min(0.95),
|
||||
..main
|
||||
}
|
||||
} else {
|
||||
Hsla {
|
||||
l: (main.l * 0.45).max(0.18),
|
||||
..main
|
||||
}
|
||||
};
|
||||
|
||||
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
|
||||
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
|
||||
|
||||
let pattern = pixel_pattern(seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
for (value, color) in [(1u8, main), (2u8, shade)] {
|
||||
cells.clear();
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID {
|
||||
if pattern[row * PIXEL_GRID + col] != value {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
|
||||
if cell.len() >= 3 {
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paint_polygons(window, cells.iter(), color);
|
||||
}
|
||||
},
|
||||
)
|
||||
.refine_style(&self.style)
|
||||
.size(side)
|
||||
.flex_shrink_0()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the mirrored fill pattern for `seed`.
|
||||
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
|
||||
let mut filled = 0usize;
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled < MIN_FILLED {
|
||||
let half = PIXEL_GRID * PIXEL_GRID / 2;
|
||||
let start = (rng.next() % half as u64) as usize;
|
||||
|
||||
for offset in 0..half {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
|
||||
let ix = (start + offset) % half;
|
||||
let row = ix / (PIXEL_GRID / 2);
|
||||
let col = ix % (PIXEL_GRID / 2);
|
||||
|
||||
if pattern[row * PIXEL_GRID + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Paints `polygons` as a single anti-aliased filled path in `color`.
|
||||
fn paint_polygons<'a>(
|
||||
window: &mut Window,
|
||||
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
|
||||
color: Hsla,
|
||||
) {
|
||||
let mut builder = PathBuilder::fill();
|
||||
let mut painted = false;
|
||||
|
||||
for polygon in polygons {
|
||||
if polygon.len() >= 3 {
|
||||
builder.add_polygon(polygon, true);
|
||||
painted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if painted && let Ok(path) = builder.build() {
|
||||
window.paint_path(path, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximates the circle of `radius` around `center` as a convex polygon,
|
||||
/// wound so that its interior is on the left of every directed edge.
|
||||
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
|
||||
let center_x = center.x.as_f32();
|
||||
let center_y = center.y.as_f32();
|
||||
|
||||
(0..CIRCLE_SEGMENTS)
|
||||
.map(|index| {
|
||||
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
|
||||
point(
|
||||
px(center_x + radius * angle.cos()),
|
||||
px(center_y + radius * angle.sin()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The four corners of cell `(row, col)` of the grid laid out in `bounds`.
|
||||
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
|
||||
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
|
||||
let left = bounds.origin.x.as_f32() + col as f32 * cell;
|
||||
let top = bounds.origin.y.as_f32() + row as f32 * cell;
|
||||
|
||||
[
|
||||
point(px(left), px(top)),
|
||||
point(px(left + cell), px(top)),
|
||||
point(px(left + cell), px(top + cell)),
|
||||
point(px(left), px(top + cell)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Clips `subject` to the convex `clip` polygon, keeping the part inside it.
|
||||
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
|
||||
let mut current = subject.to_vec();
|
||||
let mut next = Vec::with_capacity(subject.len() + 4);
|
||||
|
||||
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
|
||||
if current.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
next.clear();
|
||||
let mut previous = match current.last() {
|
||||
Some(&vertex) => vertex,
|
||||
None => break,
|
||||
};
|
||||
|
||||
for &vertex in current.iter() {
|
||||
let previous_inside = is_inside(start, end, previous);
|
||||
let vertex_inside = is_inside(start, end, vertex);
|
||||
|
||||
if vertex_inside {
|
||||
if !previous_inside
|
||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
||||
{
|
||||
next.push(crossing);
|
||||
}
|
||||
next.push(vertex);
|
||||
} else if previous_inside
|
||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
||||
{
|
||||
next.push(crossing);
|
||||
}
|
||||
|
||||
previous = vertex;
|
||||
}
|
||||
|
||||
std::mem::swap(&mut current, &mut next);
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
/// Whether `vertex` lies on the interior side of the directed edge `start -> end`.
|
||||
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
|
||||
let start_x = start.x.as_f32();
|
||||
let start_y = start.y.as_f32();
|
||||
let edge_x = end.x.as_f32() - start_x;
|
||||
let edge_y = end.y.as_f32() - start_y;
|
||||
let to_vertex_x = vertex.x.as_f32() - start_x;
|
||||
let to_vertex_y = vertex.y.as_f32() - start_y;
|
||||
|
||||
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
|
||||
}
|
||||
|
||||
/// The intersection of segment `from -> to` with the infinite line `start -> end`.
|
||||
fn line_intersection(
|
||||
start: Point<Pixels>,
|
||||
end: Point<Pixels>,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
) -> Option<Point<Pixels>> {
|
||||
let start_x = start.x.as_f32();
|
||||
let start_y = start.y.as_f32();
|
||||
let edge_x = end.x.as_f32() - start_x;
|
||||
let edge_y = end.y.as_f32() - start_y;
|
||||
let from_x = from.x.as_f32();
|
||||
let from_y = from.y.as_f32();
|
||||
let segment_x = to.x.as_f32() - from_x;
|
||||
let segment_y = to.y.as_f32() - from_y;
|
||||
let denominator = edge_x * segment_y - edge_y * segment_x;
|
||||
|
||||
if denominator.abs() < f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
let offset_x = from_x - start_x;
|
||||
let offset_y = from_y - start_y;
|
||||
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
|
||||
|
||||
Some(point(
|
||||
px(from_x + segment_x * t),
|
||||
px(from_y + segment_y * t),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fills `cell (row, col)` and its horizontal mirror.
|
||||
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
|
||||
pattern[row * PIXEL_GRID + col] = value;
|
||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||
struct PixelRng(u64);
|
||||
|
||||
impl PixelRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
|
||||
fn chance(&mut self, probability: f32) -> bool {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the generated pixel avatar shown in place of a missing picture.
|
||||
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
|
||||
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
|
||||
.with_size(size)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
///
|
||||
/// Entities without a picture still get a stable identity: the avatar falls
|
||||
/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there
|
||||
/// is no picture and when the picture fails to load.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::avatar::Avatar;
|
||||
///
|
||||
/// Avatar::new(None).seed("alice");
|
||||
/// ```
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
image: Img,
|
||||
picture: Option<SharedString>,
|
||||
grayscale: bool,
|
||||
seed: Option<SharedString>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
@@ -39,11 +383,16 @@ pub struct Avatar {
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Creates a new avatar element with the specified image source.
|
||||
pub fn new(src: impl Into<ImageSource>) -> Self {
|
||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
||||
///
|
||||
/// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when
|
||||
/// `picture` is `None`.
|
||||
pub fn new(picture: Option<SharedString>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
image: img(src),
|
||||
picture,
|
||||
grayscale: false,
|
||||
seed: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
border_color: None,
|
||||
@@ -51,17 +400,26 @@ impl Avatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the seed for the generated pixel avatar.
|
||||
///
|
||||
/// The seed should be a stable identifier of the entity the avatar
|
||||
/// represents, such as a public key.
|
||||
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
|
||||
self.seed = Some(seed.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Applies a grayscale filter to the avatar image.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::{Avatar, AvatarShape};
|
||||
/// use ui::avatar::Avatar;
|
||||
///
|
||||
/// let avatar = Avatar::new("path/to/image.png").grayscale(true);
|
||||
/// Avatar::new(None).grayscale(true);
|
||||
/// ```
|
||||
pub fn grayscale(mut self, grayscale: bool) -> Self {
|
||||
self.image = self.image.grayscale(grayscale);
|
||||
self.grayscale = grayscale;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -113,8 +471,24 @@ impl RenderOnce for Avatar {
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
let image_size = avatar_size(self.size);
|
||||
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let container_size = image_size + border_width * 2.;
|
||||
|
||||
let content = match self.picture {
|
||||
Some(picture) => {
|
||||
let seed = self.seed;
|
||||
let grayscale = self.grayscale;
|
||||
img(picture)
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.grayscale(grayscale)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
|
||||
.into_any_element()
|
||||
}
|
||||
None => generated_avatar(self.seed.as_deref(), image_size),
|
||||
};
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
@@ -124,18 +498,79 @@ impl RenderOnce for Avatar {
|
||||
.when_some(self.border_color, |this, color| {
|
||||
this.border(border_width).border_color(color)
|
||||
})
|
||||
.child(
|
||||
self.image
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || {
|
||||
img("brand/avatar.png")
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.child(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pixel_patterns_are_symmetric_and_stable() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pixel_pattern(seed);
|
||||
let filled = pattern.iter().filter(|&&cell| cell != 0).count();
|
||||
|
||||
assert!(
|
||||
filled >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID {
|
||||
assert_eq!(
|
||||
pattern[row * PIXEL_GRID + col],
|
||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
|
||||
"asymmetric pattern for seed {seed} at ({row}, {col})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for seed in [0, 1, 42, u64::MAX] {
|
||||
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
|
||||
}
|
||||
|
||||
assert_ne!(pixel_pattern(42), pixel_pattern(43));
|
||||
}
|
||||
|
||||
fn area(polygon: &[Point<Pixels>]) -> f32 {
|
||||
let mut sum: f32 = 0.;
|
||||
for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) {
|
||||
sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32();
|
||||
}
|
||||
(sum / 2.).abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_keeps_only_the_part_inside_the_circle() {
|
||||
let circle = circle_polygon(point(px(10.), px(10.)), 10.);
|
||||
let square = |left: f32, top: f32| {
|
||||
[
|
||||
point(px(left), px(top)),
|
||||
point(px(left + 4.), px(top)),
|
||||
point(px(left + 4.), px(top + 4.)),
|
||||
point(px(left), px(top + 4.)),
|
||||
]
|
||||
};
|
||||
|
||||
let inside = clip_polygon(&square(8., 8.), &circle);
|
||||
assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside));
|
||||
|
||||
assert!(clip_polygon(&square(20., 20.), &circle).is_empty());
|
||||
|
||||
let straddling = clip_polygon(&square(0., 0.), &circle);
|
||||
for vertex in &straddling {
|
||||
let delta_x = vertex.x.as_f32() - 10.;
|
||||
let delta_y = vertex.y.as_f32() - 10.;
|
||||
assert!(
|
||||
delta_x.hypot(delta_y) <= 10. + 0.1,
|
||||
"clipped vertex outside the circle"
|
||||
);
|
||||
}
|
||||
|
||||
let area = area(&straddling);
|
||||
assert!(area > 0. && area < 16., "area {area}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod indicator;
|
||||
pub mod input;
|
||||
pub mod menu;
|
||||
pub mod modal;
|
||||
pub mod nav_item;
|
||||
pub mod notification;
|
||||
pub mod popover;
|
||||
pub mod resizable;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
|
||||
IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::Selectable;
|
||||
use crate::avatar::Avatar;
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::popover::Popover;
|
||||
use crate::popover::{Popover, PopoverState};
|
||||
|
||||
/// Builds the items of a popup menu on each render.
|
||||
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
|
||||
|
||||
/// A dropdown menu trait for buttons and other interactive elements
|
||||
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
|
||||
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
trigger: T,
|
||||
#[allow(clippy::type_complexity)]
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
builder: Rc<MenuBuilder>,
|
||||
}
|
||||
|
||||
impl<T> DropdownMenuPopover<T>
|
||||
@@ -80,37 +82,64 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
|
||||
/// (right by default), keeping the child's own click handler intact.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ContextMenu {
|
||||
id: ElementId,
|
||||
anchor: Anchor,
|
||||
mouse_button: MouseButton,
|
||||
child: AnyElement,
|
||||
builder: Rc<MenuBuilder>,
|
||||
}
|
||||
|
||||
impl ContextMenu {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
child: impl IntoElement,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
anchor: Anchor::TopLeft,
|
||||
mouse_button: MouseButton::Right,
|
||||
child: child.into_any_element(),
|
||||
builder: Rc::new(builder),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
|
||||
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
|
||||
self.mouse_button = mouse_button;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DropdownMenuState {
|
||||
struct MenuState {
|
||||
menu: Option<Entity<PopupMenu>>,
|
||||
}
|
||||
|
||||
impl<T> RenderOnce for DropdownMenuPopover<T>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state =
|
||||
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
/// Builds the menu once and reuses it until it is dismissed.
|
||||
///
|
||||
/// The popover content closure runs on every render, so rebuilding the menu
|
||||
/// entity each time would drop its focus and selection state.
|
||||
fn cached_menu(
|
||||
menu_state: &Entity<MenuState>,
|
||||
builder: Rc<MenuBuilder>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<PopoverState>,
|
||||
) -> Entity<PopupMenu> {
|
||||
if let Some(menu) = menu_state.read(cx).menu.clone() {
|
||||
return menu;
|
||||
}
|
||||
|
||||
Popover::new(SharedString::from(format!("popover:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.trigger(self.trigger)
|
||||
.trigger_style(self.style)
|
||||
.anchor(self.anchor)
|
||||
.content(move |_, window, cx| {
|
||||
// Here is special logic to only create the PopupMenu once and reuse it.
|
||||
// Because this `content` will called in every time render, so we need to store the menu
|
||||
// in state to avoid recreating at every render.
|
||||
//
|
||||
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
|
||||
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
|
||||
let menu = match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let builder = builder.clone();
|
||||
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
@@ -119,15 +148,12 @@ where
|
||||
});
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
|
||||
// Listen for dismiss events from the PopupMenu to close the popover.
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
popover_state.update(cx, |state, cx| state.dismiss(window, cx));
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
@@ -135,11 +161,38 @@ where
|
||||
})
|
||||
.detach();
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
};
|
||||
menu
|
||||
}
|
||||
|
||||
menu.clone()
|
||||
})
|
||||
impl<T> RenderOnce for DropdownMenuPopover<T>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
|
||||
|
||||
Popover::new(SharedString::from(format!("popover:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.trigger(self.trigger)
|
||||
.trigger_style(self.style)
|
||||
.anchor(self.anchor)
|
||||
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ContextMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
|
||||
|
||||
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.anchor(self.anchor)
|
||||
.mouse_button(self.mouse_button)
|
||||
.trigger_with(move |_open, _window, _cx| self.child)
|
||||
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod dropdown_menu;
|
||||
mod menu_item;
|
||||
mod popup_menu;
|
||||
|
||||
pub use dropdown_menu::DropdownMenu;
|
||||
pub use dropdown_menu::{ContextMenu, DropdownMenu};
|
||||
pub use popup_menu::{PopupMenu, PopupMenuItem};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||
div,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{StyledExt, h_flex};
|
||||
|
||||
/// A single navigation entry in a sidebar.
|
||||
///
|
||||
/// It has an arbitrary leading element, such as an icon or avatar, and a text
|
||||
/// label. It can carry an optional trailing suffix, such as a status icon, and
|
||||
/// an optional click handler. Rows with a click handler are highlighted on
|
||||
/// hover and show a pointer cursor.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct NavItem {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: AnyElement,
|
||||
label: SharedString,
|
||||
/// Trailing element at the right edge of the row, after the ellipsized label.
|
||||
suffix: Option<AnyElement>,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
label: impl Into<SharedString>,
|
||||
icon: impl IntoElement,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
icon: icon.into_any_element(),
|
||||
label: label.into(),
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for NavItem {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NavItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let clickable = self.on_click.is_some();
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().text)
|
||||
.child(self.icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.child(self.label),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().flex_shrink_0().child(suffix))
|
||||
})
|
||||
.when(clickable, |this| {
|
||||
this.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
})
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,19 @@ impl Popover {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the trigger from a builder, for elements that have no selected state.
|
||||
///
|
||||
/// [`Self::trigger`] marks the trigger as selected while the popover is
|
||||
/// open, so it cannot be used with elements whose selection carries a
|
||||
/// different meaning, such as a row that indicates the current room.
|
||||
pub fn trigger_with<F>(mut self, trigger: F) -> Self
|
||||
where
|
||||
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
|
||||
{
|
||||
self.trigger = Some(Box::new(trigger));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the default open state of the popover, default is `false`.
|
||||
///
|
||||
/// This is only used to initialize the open state of the popover.
|
||||
|
||||
@@ -295,7 +295,11 @@ impl Screening {
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.child(Avatar::new(profile.avatar()).small())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
);
|
||||
}
|
||||
@@ -335,7 +339,11 @@ impl Render for Screening {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(profile.avatar()).large())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.large(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
|
||||
@@ -239,7 +239,11 @@ impl ContactListPanel {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar()).small())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -309,12 +309,7 @@ impl Render for ProfilePanel {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar_input = self.avatar_input.read(cx).value();
|
||||
|
||||
// Get the avatar
|
||||
let avatar = if avatar_input.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
avatar_input.as_str()
|
||||
};
|
||||
let picture = (!avatar_input.is_empty()).then_some(avatar_input);
|
||||
|
||||
// Get the public key as short string
|
||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||
@@ -331,7 +326,7 @@ impl Render for ProfilePanel {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(Avatar::new(avatar).large())
|
||||
.child(Avatar::new(picture).seed(self.public_key.to_hex()).large())
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::PlusCircle)
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
|
||||
use anyhow::Error;
|
||||
use chat::{ChatRegistry, Room, RoomKind};
|
||||
use common::DebouncedDelay;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div,
|
||||
uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::sidebar::RoomEntry;
|
||||
|
||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||
cx.new(|cx| SearchPanel::new(window, cx))
|
||||
@@ -13,15 +33,362 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||
pub struct SearchPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Find input state
|
||||
find_input: Entity<InputState>,
|
||||
|
||||
/// Debounced delay for find input
|
||||
find_debouncer: DebouncedDelay<Self>,
|
||||
|
||||
/// Whether a search is in progress
|
||||
finding: bool,
|
||||
|
||||
/// Find results
|
||||
find_results: Entity<Option<Vec<PublicKey>>>,
|
||||
|
||||
/// Async find operation
|
||||
find_task: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Selected public keys
|
||||
selected_pkeys: Entity<HashSet<PublicKey>>,
|
||||
|
||||
/// User's contacts
|
||||
contact_list: Entity<Option<Vec<PublicKey>>>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl SearchPanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let contact_list = cx.new(|_| None);
|
||||
let selected_pkeys = cx.new(|_| HashSet::new());
|
||||
let find_results = cx.new(|_| None);
|
||||
let find_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(INPUT_PLACEHOLDER)
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to find input events
|
||||
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
|
||||
let delay = Duration::from_millis(FIND_DELAY);
|
||||
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => {
|
||||
this.search(window, cx);
|
||||
}
|
||||
InputEvent::Change => {
|
||||
if state.read(cx).value().is_empty() {
|
||||
// Clear results when input is empty
|
||||
this.reset(window, cx);
|
||||
} else {
|
||||
// Run debounced search
|
||||
this.find_debouncer
|
||||
.fire_new(delay, window, cx, |this, window, cx| {
|
||||
this.debounced_search(window, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
InputEvent::Focus => {
|
||||
this.get_contact_list(window, cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Search".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
find_input,
|
||||
find_debouncer: DebouncedDelay::new(),
|
||||
find_results,
|
||||
find_task: None,
|
||||
finding: false,
|
||||
contact_list,
|
||||
selected_pkeys,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the contact list.
|
||||
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_contact_list(contacts, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the contact list with new contacts.
|
||||
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
self.contact_list.update(cx, |this, cx| {
|
||||
*this = Some(contacts.into_iter().collect());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Trigger the debounced search
|
||||
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.search(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
/// Search
|
||||
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Get query
|
||||
let query = self.find_input.read(cx).value();
|
||||
|
||||
// Return if the query is empty
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Block the input until the search completes
|
||||
self.set_finding(true, window, cx);
|
||||
|
||||
// Create the search task
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let find_users = nostr.read(cx).search(&query, cx);
|
||||
|
||||
// Run task in the main thread
|
||||
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
let rooms = find_users.await?;
|
||||
|
||||
// Update the UI with the search results
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_results(rooms, cx);
|
||||
this.set_finding(false, window, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the results of the search
|
||||
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.find_results.update(cx, |this, cx| {
|
||||
*this = Some(results);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the finding status
|
||||
fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Disable the input to prevent duplicate requests
|
||||
self.find_input.update(cx, |this, cx| {
|
||||
this.set_loading(status, window, cx);
|
||||
});
|
||||
// Set the search status
|
||||
self.finding = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Clear all search results
|
||||
self.find_results.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear all selected public keys
|
||||
self.selected_pkeys.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Reset the search status
|
||||
self.set_finding(false, window, cx);
|
||||
|
||||
// Cancel the current search task
|
||||
self.find_task = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Select a public key in the search panel.
|
||||
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
self.selected_pkeys.update(cx, |this, cx| {
|
||||
if this.contains(public_key) {
|
||||
this.remove(public_key);
|
||||
} else {
|
||||
this.insert(public_key.to_owned());
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if a public key is selected in the search panel.
|
||||
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
|
||||
self.selected_pkeys.read(cx).contains(public_key)
|
||||
}
|
||||
|
||||
/// Get all selected public keys in the search panel.
|
||||
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
|
||||
self.selected_pkeys.read(cx).clone()
|
||||
}
|
||||
|
||||
/// Create a new room
|
||||
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let async_chat = chat.downgrade();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get all selected public keys
|
||||
let receivers = self.get_selected(cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
// Create a new room and emit it
|
||||
async_chat.update_in(cx, |this, _window, cx| {
|
||||
let room = cx.new(|_| {
|
||||
Room::new(public_key, receivers)
|
||||
.organize(&public_key)
|
||||
.kind(RoomKind::Ongoing)
|
||||
});
|
||||
this.emit_room(&room, _window, cx);
|
||||
})?;
|
||||
|
||||
// Reset the find panel
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.reset(window, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Render the search results
|
||||
fn render_results(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<impl IntoElement + use<>> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Get the results
|
||||
let Some(results) = self.find_results.read(cx) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Map the results to a list of elements
|
||||
results
|
||||
.get(range.clone())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(ix, public_key)| {
|
||||
let selected = self.is_selected(public_key, cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let pkey_clone = public_key.to_owned();
|
||||
let handler = cx.listener(move |this, _ev, _window, cx| {
|
||||
this.select(&pkey_clone, cx);
|
||||
});
|
||||
|
||||
RoomEntry::new(range.start + ix)
|
||||
.name(profile.name())
|
||||
.avatar(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render the contact list
|
||||
fn render_contacts(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<impl IntoElement + use<>> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Get the contact list
|
||||
let Some(contacts) = self.contact_list.read(cx) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Map the contact list to a list of elements
|
||||
contacts
|
||||
.get(range.clone())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(ix, public_key)| {
|
||||
let selected = self.is_selected(public_key, cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let pkey_clone = public_key.to_owned();
|
||||
let handler = cx.listener(move |this, _ev, _window, cx| {
|
||||
this.select(&pkey_clone, cx);
|
||||
});
|
||||
|
||||
RoomEntry::new(range.start + ix)
|
||||
.name(profile.name().trim())
|
||||
.avatar(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for SearchPanel {
|
||||
@@ -52,11 +419,123 @@ impl Focusable for SearchPanel {
|
||||
|
||||
impl Render for SearchPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let logged_in = nostr.read(cx).current_user().is_some();
|
||||
let loading = chat.read(cx).loading() && logged_in;
|
||||
|
||||
// Set button label based on total selected users
|
||||
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
|
||||
"Create Group DM"
|
||||
} else {
|
||||
"Create DM"
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.gap_3()
|
||||
.p_2()
|
||||
.child(
|
||||
h_flex().child(
|
||||
Input::new(&self.find_input)
|
||||
.small()
|
||||
.text_xs()
|
||||
.disabled(loading)
|
||||
.when(
|
||||
!self.find_input.read(cx).presentation().is_loading(),
|
||||
|this| {
|
||||
this.suffix(
|
||||
Button::new("find-icon")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.transparent()
|
||||
.small(),
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.gap_3()
|
||||
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
.child(Icon::new(IconName::ChevronDown))
|
||||
.child("Results"),
|
||||
)
|
||||
.child(
|
||||
uniform_list(
|
||||
"rooms",
|
||||
results.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_results(range, cx)
|
||||
}),
|
||||
)
|
||||
.flex_1()
|
||||
.h_full(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Icon::new(IconName::ChevronDown).small())
|
||||
.child("Contacts"),
|
||||
)
|
||||
.child(
|
||||
uniform_list(
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
cx.processor(|this, range, _window, cx| {
|
||||
this.render_contacts(range, cx)
|
||||
}),
|
||||
)
|
||||
.flex_1()
|
||||
.h_full(),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_2()
|
||||
.left_0()
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_4()
|
||||
.child(
|
||||
Button::new("create")
|
||||
.label(button_label)
|
||||
.primary()
|
||||
.rounded()
|
||||
.shadow_md()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.create_room(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::rc::Rc;
|
||||
use chat::RoomKind;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
@@ -22,13 +22,13 @@ pub struct RoomEntry {
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
seed: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
depth: u8,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
trailing: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl RoomEntry {
|
||||
@@ -38,12 +38,12 @@ impl RoomEntry {
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
seed: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
depth: 0,
|
||||
handler: None,
|
||||
selected: false,
|
||||
trailing: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,13 @@ impl RoomEntry {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
pub fn avatar(mut self, picture: Option<SharedString>) -> Self {
|
||||
self.avatar = picture;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
|
||||
self.seed = Some(seed.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -77,11 +82,6 @@ impl RoomEntry {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trailing(mut self, trailing: impl IntoElement) -> Self {
|
||||
self.trailing = Some(trailing.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
@@ -109,21 +109,26 @@ impl RenderOnce for RoomEntry {
|
||||
|
||||
let public_key = self.public_key;
|
||||
let is_selected = self.is_selected();
|
||||
let avatar = match (self.avatar, self.seed) {
|
||||
(None, None) => None,
|
||||
(picture, seed) => Some(
|
||||
Avatar::new(picture)
|
||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||
.xsmall()
|
||||
.flex_shrink_0(),
|
||||
),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_8()
|
||||
.w_full()
|
||||
.pl(px(6. + self.depth as f32 * 14.))
|
||||
.pl(px(6. + self.depth as f32 * 10.))
|
||||
.pr_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.when_some(self.avatar, |this, avatar| {
|
||||
this.child(Avatar::new(avatar).small().flex_shrink_0())
|
||||
})
|
||||
})
|
||||
.when(!hide_avatar, |this| this.children(avatar))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
@@ -158,7 +163,6 @@ impl RenderOnce for RoomEntry {
|
||||
.when_some(self.created_at, |this, created_at| this.child(created_at)),
|
||||
),
|
||||
)
|
||||
.when_some(self.trailing, |this, trailing| this.child(trailing))
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.when_some(self.handler, |this, handler| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
|
||||
+334
-617
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,9 @@ use gpui::{
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::PixelAvatar;
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
|
||||
/// Collapsible tree sections; declaration order is render order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TreeSection {
|
||||
Pins,
|
||||
@@ -18,7 +18,43 @@ pub enum TreeSection {
|
||||
Messages,
|
||||
}
|
||||
|
||||
/// One rendered tree row, in flattened order.
|
||||
impl TreeSection {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pins => "Pinned",
|
||||
Self::Requests => "Requests",
|
||||
Self::Community => "Community",
|
||||
Self::Messages => "Messages",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> IconName {
|
||||
match self {
|
||||
Self::Pins | Self::Requests | Self::Community => IconName::Folder,
|
||||
Self::Messages => IconName::Message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pins => "pins",
|
||||
Self::Requests => "requests",
|
||||
Self::Community => "community",
|
||||
Self::Messages => "messages",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> Option<Self> {
|
||||
match key {
|
||||
"pins" => Some(Self::Pins),
|
||||
"requests" => Some(Self::Requests),
|
||||
"community" => Some(Self::Community),
|
||||
"messages" => Some(Self::Messages),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SidebarRow {
|
||||
Section {
|
||||
section: TreeSection,
|
||||
@@ -39,12 +75,10 @@ pub enum SidebarRow {
|
||||
},
|
||||
}
|
||||
|
||||
/// A community shown under the Community section.
|
||||
pub struct CommunityEntry {
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
/// Communities to show until the Concord backend is wired up.
|
||||
pub fn dummy_communities() -> &'static [CommunityEntry] {
|
||||
// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md.
|
||||
&[
|
||||
@@ -57,7 +91,6 @@ pub fn dummy_communities() -> &'static [CommunityEntry] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Presentation differences between the rows [`TreeRow`] draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TreeRowKind {
|
||||
Section,
|
||||
@@ -65,7 +98,6 @@ pub enum TreeRowKind {
|
||||
Hint,
|
||||
}
|
||||
|
||||
/// Folder/file row. One element for section headers, community rows and hints.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TreeRow {
|
||||
id: ElementId,
|
||||
@@ -77,7 +109,6 @@ pub struct TreeRow {
|
||||
label: SharedString,
|
||||
count: Option<usize>,
|
||||
dot: bool,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
@@ -98,7 +129,6 @@ impl TreeRow {
|
||||
label: label.into(),
|
||||
count: None,
|
||||
dot: false,
|
||||
selected: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
@@ -118,8 +148,9 @@ impl TreeRow {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(name.into());
|
||||
/// Sets the seed for the row's generated avatar.
|
||||
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(seed.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -133,11 +164,6 @@ impl TreeRow {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
@@ -150,11 +176,7 @@ impl TreeRow {
|
||||
impl RenderOnce for TreeRow {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let indent = px(6. + self.depth as f32 * 14.);
|
||||
let avatar_initial = self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|name| name.chars().next())
|
||||
.map(|letter| SharedString::from(letter.to_uppercase().to_string()));
|
||||
let avatar_seed = self.avatar;
|
||||
let is_section = self.kind == TreeRowKind::Section;
|
||||
let is_community = self.kind == TreeRowKind::Community;
|
||||
let is_hint = self.kind == TreeRowKind::Hint;
|
||||
@@ -168,9 +190,7 @@ impl RenderOnce for TreeRow {
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(is_section, |this| {
|
||||
this.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
this.text_xs().text_color(cx.theme().text_muted)
|
||||
})
|
||||
.when(is_community, |this| this.text_sm())
|
||||
.when(is_hint, |this| {
|
||||
@@ -178,39 +198,30 @@ impl RenderOnce for TreeRow {
|
||||
.font_normal()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
})
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().ghost_element_selected)
|
||||
})
|
||||
.when_some(self.caret, |this, caret| {
|
||||
this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted))
|
||||
})
|
||||
.when_some(self.icon, |this, icon| {
|
||||
this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted))
|
||||
})
|
||||
.when_some(avatar_initial, |this, initial| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_background)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text)
|
||||
.child(initial),
|
||||
)
|
||||
.when_some(avatar_seed, |this, seed| {
|
||||
this.child(PixelAvatar::new(seed).xsmall())
|
||||
})
|
||||
.child(div().flex_1().truncate().child(self.label))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.child(div().truncate().min_w_0().child(self.label))
|
||||
.when_some(self.count, |this, count| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(count.to_string()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(self.caret, |this, caret| {
|
||||
this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted))
|
||||
})
|
||||
.when(self.dot, |this| {
|
||||
this.child(
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# The community registry — implementation plan
|
||||
|
||||
`crates/community` is the GPUI layer over `crates/concord`: a global registry, one
|
||||
entity per community, and one notification stream that keeps both fed from the
|
||||
local database. `crates/concord` stays GPUI-free; this crate is the only place
|
||||
where entities, tasks and subscriptions meet.
|
||||
|
||||
The shape follows `crates/chat`: a registry global created in `init`, entities
|
||||
that own protocol state, a background notification listener, and a foreground
|
||||
consumer that is the only writer of entity state.
|
||||
|
||||
## Scope
|
||||
|
||||
In:
|
||||
|
||||
- discovery of the current account's communities from the local database;
|
||||
- one `Entity<Community>` owning `CommunityState`, the last `ControlFold`, the
|
||||
folded member list and the channel list;
|
||||
- subscriptions to every held plane (Control, Guestbook, public Channels);
|
||||
- notification-driven refresh: open and cache wraps in the background, fold them
|
||||
there too, and apply only the result on the foreground;
|
||||
- re-subscription when a fold moves a plane address or a relay list changes.
|
||||
|
||||
Out (see "Known gaps"): rekeys, private-channel keys, sends, moderation,
|
||||
invites, chat timelines, and NIP-46 writers.
|
||||
|
||||
## Crate layout
|
||||
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `community.rs` | The `Community` entity, `CommunityEvent`, refresh coalescing |
|
||||
| `lib.rs` | `init`, `CommunityRegistry`, the signal channel, subscription sync |
|
||||
| `sync.rs` | Planes, the REQ filter, loading from the database, the fold |
|
||||
|
||||
## Entities
|
||||
|
||||
`CommunityRegistry` mirrors `ChatRegistry`:
|
||||
|
||||
```rust
|
||||
pub struct CommunityRegistry {
|
||||
communities: Vec<Entity<Community>>,
|
||||
index: HashMap<CommunityId, Entity<Community>>,
|
||||
synced: HashMap<CommunityId, SubscriptionKey>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
signal_consumer: Option<Task<Result<(), Error>>>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
pub enum CommunityEvent {
|
||||
Updated(CommunityId),
|
||||
Error(String),
|
||||
}
|
||||
```
|
||||
|
||||
- `Updated` is emitted by a `Community` after a fold is applied; the registry
|
||||
emits only `Error`. Views subscribe where they render.
|
||||
- `Community` holds `state: CommunityState`, `control: ControlFold`,
|
||||
`members: BTreeSet<PublicKey>`, a `dirty` flag and one in-flight
|
||||
`refresh_task`. Public reads: `id()`, `state()`, `control()`, `members()`,
|
||||
`channels()`.
|
||||
- The registry is not an emitter of `Updated`: a view holds
|
||||
`Entity<Community>` and observes that.
|
||||
|
||||
## Loading
|
||||
|
||||
The registry loads when the signer changes and once at startup through
|
||||
`cx.defer_in`.
|
||||
|
||||
- State documents are discovered by scanning the local database for
|
||||
`Kind::ApplicationSpecificData` events whose `d` tag is `concord/<hex>`,
|
||||
newest per community. This is the bootstrap path: `cord02::genesis` +
|
||||
`store::save_state` (the creation flow in `concord-usage.md`) writes no list
|
||||
entry, so a list-only load would show nothing until joining exists.
|
||||
- The Community List (`kind 13302`, NIP-44 to self) is read from the database
|
||||
without `fetch_events`; decryption goes through `UniversalSigner::nip44_decrypt_async`,
|
||||
so NIP-46 signers work. When a list exists it is authoritative for liveness:
|
||||
a state document whose id is not live (no entry, or a newer tombstone) is
|
||||
dropped. With no list event, every state document loads.
|
||||
- `cord02::list::parse_list_event` takes `&Keys`, which the UI layer does not
|
||||
hold, so the list is decrypted with the signer and parsed as
|
||||
`CommunityList` directly. Nothing in the list is rewritten here.
|
||||
|
||||
Creation and join flows persist a `CommunityState` themselves and call
|
||||
`CommunityRegistry::reload`; the registry grows no writer APIs it cannot
|
||||
correctly support.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
A community's planes are derived from its state; the wrap's *author* is the
|
||||
routing key.
|
||||
|
||||
| Plane | Read key | Wrap author (`Filter::authors`) |
|
||||
| --- | --- | --- |
|
||||
| Control, each held epoch | `control_group_key(root, id, epoch)` | `state.control_pks[epoch]` (the signer pk) |
|
||||
| Guestbook | `guestbook_group_key(root, id, root_epoch)` | the group's own pk |
|
||||
| Channel (public only) | `channel_group_key(root, channel, channel.epoch)` | the group's own pk |
|
||||
|
||||
One caveat on the snippet in `concord-usage.md`: it uses
|
||||
`Filter::new().pubkey(plane.pk())`, but in this nostr-sdk `Filter::pubkey` adds a
|
||||
`#p` tag constraint, and a Concord wrap's `p` tag is a random ephemeral key
|
||||
(`cord01::wrap_seal_with`). The filter must be `.authors(...)`, matching the
|
||||
event author that `open_wrap_at` already checks.
|
||||
|
||||
- One subscription id per community: `SubscriptionId::new("concord/<hex>")`.
|
||||
Routing back from a notification is a prefix strip and a hex parse.
|
||||
- `Community` exposes a cheap `SubscriptionKey` (control pks, channels + epochs
|
||||
+ privacy, relays). When a fold changes it, the registry re-subscribes:
|
||||
`unsubscribe` then `subscribe` with the same id.
|
||||
- Community relays are added to the client explicitly
|
||||
(`client.add_relay(..).and_connect()`), per `concord-usage.md`.
|
||||
|
||||
## Notification stream
|
||||
|
||||
`client.notifications()` is one stream for the whole app; the community listener
|
||||
takes the first-seen variant and routes by subscription id, never by kind:
|
||||
|
||||
```rust
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Event { subscription_id, event, .. } = notification else {
|
||||
continue;
|
||||
};
|
||||
if event.kind != Kind::from(KIND_WRAP) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = sync::community_of(&subscription_id) else {
|
||||
continue;
|
||||
};
|
||||
tx.send_async(Signal::Event(id)).await?;
|
||||
}
|
||||
```
|
||||
|
||||
- `ClientNotification::Event` fires only the first time an event is seen; the
|
||||
relay has already saved it to the local database before notifying
|
||||
(`nostr-sdk` relay inner), so a signal only needs the community id and the
|
||||
fold reads the wrap back from the database. This is also what makes restart
|
||||
work: a backlog already in the database produces no notification, so
|
||||
`Community::refresh` runs once when the community is tracked.
|
||||
- `KIND_WRAP_EPHEMERAL` (21059, typing) is not subscribed: ephemeral events are
|
||||
never persisted, so the database-read path cannot see them. Nothing in the
|
||||
registry consumes typing today.
|
||||
- The channel is `flume::bounded(256)`; the consumer is a foreground `cx.spawn`
|
||||
that updates entities, as in `concord-usage.md`.
|
||||
|
||||
The chat registry must route gift wraps by subscription id before community
|
||||
subscriptions go live, or every stream wrap lands in the DM trash:
|
||||
|
||||
```rust
|
||||
RelayMessage::Event { subscription_id, event } => {
|
||||
if event.kind == Kind::GiftWrap
|
||||
&& subscription_id.as_ref() != sub_id1.as_str()
|
||||
&& subscription_id.as_ref() != sub_id2.as_str()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ..
|
||||
}
|
||||
```
|
||||
|
||||
The `InboxRelays` handling in the same loop stays unscoped: it arrives on a
|
||||
short-lived subscription with a generated id.
|
||||
|
||||
## The fold
|
||||
|
||||
One background function, `sync::fold(database, state) -> Snapshot`, does all
|
||||
crypto, verification, I/O and folding. `Snapshot` carries the applied
|
||||
`CommunityState`, the `ControlFold` and the member set; the foreground only
|
||||
assigns.
|
||||
|
||||
1. Derive the held planes.
|
||||
2. For each plane, query the database for `KIND_WRAP` events authored by the
|
||||
plane address and open them:
|
||||
- Control: `cord02::open_edition(wrap, read, address, true)` → `ParsedEdition`;
|
||||
- Guestbook: `cord02::guestbook::open(wrap, group)` → `GuestbookRumor`;
|
||||
- Channel: `cord03::open(wrap, group, channel, epoch)` then
|
||||
`store::cache_rumor` — the chat read path is already database-backed.
|
||||
Collect `observed: PublicKey -> ms` from every author that opened.
|
||||
3. `cord02::fold_control(owner, id, &editions, &state.floors(), &state.banned)`,
|
||||
then `state.apply_fold` and `store::save_state`, all in this task. If no
|
||||
edition opened at all, the fold is not applied: an empty fold would erase the
|
||||
committed floors the next fold is judged against.
|
||||
4. `cord02::guestbook::coalesce` with the roster-backed `can_kick`
|
||||
(`citation_ok` + `can_act_on_member(.., Permissions::KICK)`), then
|
||||
`complete_memberlist` with `observed`, the roster's grants, `control.banned`
|
||||
and an empty `banned_at`. The owner is inserted explicitly — the roster does
|
||||
not mint an implicit grant for them.
|
||||
|
||||
## Foreground and background
|
||||
|
||||
- Every entity touch and every fold application happens on the foreground.
|
||||
Background tasks only read the database and return values.
|
||||
- `Community::refresh` coalesces: if a fold is in flight it sets `dirty`, and
|
||||
the completion applies the snapshot, clears the task, then runs one more fold
|
||||
if dirtied. A burst of backlog events produces at most two folds.
|
||||
- `refresh_task: Option<Task<_>>` is dropped on reset, which cancels it.
|
||||
- The registry observes each community (`cx.observe`) and re-syncs
|
||||
subscriptions when a fold changed a plane or relay set; sync is a key
|
||||
comparison, so ordinary notifies are a no-op.
|
||||
- Errors from load/subscribe/fold reach the UI as `CommunityEvent::Error`; a
|
||||
task whose result is never read must not be the only error path.
|
||||
|
||||
## Integration
|
||||
|
||||
- `community::init(window, cx)` in `desktop/src/main.rs` and `web/src/lib.rs`
|
||||
after `chat::init`.
|
||||
- Chat's notification routing fix above.
|
||||
- `concord::store::{save_state, load_state}` gain `+ ?Sized` on `D`: the
|
||||
integration path passes `&dyn NostrDatabase` (the doc's advice cannot compile
|
||||
against the current bound). `cache_rumor`, `query_rumors`, `purge_expired`
|
||||
and `backfill` already take `&dyn`.
|
||||
|
||||
## Tests
|
||||
|
||||
Pure `#[test]` with `MemoryDatabase` and `smol::block_on`, like `concord`'s
|
||||
store tests; no GPUI test context (no registry test exists in this repo, and
|
||||
`state::init` owns the global client).
|
||||
|
||||
1. genesis folds into metadata, the general channel, and an owner-only member
|
||||
list, and the folded state is persisted.
|
||||
2. a member's join becomes a member and a later leave removes them.
|
||||
3. the subscription filter asks for every held plane by wrap author, and
|
||||
`community_of(subscription_id(id)) == Some(id)`.
|
||||
4. loading: state documents load without a list; a Community List entry keeps a
|
||||
community and a newer tombstone hides it.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Rekeys are not adopted.** `CommunityState` cannot hold a second root or a
|
||||
channel key, so the rekey planes (one epoch ahead) are not subscribed. A
|
||||
community stays on the plane set its state can derive.
|
||||
- **Private channels are skipped**, not guessed: no key is held for them yet.
|
||||
- The fold re-opens every wrap on every refresh. `ClientNotification::Event`
|
||||
plus refresh coalescing keep it bounded, and the database read path stays
|
||||
simple; incremental caches are a follow-up.
|
||||
- `list.is_live` filtering is only as fresh as the last list event; the
|
||||
registry never writes list or state documents for the user's account.
|
||||
|
||||
## Phases
|
||||
|
||||
Phases are sequential: each one lands compiling code and has an exit check. Nothing
|
||||
in a later phase is started before the earlier one is green, so the crate is never
|
||||
in a half-wired state.
|
||||
|
||||
### Phase 0 — Decisions
|
||||
|
||||
Five calls to confirm before writing code. Defaults in brackets.
|
||||
|
||||
1. **v1 planes** [Control + Guestbook + public Channels]. Rekeys and private
|
||||
channels are out of scope, not stubs.
|
||||
2. **Discovery and liveness** [scan the local DB for `concord/<hex>` state
|
||||
documents; read the Community List when present and use `is_live` to drop
|
||||
tombstones; never republish the list].
|
||||
3. **Wiring** [`community::init` after `chat::init` in `desktop` and `web`].
|
||||
4. **Chat routing fix** [route kind 1059 by subscription id in
|
||||
`chat::handle_notifications`; leave `InboxRelays` unscoped].
|
||||
5. **`concord::store` bound** [add `+ ?Sized` to `save_state`/`load_state` so
|
||||
`&dyn NostrDatabase` compiles; the doc's snippet does not compile today].
|
||||
6. **Tests** [pure `#[test]` + `smol::block_on` + `MemoryDatabase`; no GPUI test
|
||||
context].
|
||||
|
||||
Exit: all six confirmed. Any that change rewrite the affected phase below.
|
||||
|
||||
### Phase 1 — Crate skeleton
|
||||
|
||||
- Create `crates/community/Cargo.toml` and `src/{lib,community,sync}.rs` stubs.
|
||||
- Deps: `concord`, `state`, `gpui`, `nostr-sdk`, `anyhow`, `flume`, `log`,
|
||||
`serde_json`, `smallvec`. Dev: `nostr-memory`, `smol`.
|
||||
- The workspace already globs `crates/*`, so no root manifest edit.
|
||||
|
||||
Exit: `cargo check -p community` passes with the empty modules.
|
||||
|
||||
### Phase 2 — `sync.rs`
|
||||
|
||||
Pure, GPUI-free plumbing: `Plane`/`PlaneKind`, `planes(&CommunityState)`,
|
||||
`subscription_filter` (using `.authors(...)`, not `.pubkey(...)`), the
|
||||
`SubscriptionId`/`community_of` round-trip, `load`, and `fold -> Snapshot`.
|
||||
|
||||
Exit: unit tests 3 and 4 pass; no GPUI types in the file.
|
||||
|
||||
### Phase 3 — `community.rs`
|
||||
|
||||
`Community` entity (`state`, `control`, `members`, `dirty`, in-flight
|
||||
`refresh_task`), `CommunityEvent::{Updated, Error}`, and coalesced refresh that
|
||||
spawns the fold on `background_spawn` and applies the snapshot on the foreground.
|
||||
|
||||
Exit: tests 1 and 2 pass; entity compiles against a `TestAppContext`-free test.
|
||||
|
||||
### Phase 4 — `lib.rs`
|
||||
|
||||
`init` plus `CommunityRegistry`: bounded `flume(256)` signal channel, the
|
||||
notification listener task, the foreground consumer, `SubscriptionKey` re-sync on
|
||||
observe, and `reset`/`reload` on `StateEvent::SignerChanged`.
|
||||
|
||||
Exit: registry starts and stops cleanly under `cargo check`; listener routes by
|
||||
subscription id only.
|
||||
|
||||
### Phase 5 — Cross-crate fixes
|
||||
|
||||
- `concord::store::{save_state, load_state}` gain `+ ?Sized`.
|
||||
- Chat gift-wrap routing fix.
|
||||
|
||||
Exit: `cargo check -p concord -p chat` passes; no behaviour change for DM-only
|
||||
clients.
|
||||
|
||||
### Phase 6 — App wiring
|
||||
|
||||
Call `community::init(window, cx)` after `chat::init` in `desktop/src/main.rs`
|
||||
and `web/src/lib.rs`.
|
||||
|
||||
Exit: `cargo check -p coop` (or the app targets) passes.
|
||||
|
||||
### Phase 7 — Validation
|
||||
|
||||
Run the four tests plus `cargo check` and `cargo test` for `community`, then the
|
||||
workspace.
|
||||
|
||||
Exit: all green, or failing lines reported with root cause.
|
||||
|
||||
### Phase 8 — Doc finalization
|
||||
|
||||
Reconcile this document with what actually landed (scope, gaps, test names) and
|
||||
remove the draft's speculative sections that were cut.
|
||||
|
||||
Exit: the doc matches the code.
|
||||
@@ -1,417 +0,0 @@
|
||||
# Sidebar tree redesign
|
||||
|
||||
Status: steps 1-4 implemented (icons, tree primitives, `RoomEntry` extensions,
|
||||
panel shells); step 5 (search relocation + sidebar render rewrite) not started.
|
||||
|
||||
Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`),
|
||||
new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in
|
||||
`crates/workspace/src/lib.rs`. New icons in `crates/ui/src/icon.rs` and
|
||||
`assets/icons/`.
|
||||
|
||||
Related: `docs/concord-usage.md` — Community is placeholder data until a
|
||||
`ConcordRegistry` exists (that document describes the backend shape; none of it
|
||||
is wired up yet).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the segmented filter (Inbox / Requests) plus flat room list with a
|
||||
collapsible tree, and give the sidebar a nav rail whose items open dock panels.
|
||||
|
||||
The sidebar body is always the tree. Search is not part of it: the find input,
|
||||
results, and contacts move to a Search panel (relocation lands here, step 5;
|
||||
the panel is owned separately afterwards).
|
||||
|
||||
Target layout:
|
||||
|
||||
```text
|
||||
┌ sidebar ───────────────────────────────┐
|
||||
│ [avatar] user menu │ render_user (unchanged)
|
||||
│ │
|
||||
│ Inbox │ opens Inbox panel
|
||||
│ Browse │ opens Browse panel
|
||||
│ Search │ opens Search panel
|
||||
│ │
|
||||
│ ▾ Pinned (2) │ hidden when empty
|
||||
│ ○ alice │
|
||||
│ ○ team chat │
|
||||
│ ▸ Requests │ collapsed by default
|
||||
│ ▾ Community │
|
||||
│ ○ Coop Contributors │ 1-3 dummy entries
|
||||
│ ○ Nostr Design │
|
||||
│ ▾ Messages │
|
||||
│ ○ bob │ RoomKind::Ongoing
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. Current state
|
||||
|
||||
| Piece | Where |
|
||||
| --- | --- |
|
||||
| Sidebar view, search, filters, room list | `crates/workspace/src/sidebar/mod.rs` |
|
||||
| Room row element | `crates/workspace/src/sidebar/entry.rs` (`RoomEntry`) |
|
||||
| Room kinds and lookup | `crates/chat/src/lib.rs` (`ChatRegistry::rooms/count/room`), `crates/chat/src/room.rs` (`RoomKind::{Request, Ongoing}`) |
|
||||
| User row, dropdown menu | `Sidebar::render_user` (keep as is) |
|
||||
| Dock panels | `crates/workspace/src/panels/` (`greeter`, `profile`, `contact_list`, ...) |
|
||||
| Panel opening + commands | `crates/workspace/src/lib.rs` (`Command`, `Workspace::on_command`, `add_panel_to_dock`) |
|
||||
| Panel dedupe/focus | `crates/ui/src/dock/mod.rs` (`add_panel` finds by `panel_id` and moves/focuses) |
|
||||
| Buttons, icons, tooltips | `crates/ui/src/button.rs`, `crates/ui/src/icon.rs` |
|
||||
| Split button / dropdown primitives | `crates/ui/src/menu/` (`DropdownMenu`, `PopupMenu`, `PopupMenuItem`) |
|
||||
| App settings persistence | `crates/settings/src/lib.rs` (`Settings`, `setting_accessors!`) |
|
||||
|
||||
Behavior to preserve:
|
||||
|
||||
- `RoomEntry` click emits `ChatEvent::OpenRoom` through
|
||||
`ChatRegistry::emit_room`, and shows the screening modal for non-ongoing
|
||||
rooms (`entry.rs`).
|
||||
- `ChatEvent::Ping` sets `new_requests = true`, drawn as a dot on Requests.
|
||||
- The dock-facing `Panel`/`Focusable` impls on `Sidebar` stay untouched.
|
||||
- Search behavior is preserved by moving it, not rewriting it (§7).
|
||||
|
||||
## 3. Decisions and assumptions
|
||||
|
||||
| # | Question | Decision |
|
||||
| --- | --- | --- |
|
||||
| 1 | Nav items | `Inbox` / `Browse` / `Search` dispatch new commands (`Command::{ShowInbox, ShowBrowse, ShowSearch}`); `Workspace::on_command` opens each panel with `DockPlacement::Center`. `ui::dock::add_panel` already focuses an open panel by `panel_id` instead of duplicating it. |
|
||||
| 2 | Search in the sidebar | No find input, no results/contacts sections. The existing search/select implementation moves to `panels/search.rs` as a mechanical relocation (§7, step 5). |
|
||||
| 3 | Panels in this change | `Inbox` and `Browse` render empty bodies for now (tab title only); `Search` gets its body from the search relocation in step 5. Real Inbox/Browse content is follow-up work. |
|
||||
| 4 | Pin storage | UI-local `Vec<u64>` of room ids, in memory first; persistence is step 8 (optional). |
|
||||
| 5 | Pinned rooms in Messages | Kept in both places; `Pinned` is a shortcut, not a move. |
|
||||
| 6 | Row height | Uniform `h_8` (32px) for every tree row, including `RoomEntry` (currently `h_9`). Required by `uniform_list`, which measures only the first row. |
|
||||
| 7 | Community data | 1-3 hardcoded `CommunityEntry` values with a `TODO(concord)` pointing at `docs/concord-usage.md`. |
|
||||
| 8 | Requests default | Collapsed; the folder row still shows the unread dot. Expanding clears `new_requests`. |
|
||||
|
||||
## 4. State model
|
||||
|
||||
`Sidebar` keeps only what the tree needs.
|
||||
|
||||
```rust
|
||||
/// Collapsible tree sections; declaration order is render order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
enum TreeSection {
|
||||
Pins,
|
||||
Requests,
|
||||
Community,
|
||||
Messages,
|
||||
}
|
||||
```
|
||||
|
||||
New fields:
|
||||
|
||||
```rust
|
||||
expanded: BTreeSet<TreeSection>,
|
||||
pinned_rooms: Vec<u64>, // room ids in pin order
|
||||
```
|
||||
|
||||
Defaults: `expanded = {Community, Messages}` (Requests intentionally absent;
|
||||
Pins only matters when non-empty and starts expanded).
|
||||
|
||||
New methods:
|
||||
|
||||
```rust
|
||||
fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>);
|
||||
fn is_expanded(&self, section: TreeSection) -> bool;
|
||||
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
|
||||
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
|
||||
fn is_pinned(&self, room_id: u64) -> bool;
|
||||
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow>; // see §5
|
||||
```
|
||||
|
||||
`toggle_section(Requests)` clears `new_requests`.
|
||||
|
||||
Removed from `Sidebar` (all search-related, carried to the Search panel in
|
||||
step 5): `filter: Entity<RoomKind>`, `current_filter`, `set_filter`,
|
||||
`show_find_panel`, `find_input`, `find_debouncer`, `finding`, `find_focused`,
|
||||
`find_results`, `find_task`, `has_search`, `contact_list`, `selected_pkeys`,
|
||||
and methods `get_contact_list`, `set_contact_list`, `debounced_search`,
|
||||
`search`, `set_results`, `set_finding`, `set_input_focus`, `reset`, `select`,
|
||||
`is_selected`, `get_selected`, `create_room`, `render_results`,
|
||||
`render_contacts`. Nothing is deleted from the codebase: step 5 moves it into
|
||||
the Search panel.
|
||||
|
||||
## 5. Row model and flattening
|
||||
|
||||
The tree body is one `uniform_list`, so all rows must be the same height
|
||||
(`h_8`) and the flattened order must be precomputed per frame.
|
||||
|
||||
New file `crates/workspace/src/sidebar/tree.rs`:
|
||||
|
||||
```rust
|
||||
/// One rendered tree row, in flattened order.
|
||||
enum SidebarRow {
|
||||
Section { section: TreeSection, count: usize },
|
||||
Room { room: Entity<Room>, depth: u8, pinned: bool },
|
||||
Community { entry: &'static CommunityEntry, depth: u8 },
|
||||
Hint { text: SharedString, depth: u8 },
|
||||
}
|
||||
|
||||
struct CommunityEntry {
|
||||
name: &'static str,
|
||||
// Rendered as a 20px circle with the first letter; no backend yet.
|
||||
}
|
||||
|
||||
fn dummy_communities() -> &'static [CommunityEntry]; // TODO(concord)
|
||||
|
||||
/// Folder/file row. One element for section headers, community rows and hints.
|
||||
#[derive(IntoElement)]
|
||||
struct TreeRow { /* id, depth, caret, icon, avatar, label, count, dot, selected, on_click */ }
|
||||
```
|
||||
|
||||
`Sidebar::tree_rows`:
|
||||
|
||||
```rust
|
||||
// Pins (only when a pinned id resolves to a live room), in pin order
|
||||
// Requests -> chat.rooms(&RoomKind::Request, cx)
|
||||
// Community -> dummy_communities()
|
||||
// Messages -> chat.rooms(&RoomKind::Ongoing, cx)
|
||||
//
|
||||
// Each section emits Section first, then children when expanded,
|
||||
// then a Hint row when expanded and empty.
|
||||
```
|
||||
|
||||
Render integration:
|
||||
|
||||
```rust
|
||||
let rows = Rc::new(self.tree_rows(cx));
|
||||
|
||||
uniform_list("sidebar-tree", rows.len(), cx.processor(move |this, range, _window, cx| {
|
||||
this.render_rows(range, &rows, cx)
|
||||
}))
|
||||
.track_scroll(&self.scroll_handle)
|
||||
```
|
||||
|
||||
`render_rows` matches on `SidebarRow` and builds either a `TreeRow` (sections,
|
||||
communities, hints) or a `RoomEntry` (rooms). Element ids: room rows use the
|
||||
flattened index (`RoomEntry::new(ix)`); non-room rows use
|
||||
`ElementId::NamedInteger("tree-row".into(), ix as u64)`.
|
||||
|
||||
Why a flattened list instead of `gpui_base::Tree` / `VirtualList`: sections are
|
||||
few and fixed, rows are heterogeneous, and the crate already uses
|
||||
`uniform_list` + `Scrollbar::vertical`. The base `Tree`/`VirtualList`
|
||||
primitives remain available if variable row heights are ever needed.
|
||||
|
||||
## 6. Rendering spec
|
||||
|
||||
### 6.1 Nav rail
|
||||
|
||||
Three full-width `Button`s, `ghost_alt`, `small`, dispatching actions:
|
||||
|
||||
```rust
|
||||
Button::new("nav-inbox")
|
||||
.icon(IconName::Inbox)
|
||||
.label("Inbox")
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.on_click(|_ev, _window, cx| {
|
||||
cx.dispatch_action(&Command::ShowInbox);
|
||||
})
|
||||
```
|
||||
|
||||
Icons: `Inbox`, `Compass` (new, Browse), `Search`. `Command` is already
|
||||
imported in `sidebar/mod.rs`, and dispatching actions from a button listener is
|
||||
the existing `greeter.rs` pattern.
|
||||
|
||||
Nav rows carry no `selected` state: the sidebar does not know which panel the
|
||||
dock is showing. Highlighting the active destination is a follow-up if the dock
|
||||
API exposes it.
|
||||
|
||||
### 6.2 Section (folder) row
|
||||
|
||||
- `h_8`, `pl`/`pr` matching the list padding, `rounded(theme.radius)`, full
|
||||
width, hover `ghost_element_hover`.
|
||||
- Caret: `CaretDown` when expanded, `CaretRight` when collapsed.
|
||||
- Icon 16px (`small()`), `text_muted`.
|
||||
- Label: `text_xs`, `font_semibold`, `text_muted`; `flex_1`.
|
||||
- Trailing: count (`text_xs`, `text_placeholder`) for Requests/Pins, and the
|
||||
unread dot (`size_1().rounded_full().bg(theme.cursor)`) when
|
||||
`new_requests && section == Requests`.
|
||||
- Click toggles the section.
|
||||
|
||||
### 6.3 File rows
|
||||
|
||||
- Rooms reuse `RoomEntry` with two additions: `.depth(u8)` (left padding
|
||||
`px(6. + depth * 14.)`) and an optional `.trailing(AnyElement)` slot for the
|
||||
hover ellipsis; height becomes `h_8`.
|
||||
- Community rows use `TreeRow` with a 20px `element_background` circle and the
|
||||
first letter, `text_sm` label.
|
||||
- Indent guide (optional polish): 1px `border_variant` vertical line at the
|
||||
child indent, drawn by the child row.
|
||||
|
||||
### 6.4 Fixed chrome
|
||||
|
||||
`render_user` unchanged. The loading pill stays positioned as today. The
|
||||
"Create DM" floating button and the screening flow move with search to the
|
||||
Search panel. Only the tree body scrolls.
|
||||
|
||||
## 7. Search leaves the sidebar (hidden, not removed)
|
||||
|
||||
Search is now a panel, not a sidebar mode:
|
||||
|
||||
- `Command::ShowSearch` opens `panels/search.rs` in the dock center.
|
||||
- The current implementation moves there intact — same input, debounce
|
||||
(`DebouncedDelay` + `FIND_DELAY`), `NostrRegistry::search`, contact list,
|
||||
multi-select, create-DM flow, and the `RoomEntry` selection/screening
|
||||
behavior. The move is mechanical; no search logic is rewritten or dropped.
|
||||
- The sidebar renders no input and no results/contacts sections, and keeps no
|
||||
copy of the state (a dormant copy would be dead code).
|
||||
- The Search panel is a separate workstream from the tree: it owns the module
|
||||
after the relocation and evolves independently.
|
||||
|
||||
## 8. Pin folder
|
||||
|
||||
- Pin state: `pinned_rooms: Vec<u64>` in `Sidebar`, order = pin order.
|
||||
- UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`)
|
||||
on each room row, opening a `DropdownMenu` with `Pin` / `Unpin`
|
||||
(`PopupMenuItem::new(...).on_click(...)`). Verify the trigger click does not
|
||||
also fire the row's `emit_room` click; if it does, `cx.stop_propagation()`
|
||||
in the menu trigger's `on_click`. (There is no right-click menu pattern in
|
||||
the codebase yet; a context menu is a follow-up.)
|
||||
- `Pinned` folder is hidden when no pinned room resolves to a live room;
|
||||
otherwise expanded by default, showing pinned rooms in pin order.
|
||||
- A pinned room remains listed under `Messages`.
|
||||
|
||||
## 9. Requests
|
||||
|
||||
- Folder always rendered, collapsed by default (`expanded` does not contain
|
||||
`Requests`).
|
||||
- Count badge = `chat.count(&RoomKind::Request, cx)`.
|
||||
- Expanding the folder clears `new_requests`.
|
||||
- Children are the same `RoomKind::Request` rooms the old Requests filter
|
||||
showed, with the same `RoomEntry` screening behavior.
|
||||
|
||||
## 10. Community (dummy data)
|
||||
|
||||
- `dummy_communities()` returns 2 entries for now (`Coop Contributors`,
|
||||
`Nostr Design`) so the folder has content; 1-3 is the range the sketch asks
|
||||
for.
|
||||
- Rows do not navigate anywhere yet; clicking is a no-op. Add
|
||||
`// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md`.
|
||||
- Folder expanded by default.
|
||||
|
||||
## 11. Messages
|
||||
|
||||
- `RoomKind::Ongoing` rooms, using the existing `render_list_items` logic
|
||||
(display name/avatar/member pubkey/kind/created_at, `emit_room` on click).
|
||||
- Expanded by default.
|
||||
- When empty and expanded, show a `Hint` row ("No conversations yet") instead
|
||||
of the current large dashed card; the card is removed.
|
||||
|
||||
## 12. Implementation steps
|
||||
|
||||
Steps 1-4 are additive and compile on their own. Step 5 is one atomic change
|
||||
set: the search relocation and the sidebar render rewrite depend on each other,
|
||||
because removing the search fields breaks the old render and rewriting the
|
||||
render orphans the search code. Helpers added in earlier steps may warn as
|
||||
unused until step 5 consumes them. Run the checks in §15 after each step.
|
||||
|
||||
- [x] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`,
|
||||
`message.svg` (24x24 viewBox, `stroke="currentColor"`, `stroke-width="1.5"`,
|
||||
matching existing files); add `Folder`, `Compass`, `Message` variants to
|
||||
`IconName` and its `path()` match in `crates/ui/src/icon.rs`.
|
||||
- [x] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs`
|
||||
with `TreeSection`, `SidebarRow`, `CommunityEntry`, `dummy_communities()`,
|
||||
and the `TreeRow` element; declare `mod tree;` in `sidebar/mod.rs`.
|
||||
- [x] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`;
|
||||
change `h_9` to `h_8`.
|
||||
- [x] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse,
|
||||
ShowSearch}` and `panels/{inbox,browse,search}.rs` shells (`init`, `Panel`,
|
||||
`Focusable`, `EventEmitter<PanelEvent>`, empty `Render`, following
|
||||
`greeter.rs`); register them in `panels/mod.rs`; handle the commands in
|
||||
`Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`.
|
||||
All three render empty bodies for now; the Search body is filled in step 5.
|
||||
- [ ] **Step 5 — relocation + render rewrite (atomic, separate workstream
|
||||
handoff).** Move the search/select implementation out of `Sidebar` into
|
||||
`panels/search.rs` (inventory in §7), wiring the input, results, contacts,
|
||||
selection, and create-DM button exactly as they are today; at the same time
|
||||
rewrite the sidebar render (nav rail dispatching the three commands, flattened
|
||||
tree list, scrollbar, `render_user`, loading pill), add
|
||||
`expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`,
|
||||
`set_filter`, and the sidebar's search state. The search workstream owns the
|
||||
relocated module afterwards.
|
||||
- [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire
|
||||
`pin_room`/`unpin_room`.
|
||||
- [ ] **Step 7 — community section.** Render dummy entries and hint; add the
|
||||
`TODO(concord)` marker.
|
||||
- [ ] **Step 8 (optional) — persistence.** Add
|
||||
`#[serde(default)] pinned_rooms: Vec<u64>` (and optionally
|
||||
`expanded_sections: Vec<String>`) to `settings::Settings`, register accessors
|
||||
in `setting_accessors!`, and load/save from `Sidebar`. The `#[serde(default)]`
|
||||
attribute is required: `Settings` has no defaults today, so a new field
|
||||
without it breaks parsing of existing `.settings` files.
|
||||
- [ ] **Step 9 — cleanup.** `cargo fmt`, remove dead imports/helpers, run
|
||||
clippy.
|
||||
|
||||
## 13. Files touched
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `crates/workspace/src/sidebar/mod.rs` | State, flattening, render rewrite; search code moves out |
|
||||
| `crates/workspace/src/sidebar/tree.rs` | New: sections, rows, `TreeRow`, dummy data |
|
||||
| `crates/workspace/src/sidebar/entry.rs` | `depth`, `trailing`, height |
|
||||
| `crates/workspace/src/panels/{inbox,browse,search}.rs` | New panel modules |
|
||||
| `crates/workspace/src/panels/mod.rs` | Module registration |
|
||||
| `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms |
|
||||
| `crates/ui/src/icon.rs` | New icon variants |
|
||||
| `assets/icons/{folder,compass,message}.svg` | New assets |
|
||||
| `crates/settings/src/lib.rs` | Optional step 8 only |
|
||||
|
||||
## 14. Edge cases
|
||||
|
||||
- **Uniform height.** `uniform_list` measures the first row and reuses that
|
||||
height; every row must be `h_8`. If a section row ever needs a different
|
||||
height, switch to `gpui_base::VirtualList` instead of mixing.
|
||||
- **Panel dedupe.** Clicking a nav item whose panel is already open focuses and
|
||||
moves it (`ui::dock::add_panel` looks up `panel_id`); no duplicate tabs.
|
||||
- **Stale pins.** A pinned id whose room is gone is skipped at flatten time
|
||||
(and pruned on the next pin/unpin).
|
||||
- **Empty sections.** Expanded + empty renders a `Hint` row; collapsed sections
|
||||
render nothing.
|
||||
- **Logged out.** `NostrRegistry::current_user()` is `None`: `render_user`
|
||||
keeps its import-identity prompt; sections resolve to empty and show hints.
|
||||
- **Loading.** Sections may be empty; the loading pill stays.
|
||||
- **New requests while collapsed.** The dot shows on the collapsed Requests
|
||||
folder; expanding clears it.
|
||||
- **Image cache.** Keep `retain_all("sidebar")` on the root.
|
||||
- **Element ids.** Flattened index for room rows, `NamedInteger` for others, so
|
||||
expansion/collapse does not smuggle state between rows.
|
||||
|
||||
## 15. Validation
|
||||
|
||||
- `cargo fmt --check` (workspace `rustfmt.toml`).
|
||||
- `cargo check -p workspace` and `cargo clippy -p workspace --all-targets`.
|
||||
- Manual QA checklist:
|
||||
- Inbox/Browse/Search each open their panel; clicking the same nav item again
|
||||
focuses the existing panel instead of duplicating it;
|
||||
- the sidebar has no search input and no results/contacts sections;
|
||||
- the Search panel keeps the old behavior (debounced search, contacts,
|
||||
multi-select, create DM, `Enter` to search);
|
||||
- each folder toggles and keeps its state across re-renders and room updates;
|
||||
- Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears
|
||||
when expanded;
|
||||
- pin/unpin from the row menu updates the Pinned folder without opening the
|
||||
room; clicking a pinned row opens it;
|
||||
- Messages lists ongoing rooms and still opens the screening modal for
|
||||
non-ongoing rooms;
|
||||
- empty states at 0 ongoing and 0 requests.
|
||||
- There is no GPUI test infrastructure in the repo (no `#[gpui::test]`
|
||||
anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin
|
||||
ordering) if they are extracted as free functions; `cargo check` plus the
|
||||
manual checklist is the baseline.
|
||||
|
||||
## 16. Open questions
|
||||
|
||||
1. **Persistence.** Persist pins and folder state, or keep them session-local?
|
||||
2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for
|
||||
now, one indent level fits.
|
||||
3. **Community entries.** Preferred dummy names/branding before the real
|
||||
registry lands.
|
||||
|
||||
## 17. Out of scope / follow-ups
|
||||
|
||||
- Inbox/Browse panel content (empty bodies in this change).
|
||||
- Search panel development beyond the relocation; any search UI changes happen
|
||||
in that workstream.
|
||||
- Real Concord integration (`ConcordRegistry`, subscriptions, member lists) —
|
||||
tracked in `docs/concord-usage.md`.
|
||||
- Drag-to-reorder pins, pin folders/groups beyond the single `Pinned` folder.
|
||||
- Unread counts per room, nav-item active highlighting.
|
||||
- Variable-height rows or nested subfolders.
|
||||
Reference in New Issue
Block a user