update community

This commit is contained in:
2026-09-19 10:33:11 +07:00
parent 97e7539cea
commit cbb97e471f
8 changed files with 190 additions and 21 deletions
+54 -4
View File
@@ -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<PublicKey>,
icon: Option<PathBuf>,
icon_ref: Option<ImageRef>,
dirty: bool,
refresh_task: Option<Task<Result<()>>>,
icon_task: Option<Task<Result<()>>>,
}
impl EventEmitter<CommunityEvent> 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<PathBuf> {
self.icon.clone()
}
pub fn members(&self) -> &BTreeSet<PublicKey> {
&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<Self>) {
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(())
}));
}
}
-1
View File
@@ -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 {
+25 -5
View File
@@ -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<Vec<Plane>> {
});
}
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<CommunityId> {
.ok()
}
/// Download and decrypt a community icon into a content-addressed cache file.
pub async fn resolve_icon(icon: &ImageRef, cx: &AsyncApp) -> Result<PathBuf> {
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],
+16
View File
@@ -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<String>,
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<String> {
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],
+57
View File
@@ -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<PathBuf, Error> {
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<PathBuf, Error> {
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)
+15 -6
View File
@@ -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<SharedString>,
picture: Option<ImageSource>,
grayscale: bool,
seed: Option<SharedString>,
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<SharedString>) -> Self {
Self::from_picture(picture.map(ImageSource::from))
}
/// Creates an avatar from an already-resolved source.
pub fn from_source(picture: impl Into<ImageSource>) -> Self {
Self::from_picture(Some(picture.into()))
}
fn from_picture(picture: Option<ImageSource>) -> Self {
Avatar {
base: div(),
picture,
+1
View File
@@ -404,6 +404,7 @@ impl Sidebar {
)
.depth(*depth)
.avatar(community.id().to_hex())
.picture(community.icon())
.into_any_element()
}
SidebarRow::NewCommunity { depth } => TreeRow::new(
+22 -5
View File
@@ -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<IconName>,
icon: Option<IconName>,
avatar: Option<SharedString>,
picture: Option<PathBuf>,
label: SharedString,
count: Option<usize>,
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<PathBuf>) -> 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()