update community
This commit is contained in:
@@ -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(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -404,6 +404,7 @@ impl Sidebar {
|
||||
)
|
||||
.depth(*depth)
|
||||
.avatar(community.id().to_hex())
|
||||
.picture(community.icon())
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::NewCommunity { depth } => TreeRow::new(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user