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