update community sidebar

This commit is contained in:
2026-09-22 08:14:56 +07:00
parent 29f3c74d06
commit 7e7d13cbfc
9 changed files with 691 additions and 584 deletions
+81 -9
View File
@@ -45,6 +45,8 @@ impl SubscriptionKey {
pub enum CommunityEvent {
Updated(CommunityId),
Open(CommunityId),
Close(CommunityId),
Channel(CommunityId, ChannelId),
Error(String),
}
@@ -52,11 +54,16 @@ pub struct Community {
state: CommunityState,
control: ControlFold,
members: BTreeSet<PublicKey>,
/// The channel the sidebar and panel show, once the user has picked one
active: Option<ChannelId>,
icon: Option<PathBuf>,
icon_ref: Option<ImageRef>,
banner: Option<PathBuf>,
banner_ref: Option<ImageRef>,
dirty: bool,
refresh_task: Option<Task<Result<()>>>,
icon_task: Option<Task<Result<()>>>,
banner_task: Option<Task<Result<()>>>,
}
impl EventEmitter<CommunityEvent> for Community {}
@@ -67,11 +74,15 @@ impl Community {
state,
control: ControlFold::default(),
members: BTreeSet::new(),
active: None,
icon: None,
icon_ref: None,
banner: None,
banner_ref: None,
dirty: false,
refresh_task: None,
icon_task: None,
banner_task: None,
}
}
@@ -103,6 +114,27 @@ impl Community {
self.icon.clone()
}
/// The community's banner, once downloaded and decrypted into a cache file.
pub fn banner(&self) -> Option<PathBuf> {
self.banner.clone()
}
/// The channel the sidebar and panel show, defaulting to the first one.
pub fn active_channel(&self) -> Option<ChannelId> {
self.active
.or_else(|| self.state.channels.first().map(|channel| channel.id))
}
/// Mark `channel` as the one the sidebar and panel show.
pub fn set_active_channel(&mut self, channel: ChannelId, cx: &mut Context<Self>) {
if self.active == Some(channel) {
return;
}
self.active = Some(channel);
cx.emit(CommunityEvent::Channel(self.state.id, channel));
}
pub fn members(&self) -> &BTreeSet<PublicKey> {
&self.members
}
@@ -241,6 +273,16 @@ impl Community {
}))
}
/// Adopt plane material the account's list now carries.
///
/// The caller re-folds afterwards; this only seeds the new planes.
pub(crate) fn adopt(&mut self, state: CommunityState) {
// A fold already in flight would write its pre-adoption state back.
self.refresh_task = None;
self.dirty = false;
self.state = state;
}
/// Rebuilds the community from the wraps in the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh_task.is_some() {
@@ -269,7 +311,7 @@ impl Community {
self.state = snapshot.state;
self.control = snapshot.control;
self.members = snapshot.members;
self.load_icon(cx);
self.load_images(cx);
cx.emit(CommunityEvent::Updated(self.state.id));
cx.notify();
}
@@ -283,14 +325,18 @@ impl Community {
}
}
/// 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());
/// Resolve the folded icon and banner into local files.
fn load_images(&mut self, cx: &mut Context<Self>) {
let (icon, banner) = match self.control.community.as_ref() {
Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()),
None => (None, None),
};
self.load_icon(icon, cx);
self.load_banner(banner, cx);
}
fn load_icon(&mut self, icon: Option<ImageRef>, cx: &mut Context<Self>) {
if self.icon_ref == icon {
return;
}
@@ -303,7 +349,7 @@ impl Community {
};
self.icon_task = Some(cx.spawn(async move |this, cx| {
match sync::resolve_icon(&icon, cx).await {
match sync::resolve_image(&icon, cx).await {
Ok(path) => {
this.update(cx, |this, cx| {
this.icon = Some(path);
@@ -315,4 +361,30 @@ impl Community {
Ok(())
}));
}
fn load_banner(&mut self, banner: Option<ImageRef>, cx: &mut Context<Self>) {
if self.banner_ref == banner {
return;
}
self.banner_ref = banner.clone();
self.banner = None;
let Some(banner) = banner else {
return;
};
self.banner_task = Some(cx.spawn(async move |this, cx| {
match sync::resolve_image(&banner, cx).await {
Ok(path) => {
this.update(cx, |this, cx| {
this.banner = Some(path);
cx.notify();
})?;
}
Err(error) => log::warn!("community banner: {error}"),
}
Ok(())
}));
}
}
+51 -14
View File
@@ -39,7 +39,7 @@ pub struct CommunityRegistry {
/// The plane set each community was last subscribed with
synced: HashMap<CommunityId, SubscriptionKey>,
/// One observer per tracked community, dropped on reset
observers: Vec<Subscription>,
observers: HashMap<CommunityId, Subscription>,
signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>,
tasks: SmallVec<[Task<Result<()>>; 2]>,
@@ -90,7 +90,7 @@ impl CommunityRegistry {
communities: Vec::new(),
index: HashMap::new(),
synced: HashMap::new(),
observers: Vec::new(),
observers: HashMap::new(),
signal_tx: tx,
signal_rx: rx,
tasks: smallvec![],
@@ -122,6 +122,13 @@ impl CommunityRegistry {
});
}
/// Ask the workspace to close a community's panel.
pub fn emit_close(&mut self, id: CommunityId, window: &mut Window, cx: &mut Context<Self>) {
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(CommunityEvent::Close(id));
});
}
/// Create a community owned by the current account and begin tracking it.
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -228,26 +235,56 @@ impl CommunityRegistry {
}
/// Replace the tracked communities with a freshly loaded set.
///
/// A community that survives the reload keeps its entity, so an open panel
/// and a browsing sidebar stay pointed at a live community.
fn track(&mut self, states: Vec<CommunityState>, cx: &mut Context<Self>) {
self.observers.clear();
self.communities.clear();
self.index.clear();
self.synced.clear();
let mut communities = Vec::with_capacity(states.len());
for state in states {
let id = state.id;
let community = cx.new(|_| Community::new(state));
self.observers
.push(cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx);
cx.notify();
}));
let community = match self.index.remove(&id) {
Some(community) => {
// The list can carry plane material the store does not.
if community.read(cx).state() != &state {
community.update(cx, |community, _cx| community.adopt(state));
}
self.index.insert(id, community.clone());
self.communities.push(community);
community
}
None => {
let community = cx.new(|_| Community::new(state));
self.observers.insert(
id,
cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx);
cx.notify();
}),
);
community
}
};
communities.push((id, community));
}
// Whatever the index still holds is no longer in the list.
let dropped: Vec<CommunityId> = self.index.keys().copied().collect();
for id in dropped {
self.observers.remove(&id);
self.synced.remove(&id);
}
self.communities = communities
.iter()
.map(|(_, community)| community.clone())
.collect();
self.index = communities.into_iter().collect();
self.sync_subscriptions(cx);
// A backlog already in the database produces no notification, so fold it once.
+3 -3
View File
@@ -92,9 +92,9 @@ pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
}
/// 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
pub async fn resolve_image(image: &ImageRef, cx: &AsyncApp) -> Result<PathBuf> {
let url = Url::parse(&image.url).context("community image url")?;
state::download_and_decrypt_to_cache(&url, &image.key, &image.nonce, &image.hash, cx).await
}
#[derive(Debug, Clone)]