From cbb97e471f1bec6d50781b999d2cc8b03e081fd5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 10:33:11 +0700 Subject: [PATCH] update community --- crates/community/src/community.rs | 58 ++++++++++++++++++++++++++-- crates/community/src/lib.rs | 1 - crates/community/src/sync.rs | 30 +++++++++++--- crates/concord/src/store.rs | 16 ++++++++ crates/state/src/file.rs | 57 +++++++++++++++++++++++++++ crates/ui/src/avatar.rs | 21 +++++++--- crates/workspace/src/sidebar/mod.rs | 1 + crates/workspace/src/sidebar/tree.rs | 27 ++++++++++--- 8 files changed, 190 insertions(+), 21 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 4750f83b..2fee63ac 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -1,7 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; use anyhow::Result; -use concord::cord02::ControlFold; +use concord::cord02::{ControlFold, ImageRef}; use concord::store::{ChannelKeyRef, CommunityState}; use concord::{ChannelId, CommunityId, Epoch}; use gpui::{AppContext, Context, EventEmitter, Task}; @@ -45,8 +46,11 @@ pub struct Community { state: CommunityState, control: ControlFold, members: BTreeSet, + icon: Option, + icon_ref: Option, dirty: bool, refresh_task: Option>>, + icon_task: Option>>, } impl EventEmitter for Community {} @@ -57,8 +61,11 @@ impl Community { state, control: ControlFold::default(), members: BTreeSet::new(), + icon: None, + icon_ref: None, dirty: false, refresh_task: None, + icon_task: None, } } @@ -71,16 +78,25 @@ impl Community { } pub fn name(&self) -> String { - match &self.control.community { - Some(metadata) => metadata.name.clone(), - None => self.state.id.to_hex(), + if let Some(metadata) = &self.control.community { + return metadata.name.clone(); } + + self.state + .name + .clone() + .unwrap_or_else(|| self.state.id.to_hex()) } pub fn control(&self) -> &ControlFold { &self.control } + /// The community's icon, once downloaded and decrypted into a cache file. + pub fn icon(&self) -> Option { + self.icon.clone() + } + pub fn members(&self) -> &BTreeSet { &self.members } @@ -121,6 +137,7 @@ impl Community { self.state = snapshot.state; self.control = snapshot.control; self.members = snapshot.members; + self.load_icon(cx); cx.emit(CommunityEvent::Updated(self.state.id)); cx.notify(); } @@ -133,4 +150,37 @@ impl Community { self.refresh(cx); } } + + /// Resolve the folded icon into a local file. + fn load_icon(&mut self, cx: &mut Context) { + let icon = self + .control + .community + .as_ref() + .and_then(|metadata| metadata.icon.clone()); + + if self.icon_ref == icon { + return; + } + + self.icon_ref = icon.clone(); + self.icon = None; + + let Some(icon) = icon else { + return; + }; + + self.icon_task = Some(cx.spawn(async move |this, cx| { + match sync::resolve_icon(&icon, cx).await { + Ok(path) => { + this.update(cx, |this, cx| { + this.icon = Some(path); + cx.notify(); + })?; + } + Err(error) => log::warn!("community icon: {error}"), + } + Ok(()) + })); + } } diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 5ce3400c..679796e3 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -357,7 +357,6 @@ async fn subscribe( relays: &[RelayUrl], filter: Filter, ) -> Result<()> { - log::info!("community {id}: subscribing to {relays:?}"); client.unsubscribe(id).await?; for url in relays { diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 4a883185..e7de9aa1 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -1,9 +1,10 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; -use anyhow::Result; +use anyhow::{Context, Result}; use concord::cord01::KIND_WRAP; use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST}; -use concord::cord02::{self, ControlFold}; +use concord::cord02::{self, ControlFold, ImageRef}; use concord::cord04::AuthorityCitation; use concord::cord04::roles::{Permissions, citation_ok}; use concord::derive::{ @@ -11,6 +12,7 @@ use concord::derive::{ }; use concord::store::{self, CommunityState}; use concord::{ChannelId, CommunityId, Epoch, GroupKey}; +use gpui::AsyncApp; use nostr_sdk::prelude::*; use state::UniversalSigner; @@ -42,6 +44,15 @@ pub fn planes(state: &CommunityState) -> Result> { }); } + if state.control_pks.is_empty() { + let group = control_group_key(&state.community_root, &state.id, state.root_epoch)?; + planes.push(Plane { + kind: PlaneKind::Control(state.root_epoch), + address: group.pk(), + group, + }); + } + let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?; planes.push(Plane { kind: PlaneKind::Guestbook, @@ -85,6 +96,12 @@ pub fn community_of(subscription_id: &SubscriptionId) -> Option { .ok() } +/// Download and decrypt a community icon into a content-addressed cache file. +pub async fn resolve_icon(icon: &ImageRef, cx: &AsyncApp) -> Result { + let url = Url::parse(&icon.url).context("community icon url")?; + state::download_and_decrypt_to_cache(&url, &icon.key, &icon.nonce, &icon.hash, cx).await +} + #[derive(Debug, Clone)] pub struct Snapshot { pub state: CommunityState, @@ -317,6 +334,10 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { held.control_root = fresh.control_root; } + if let Some(name) = fresh.name { + held.name = Some(name); + } + for (epoch, address) in fresh.control_pks { held.control_pks.insert(epoch, address); } @@ -517,6 +538,7 @@ mod tests { let state = CommunityState { id: CommunityId::from_bytes([0x42; 32]), + name: Some("Anime and Manga".to_owned()), owner, owner_salt: [0x01; 32], community_root: [0x02; 32], @@ -548,9 +570,6 @@ mod tests { let planes = planes(&state).expect("planes"); - // Control at the root epoch, the guestbook, and the public channel. The - // private channel is skipped: its address derives from the granted key, - // not the community_root. assert_eq!(planes.len(), 3); assert!(planes.iter().any(|plane| plane.address == control_pk)); assert!( @@ -580,6 +599,7 @@ mod tests { fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState { CommunityState { id, + name: Some("Anime and Manga".to_owned()), owner: Keys::generate().public_key(), owner_salt: [0x01; 32], community_root: [0x02; 32], diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 1b92ddae..252fcb40 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -154,6 +154,8 @@ pub struct ChannelKeyRef { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CommunityState { pub id: CommunityId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub owner: PublicKey, pub owner_salt: [u8; 32], pub community_root: [u8; 32], @@ -183,6 +185,7 @@ impl CommunityState { let mut channels = Vec::new(); let mut heads = Vec::with_capacity(editions.len()); let mut relays = Vec::new(); + let mut name = None; for edition in editions { heads.push(EntityHead { @@ -201,6 +204,7 @@ impl CommunityState { .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()), ); + name = label(&metadata.name); } vsk::CHANNEL_METADATA => { let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?; @@ -228,6 +232,7 @@ impl CommunityState { Ok(Self { id: genesis.identity.community_id, + name, owner: genesis.identity.owner, owner_salt: genesis.identity.owner_salt, community_root: genesis.community_root, @@ -267,6 +272,7 @@ impl CommunityState { Ok(Self { id: material.community_id, + name: label(&material.name), owner: material.owner, owner_salt: decode_hex_32(&material.owner_salt)?, community_root: decode_hex_32(&material.community_root)?, @@ -310,6 +316,10 @@ impl CommunityState { .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()) .collect(); + + if let Some(name) = label(&community.name) { + self.name = Some(name); + } } for (id, metadata) in &fold.channels { @@ -373,6 +383,11 @@ pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry { } } +fn label(name: &str) -> Option { + let trimmed = name.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + fn state_identifier(id: &CommunityId) -> String { format!("{STATE_PREFIX}{}", id.to_hex()) } @@ -710,6 +725,7 @@ mod tests { let state = CommunityState { id: CommunityId::from_bytes([0x42; 32]), + name: Some("Anime and Manga".to_owned()), owner: Keys::generate().public_key(), owner_salt: [0x01; 32], community_root: [0x02; 32], diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs index 924d8621..53c808dc 100644 --- a/crates/state/src/file.rs +++ b/crates/state/src/file.rs @@ -318,6 +318,63 @@ pub async fn download_and_decrypt_to_file( Err(anyhow!("File download not supported on web")) } +/// The cache file a decrypted blob for `plaintext_sha256` is written to. +#[cfg(not(target_arch = "wasm32"))] +fn blob_cache_path(plaintext_sha256: &str) -> PathBuf { + std::env::temp_dir() + .join("coop-blobs") + .join(plaintext_sha256) +} + +/// Download an encrypted blob whose pointer carries the *plaintext* hash +/// and write the decrypted bytes to a content-addressed cache file, +/// so later renders skip the network. +/// +/// The cache file carries no extension: `img` sniffs the format from the bytes. +#[cfg(not(target_arch = "wasm32"))] +pub async fn download_and_decrypt_to_cache( + url: &Url, + key: &str, + nonce: &str, + plaintext_sha256: &str, + cx: &AsyncApp, +) -> Result { + let path = blob_cache_path(plaintext_sha256); + + if smol::fs::metadata(&path).await.is_ok() { + return Ok(path); + } + + let data = download_and_decrypt(url, key, nonce, None, cx).await?; + + if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) { + bail!("Blob hash mismatch"); + } + + let Some(parent) = path.parent() else { + bail!("Invalid blob cache path"); + }; + smol::fs::create_dir_all(parent).await?; + + // Write under a temporary name first, so an interrupted download is never reused + let partial = path.with_extension("download"); + smol::fs::write(&partial, data).await?; + smol::fs::rename(&partial, &path).await?; + + Ok(path) +} + +#[cfg(target_arch = "wasm32")] +pub async fn download_and_decrypt_to_cache( + _url: &Url, + _key: &str, + _nonce: &str, + _plaintext_sha256: &str, + _cx: &AsyncApp, +) -> Result { + Err(anyhow!("Blob download not supported on web")) +} + fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { tags.iter() .find(|tag| tag.kind() == name) diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 2276e085..2af564ff 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -1,8 +1,8 @@ use gpui::prelude::FluentBuilder; use gpui::{ - AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity, - IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString, - StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, + AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement, + Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, + SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, }; use theme::ActiveTheme; @@ -373,7 +373,7 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { #[derive(IntoElement)] pub struct Avatar { base: Div, - picture: Option, + picture: Option, grayscale: bool, seed: Option, style: StyleRefinement, @@ -385,9 +385,18 @@ pub struct Avatar { impl Avatar { /// Creates an avatar for an entity whose profile picture may be missing. /// - /// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when - /// `picture` is `None`. + /// Use [`Avatar::seed`] to choose the generated + /// pixel avatar rendered when `picture` is `None`. pub fn new(picture: Option) -> Self { + Self::from_picture(picture.map(ImageSource::from)) + } + + /// Creates an avatar from an already-resolved source. + pub fn from_source(picture: impl Into) -> Self { + Self::from_picture(Some(picture.into())) + } + + fn from_picture(picture: Option) -> Self { Avatar { base: div(), picture, diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 37ad3216..549f2f1a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -404,6 +404,7 @@ impl Sidebar { ) .depth(*depth) .avatar(community.id().to_hex()) + .picture(community.icon()) .into_any_element() } SidebarRow::NewCommunity { depth } => TreeRow::new( diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 6a891265..4c259157 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::rc::Rc; use chat::Room; @@ -8,7 +9,7 @@ use gpui::{ SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use theme::ActiveTheme; -use ui::avatar::PixelAvatar; +use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -94,6 +95,7 @@ pub struct TreeRow { caret: Option, icon: Option, avatar: Option, + picture: Option, label: SharedString, count: Option, dot: bool, @@ -114,6 +116,7 @@ impl TreeRow { caret: None, icon: None, avatar: None, + picture: None, label: label.into(), count: None, dot: false, @@ -142,6 +145,12 @@ impl TreeRow { self } + /// Shows `picture` instead of the generated avatar. + pub fn picture(mut self, picture: Option) -> Self { + self.picture = picture; + self + } + pub fn count(mut self, count: usize) -> Self { self.count = Some(count); self @@ -164,11 +173,21 @@ impl TreeRow { impl RenderOnce for TreeRow { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let indent = px(6. + self.depth as f32 * 14.); - let avatar_seed = self.avatar; let is_section = self.kind == TreeRowKind::Section; let is_community = self.kind == TreeRowKind::Community; let is_hint = self.kind == TreeRowKind::Hint; + let avatar = match (self.avatar, self.picture) { + (seed, Some(picture)) => Some( + Avatar::from_source(picture) + .when_some(seed, |avatar, seed| avatar.seed(seed)) + .xsmall() + .into_any_element(), + ), + (Some(seed), None) => Some(PixelAvatar::new(seed).xsmall().into_any_element()), + (None, None) => None, + }; + h_flex() .id(self.id) .h_8() @@ -189,9 +208,7 @@ impl RenderOnce for TreeRow { .when_some(self.icon, |this, icon| { this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) }) - .when_some(avatar_seed, |this, seed| { - this.child(PixelAvatar::new(seed).xsmall()) - }) + .when_some(avatar, |this, avatar| this.child(avatar)) .child( h_flex() .gap_1()