7 Commits
Author SHA1 Message Date
reya 70140b2454 update community backend 2026-09-18 19:27:30 +07:00
reya 2def7548ef add community crate 2026-09-18 18:49:21 +07:00
reya 1320a2c361 refactor avatar 2026-09-18 17:03:17 +07:00
reya 88005fbc41 update sidebar 2026-09-18 15:53:40 +07:00
reya 41bd0ce345 . 2026-09-18 15:43:48 +07:00
reya 492e50746f wip 2026-09-18 15:35:42 +07:00
reya e75b1b9f10 wip 2026-09-18 15:17:42 +07:00
26 changed files with 2664 additions and 1197 deletions
Generated
+17
View File
@@ -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
View File
@@ -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()
}
}
+5 -3
View File
@@ -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()
})
+22
View File
@@ -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
+229
View File
@@ -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()]));
});
}
}
+9
View File
@@ -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) {}
+421
View File
@@ -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());
});
}
}
+2 -2
View File
@@ -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)
+5 -1
View File
@@ -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()),
),
),
+8 -4
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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}");
}
}
+1
View File
@@ -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;
+100 -47
View File
@@ -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,19 +82,95 @@ 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>>,
}
/// 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;
}
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
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));
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu
}
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());
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
@@ -100,46 +178,21 @@ where
.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)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), 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);
});
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
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());
menu.clone()
}
};
menu.clone()
})
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))
}
}
+1 -1
View File
@@ -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) {
+100
View File
@@ -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))
})
}
}
+13
View File
@@ -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.
+10 -2
View File
@@ -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()
+5 -1
View File
@@ -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(
+2 -7
View File
@@ -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)
+487 -8
View File
@@ -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()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
.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(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);
})),
),
)
})
}
}
+22 -18
View File
@@ -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| {
File diff suppressed because it is too large Load Diff
+63 -52
View File
@@ -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))
.when_some(self.count, |this, count| {
this.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(count.to_string()),
)
.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(
+328
View File
@@ -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.
-417
View File
@@ -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.