From 7e7d13cbfc1f00b5bedf30a3901d1f70eb29dd03 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 22 Sep 2026 08:14:56 +0700 Subject: [PATCH] update community sidebar --- crates/community/src/community.rs | 90 ++++- crates/community/src/lib.rs | 65 +++- crates/community/src/sync.rs | 6 +- crates/community_ui/src/lib.rs | 247 ++++---------- crates/community_ui/src/message.rs | 6 +- crates/workspace/src/lib.rs | 53 ++- crates/workspace/src/sidebar/mod.rs | 467 ++++++++++++++++++++------ docs/community-messages-panel-plan.md | 243 -------------- docs/community-sidebar-plan.md | 98 ++++++ 9 files changed, 691 insertions(+), 584 deletions(-) delete mode 100644 docs/community-messages-panel-plan.md create mode 100644 docs/community-sidebar-plan.md diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 33939f6f..545ccbf8 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -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, + /// The channel the sidebar and panel show, once the user has picked one + active: Option, icon: Option, icon_ref: Option, + banner: Option, + banner_ref: Option, dirty: bool, refresh_task: Option>>, icon_task: Option>>, + banner_task: Option>>, } impl EventEmitter 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 { + self.banner.clone() + } + + /// The channel the sidebar and panel show, defaulting to the first one. + pub fn active_channel(&self) -> Option { + 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) { + if self.active == Some(channel) { + return; + } + + self.active = Some(channel); + cx.emit(CommunityEvent::Channel(self.state.id, channel)); + } + pub fn members(&self) -> &BTreeSet { &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) { 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) { - 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) { + 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, cx: &mut Context) { 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, cx: &mut Context) { + 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(()) + })); + } } diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index bf38aa23..8f2d9785 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -39,7 +39,7 @@ pub struct CommunityRegistry { /// The plane set each community was last subscribed with synced: HashMap, /// One observer per tracked community, dropped on reset - observers: Vec, + observers: HashMap, signal_tx: flume::Sender, signal_rx: flume::Receiver, tasks: SmallVec<[Task>; 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) { + 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) { 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, cx: &mut Context) { - 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 = 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. diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index ca3bb5ca..6e4aa042 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -92,9 +92,9 @@ pub fn community_of(subscription_id: &SubscriptionId) -> Option { } /// 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 +pub async fn resolve_image(image: &ImageRef, cx: &AsyncApp) -> Result { + 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)] diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index ca140066..04b9e355 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -3,12 +3,9 @@ use community::{ChannelId, ChatMessage, Community, CommunityEvent}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, list, px, + IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString, Styled, + Subscription, Task, WeakEntity, Window, div, list, px, }; -use nostr_sdk::prelude::*; -use person::PersonRegistry; -use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use theme::ActiveTheme; use ui::avatar::Avatar; @@ -16,8 +13,8 @@ use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::input::{InputEvent, Textarea, TextareaState}; use ui::notification::Notification; -use ui::scroll::{ScrollableElement, Scrollbar}; -use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::scroll::Scrollbar; +use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex}; mod message; @@ -64,7 +61,7 @@ impl CommunityPanel { ( SharedString::from(format!("community-{}", community.id().to_hex())), community.name(), - community.channels().first().map(|channel| channel.id), + community.active_channel(), ) }; @@ -93,11 +90,15 @@ impl CommunityPanel { CommunityEvent::Updated(_) => { cx.defer_in(window, |this, window, cx| this.reload(window, cx)); } + // The sidebar picked another channel while the community was updating. + CommunityEvent::Channel(..) => { + cx.defer_in(window, |this, window, cx| this.load(window, cx)); + } CommunityEvent::Error(error) => { window .push_notification(Notification::error(error.clone()).autohide(false), cx); } - CommunityEvent::Open(_) => {} + CommunityEvent::Open(_) | CommunityEvent::Close(_) => {} }, )); @@ -118,9 +119,26 @@ impl CommunityPanel { panel } + /// The channel to show, following the community's selection. + fn resolve_channel(&mut self, cx: &App) -> Option { + let channel = self + .community + .read_with(cx, |community, _cx| community.active_channel()) + .ok() + .flatten(); + + if channel != self.channel { + self.channel = channel; + self.messages.clear(); + self.list_state.reset(0); + } + + channel + } + /// Page the selected channel's history into the cache, then read it back. fn load(&mut self, window: &mut Window, cx: &mut Context) { - let Some(channel) = self.channel else { + let Some(channel) = self.resolve_channel(cx) else { return; }; @@ -144,7 +162,7 @@ impl CommunityPanel { /// Replace the timeline with the selected channel's folded messages. fn reload(&mut self, window: &mut Window, cx: &mut Context) { - let Some(channel) = self.channel else { + let Some(channel) = self.resolve_channel(cx) else { return; }; @@ -179,19 +197,6 @@ impl CommunityPanel { })); } - fn select_channel(&mut self, channel: ChannelId, window: &mut Window, cx: &mut Context) { - if self.channel == Some(channel) { - return; - } - - self.channel = Some(channel); - self.messages.clear(); - self.list_state.reset(0); - cx.notify(); - - self.load(window, cx); - } - fn send(&mut self, window: &mut Window, cx: &mut Context) { let content = self.input.read(cx).value().trim().to_owned(); @@ -200,7 +205,7 @@ impl CommunityPanel { return; } - let Some(channel) = self.channel else { + let Some(channel) = self.resolve_channel(cx) else { return; }; @@ -238,88 +243,6 @@ impl CommunityPanel { })); } - fn render_channel( - &self, - id: ChannelId, - name: &str, - private: bool, - cx: &mut Context, - ) -> AnyElement { - let selected = self.channel == Some(id); - - h_flex() - .id(SharedString::from(format!( - "community-channel-{}", - id.to_hex() - ))) - .w_full() - .h_8() - .flex_shrink_0() - .gap_2() - .px_2() - .rounded(cx.theme().radius) - .cursor_pointer() - .when(selected, |this| this.bg(cx.theme().ghost_element_selected)) - .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .child( - Icon::new(if private { - IconName::Lock - } else { - IconName::Message - }) - .small() - .text_color(cx.theme().icon_muted), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_ellipsis() - .child(SharedString::from(name.to_owned())), - ) - .on_click(cx.listener(move |this, _event, window, cx| { - this.select_channel(id, window, cx); - })) - .into_any_element() - } - - fn render_timeline(&mut self, cx: &mut Context) -> impl IntoElement { - v_flex() - .flex_1() - .min_w_0() - .h_full() - .child( - v_flex() - .flex_1() - .min_h_0() - .relative() - .map(|this| { - if self.messages.is_empty() { - this.child( - h_flex() - .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_placeholder) - .child("No messages yet"), - ) - } else { - this.child( - list( - self.list_state.clone(), - cx.processor(move |this, ix, window, cx| { - this.render_message(ix, window, cx) - }), - ) - .size_full(), - ) - } - }) - .child(Scrollbar::vertical(&self.list_state)), - ) - .child(self.render_composer(cx)) - } - fn render_message( &mut self, ix: usize, @@ -329,7 +252,6 @@ impl CommunityPanel { let Some(message) = self.messages.get(ix) else { return div().into_any_element(); }; - message::render(ix, message, cx) } @@ -340,8 +262,6 @@ impl CommunityPanel { .p_2() .gap_1() .items_end() - .border_t_1() - .border_color(cx.theme().border) .child(Textarea::new(&self.input).appearance(false).flex_1()) .child( Button::new("send") @@ -394,89 +314,38 @@ impl Focusable for CommunityPanel { impl Render for CommunityPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(community) = self.community.upgrade() else { - return div().size_full(); - }; - - let (channels, members) = { - let community = community.read(cx); - - ( - community - .channels() - .iter() - .map(|channel| (channel.id, channel.name.clone(), channel.private)) - .collect::>(), - community.members().iter().copied().collect::>(), - ) - }; - v_flex() .size_full() - .flex_row() + .min_w_0() .child( v_flex() - .id("community-sidebar") - .w(px(220.)) - .h_full() - .flex_shrink_0() - .gap_1() - .p_2() - .border_r_1() - .border_color(cx.theme().border) - .overflow_y_scrollbar() - .child(section_label("Channels", cx)) - .children( - channels.iter().map(|(id, name, private)| { - self.render_channel(*id, name, *private, cx) - }), - ) - .child(section_label("Members", cx)) - .children( - members - .iter() - .map(|public_key| render_member(*public_key, cx)), - ), + .flex_1() + .min_h_0() + .relative() + .map(|this| { + if self.messages.is_empty() { + this.child( + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child("No messages yet"), + ) + } else { + this.child( + list( + self.list_state.clone(), + cx.processor(move |this, ix, window, cx| { + this.render_message(ix, window, cx) + }), + ) + .size_full(), + ) + } + }) + .child(Scrollbar::vertical(&self.list_state)), ) - .child(self.render_timeline(cx)) + .child(self.render_composer(cx)) } } - -fn section_label(label: &str, cx: &App) -> impl IntoElement { - div() - .px_2() - .pt_2() - .pb_1() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from(label.to_owned())) -} - -fn render_member(public_key: PublicKey, cx: &App) -> AnyElement { - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(&public_key, cx); - let hide_avatar = AppSettings::get_hide_avatar(cx); - - h_flex() - .w_full() - .h_8() - .flex_shrink_0() - .gap_2() - .px_2() - .when(!hide_avatar, |this| { - this.child( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .xsmall(), - ) - }) - .child( - div() - .flex_1() - .min_w_0() - .text_ellipsis() - .child(person.name()), - ) - .into_any_element() -} diff --git a/crates/community_ui/src/message.rs b/crates/community_ui/src/message.rs index 45882de6..19adbdc8 100644 --- a/crates/community_ui/src/message.rs +++ b/crates/community_ui/src/message.rs @@ -70,15 +70,13 @@ pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement { fn content(message: &ChatMessage, cx: &App) -> AnyElement { if message.deleted { return div() - .text_sm() - .text_color(cx.theme().text_placeholder) + .text_color(cx.theme().text_danger) .child("Message deleted") .into_any_element(); } div() - .text_sm() - .child(SharedString::from(message.content.clone())) + .child(SharedString::from(&message.content)) .into_any_element() } diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 2b283300..3af3c2cb 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -7,11 +7,12 @@ use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry}; use common::download_dir; use community::{CommunityEvent, CommunityRegistry}; +use community_ui::CommunityPanel; use device::{DeviceEvent, DeviceRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, + ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, px, }; use nostr_sdk::prelude::*; use person::{PersonRegistry, shorten_pubkey}; @@ -71,6 +72,8 @@ enum Command { pub struct Workspace { dock: Entity, title_bar_chrome: Rc, + /// The community panel currently docked, if any + community_panel: Option>, /// Async tasks tasks: Vec>>, /// Event subscriptions @@ -219,17 +222,40 @@ impl Workspace { cx.subscribe_in( &communities, window, - move |this, communities, event, window, cx| { - if let CommunityEvent::Open(id) = event - && let Some(community) = communities.read(cx).community(id) - { - this.add_panel_to_dock( - community_ui::init(community, window, cx), - DockPlacement::Center, - window, - cx, - ); + move |this, communities, event, window, cx| match event { + CommunityEvent::Open(id) => { + if let Some(community) = communities.read(cx).community(id) { + let panel = community_ui::init(community, window, cx); + + this.community_panel = Some(panel.downgrade()); + this.add_panel_to_dock(panel, DockPlacement::Center, window, cx); + } } + CommunityEvent::Close(_) => { + let Some(panel) = this + .community_panel + .take() + .and_then(|panel| panel.upgrade()) + else { + return; + }; + + this.dock.update(cx, |area, cx| { + ui::dock::add_panel( + area, + PanelHandle::new(panel), + DockPlacement::Center, + window, + cx, + ); + ui::dock::focus_tab_panel(area, window, cx); + + cx.defer_in(window, |_, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + }); + }); + } + _ => {} }, ), ); @@ -255,13 +281,13 @@ impl Workspace { Self { dock, title_bar_chrome, + community_panel: None, tasks: vec![], _subscriptions: subscriptions, } } - /// Add a panel to the dock, from anywhere that has the window but not the - /// workspace. + /// Add a panel to the dock, from anywhere that has the window but not the workspace. pub fn add_panel( panel: Entity

, placement: DockPlacement, @@ -732,6 +758,7 @@ impl Render for Workspace { let modal_layer = Root::render_modal_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); + // Render the title bar chrome self.title_bar_chrome.set_trailing(Self::titlebar_right); div() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 137d6193..6d6422e5 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -4,13 +4,15 @@ use std::rc::Rc; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, RoomKind}; use common::TimestampExt; -use community::{Community, CommunityEvent, CommunityRegistry}; +use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - UniformListScrollHandle, Window, div, px, retain_all, uniform_list, + InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, ScrollHandle, SharedString, + StatefulInteractiveElement, Styled, StyledImage, Subscription, UniformListScrollHandle, + WeakEntity, Window, div, img, px, retain_all, uniform_list, }; +use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; @@ -42,10 +44,18 @@ pub(crate) use tree::{TreeRow, TreeRowKind}; pub struct Sidebar { focus_handle: FocusHandle, scroll_handles: [UniformListScrollHandle; 3], + /// Scroll state of the channel and member lists + community_scroll: ScrollHandle, active_tab: SidebarTab, + /// The community the sidebar is browsing, if any + community: Option>, + /// Expanded state of the community's sections + channels_open: bool, + admins_open: bool, + members_open: bool, /// Whether there are new chat requests new_requests: bool, - _subscriptions: SmallVec<[Subscription; 3]>, + _subscriptions: SmallVec<[Subscription; 4]>, } impl Sidebar { @@ -78,6 +88,8 @@ impl Sidebar { subscriptions.push(cx.observe(&nostr, |_this, _nostr, cx| cx.notify())); + subscriptions.push(cx.observe(&communities, |_this, _communities, cx| cx.notify())); + Self { focus_handle: cx.focus_handle(), scroll_handles: [ @@ -85,7 +97,12 @@ impl Sidebar { UniformListScrollHandle::new(), UniformListScrollHandle::new(), ], + community_scroll: ScrollHandle::default(), active_tab: SidebarTab::Recents, + community: None, + channels_open: true, + admins_open: true, + members_open: true, new_requests: false, _subscriptions: subscriptions, } @@ -117,110 +134,129 @@ impl Sidebar { registry.emit_community(&community, window, cx); }); + self.community = Some(community.downgrade()); cx.notify(); } - fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn close_community(&mut self, window: &mut Window, cx: &mut Context) { + let Some(community) = self.community.take() else { + return; + }; + + if let Ok(id) = community.read_with(cx, |community, _cx| community.id()) { + CommunityRegistry::global(cx).update(cx, |registry, cx| { + registry.emit_close(id, window, cx); + }); + } + + cx.notify(); + } + + fn render_user(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let nostr = NostrRegistry::global(cx); let current_user = nostr.read(cx).current_user(); - title_bar_drag_handlers( - h_flex() - .id("sidebar-user") - .w_full() - .h(TABBAR_HEIGHT) - .flex_shrink_0() - .items_center() - .gap_2() - .px_2() - .when(cfg!(target_os = "macos"), |this| { - this.pl(px(TRAFFIC_LIGHT_PADDING)) - }) - .when_some(current_user.as_ref(), |this, public_key| { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(public_key, cx); - let avatar = profile.avatar(); - let avatar_seed = profile.avatar_seed(); - let name = profile.name(); + let mut row = h_flex() + .id("sidebar-user") + .w_full() + .h(TABBAR_HEIGHT) + .flex_shrink_0() + .items_center() + .gap_2() + .px_2() + .when(cfg!(target_os = "macos"), |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }); - this.child( - Button::new("current-user") - .child( - Avatar::new(avatar.clone()) - .seed(avatar_seed.clone()) - .xsmall(), - ) - .small() - .caret() - .compact() - .transparent() - .dropdown_menu(move |this, _window, cx| { - let avatar = avatar.clone(); - let avatar_seed = avatar_seed.clone(); - let name = name.clone(); + if let Some(public_key) = current_user.as_ref() { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(public_key, cx); + let avatar = profile.avatar(); + let avatar_seed = profile.avatar_seed(); + let name = profile.name(); - this.min_w(px(256.)) - .item(PopupMenuItem::element(move |_window, cx| { - h_flex() - .gap_1p5() - .text_xs() - .text_color(cx.theme().text_muted) - .child( - Avatar::new(avatar.clone()) - .seed(avatar_seed.clone()) - .xsmall(), - ) - .child(name.clone()) - })) - .separator() - .menu_with_icon( - "Inbox", - IconName::Inbox, - Box::new(Command::ShowInbox), - ) - .menu_with_icon( - "Search", - IconName::Search, - Box::new(Command::ShowSearch), - ) - .menu_with_icon( - "Profile", - IconName::Profile, - Box::new(Command::ShowProfile), - ) - .menu_with_icon( - "Contact List", - IconName::Book, - Box::new(Command::ShowContactList), - ) - .menu_with_icon( - "Backup", - IconName::UserKey, - Box::new(Command::ShowBackup), - ) - .menu_with_icon( - "Themes", - IconName::Sun, - Box::new(Command::ToggleTheme), - ) - .when(AutoUpdater::is_available(cx), |this| { - this.separator().menu_with_icon( - "Check for Updates", - IconName::Device, - Box::new(Command::Update), - ) - }) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) - }), + row = row.child( + Button::new("current-user") + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), ) - }), - window, - cx, - ) + .small() + .caret() + .compact() + .transparent() + .dropdown_menu(move |this, _window, cx| { + let avatar = avatar.clone(); + let avatar_seed = avatar_seed.clone(); + let name = name.clone(); + + this.min_w(px(256.)) + .item(PopupMenuItem::element(move |_window, cx| { + h_flex() + .gap_1p5() + .text_xs() + .text_color(cx.theme().text_muted) + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), + ) + .child(name.clone()) + })) + .separator() + .menu_with_icon("Inbox", IconName::Inbox, Box::new(Command::ShowInbox)) + .menu_with_icon( + "Search", + IconName::Search, + Box::new(Command::ShowSearch), + ) + .menu_with_icon( + "Profile", + IconName::Profile, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Contact List", + IconName::Book, + Box::new(Command::ShowContactList), + ) + .menu_with_icon( + "Backup", + IconName::UserKey, + Box::new(Command::ShowBackup), + ) + .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme)) + .when(AutoUpdater::is_available(cx), |this| { + this.separator().menu_with_icon( + "Check for Updates", + IconName::Device, + Box::new(Command::Update), + ) + }) + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) + }), + ); + } + + if self.community.is_some() { + row = row.child(div().flex_1()).child( + Button::new("sidebar-back") + .icon(IconName::ArrowLeft) + .tooltip("Back") + .ghost() + .small() + .on_click(cx.listener(|this, _event, window, cx| { + this.close_community(window, cx); + })), + ); + } + + title_bar_drag_handlers(row, window, cx).into_any_element() } } @@ -459,10 +495,12 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let nostr = NostrRegistry::global(cx); let (logged_in, ready) = { - let nostr = nostr.read(cx); - (nostr.current_user().is_some(), nostr.ready()) + let nostr = NostrRegistry::global(cx); + ( + nostr.read(cx).current_user().is_some(), + nostr.read(cx).ready(), + ) }; if !logged_in { @@ -474,17 +512,17 @@ impl Render for Sidebar { .border_color(cx.theme().border_variant) .into_any_element(); } - return onboarding::render(window, cx).into_any_element(); } - let chat = ChatRegistry::global(cx); - let loading = chat.read(cx).loading(); + let community = self + .community + .clone() + .and_then(|community| community.upgrade()); - let sidebar = cx.entity().downgrade(); - let active_tab = self.active_tab; - let rows = Rc::new(rows_for(active_tab, cx)); - let scroll_handle = &self.scroll_handles[active_tab.index()]; + if community.is_none() { + self.community = None; + } v_flex() .image_cache(retain_all("sidebar")) @@ -495,6 +533,29 @@ impl Render for Sidebar { .border_r_1() .border_color(cx.theme().border_variant) .child(self.render_user(window, cx)) + .map(|this| match community { + Some(community) => this.child(self.render_community(community, cx)), + None => this.child(self.render_tabs(cx)), + }) + .into_any_element() + } +} + +impl Sidebar { + fn render_tabs(&mut self, cx: &mut Context) -> AnyElement { + let chat = ChatRegistry::global(cx); + let loading = chat.read(cx).loading(); + + let sidebar = cx.entity().downgrade(); + let active_tab = self.active_tab; + let rows = Rc::new(rows_for(active_tab, cx)); + let scroll_handle = &self.scroll_handles[active_tab.index()]; + + v_flex() + .size_full() + .flex_1() + .min_h_0() + .gap_1() .when(active_tab.chat(), |this| { this.child( v_flex() @@ -629,4 +690,192 @@ impl Render for Sidebar { }) .into_any_element() } + + fn render_community( + &mut self, + community: Entity, + cx: &mut Context, + ) -> AnyElement { + let (channels, active, admins, members, banner) = { + let community = community.read(cx); + let owner = community.state().owner; + let mut admins = Vec::new(); + let mut members = Vec::new(); + + for public_key in community.members() { + if community.control().roles.is_staff(public_key, &owner) { + admins.push(*public_key); + } else { + members.push(*public_key); + } + } + + ( + community + .channels() + .iter() + .map(|channel| (channel.id, channel.name.clone(), channel.private)) + .collect::>(), + community.active_channel(), + admins, + members, + community.banner(), + ) + }; + + let sections = v_flex() + .id("community-sections") + .flex_1() + .min_h_0() + .w_full() + .px_2() + .pb_2() + .track_scroll(&self.community_scroll) + .overflow_y_scroll() + .child(section_row( + "channels", + "Channels", + channels.len(), + self.channels_open, + |sidebar| sidebar.channels_open = !sidebar.channels_open, + cx, + )) + .when(self.channels_open, |this| { + this.children(channels.into_iter().map(|(id, name, private)| { + channel_row(id, name, private, active == Some(id), &community, cx) + })) + }) + .child(section_row( + "admins", + "Admins", + admins.len(), + self.admins_open, + |sidebar| sidebar.admins_open = !sidebar.admins_open, + cx, + )) + .when(self.admins_open, |this| { + this.children( + admins + .iter() + .map(|public_key| member_row("community-admin", *public_key, cx)), + ) + }) + .child(section_row( + "members", + "Members", + members.len(), + self.members_open, + |sidebar| sidebar.members_open = !sidebar.members_open, + cx, + )) + .when(self.members_open, |this| { + this.children( + members + .iter() + .map(|public_key| member_row("member", *public_key, cx)), + ) + }); + + v_flex() + .flex_1() + .min_h_0() + .w_full() + .gap_2() + .when_some(banner, |this, banner| { + this.child( + div().px_2().child( + img(banner) + .w_full() + .h(px(80.)) + .rounded(cx.theme().radius) + .object_fit(ObjectFit::Cover), + ), + ) + }) + .child(sections) + .child(Scrollbar::vertical(&self.community_scroll)) + .into_any_element() + } +} + +fn section_row( + id: &'static str, + label: &'static str, + count: usize, + open: bool, + toggle: impl Fn(&mut Sidebar) + 'static, + cx: &mut Context, +) -> AnyElement { + div() + .flex_shrink_0() + .child( + TreeRow::new(ElementId::Name(id.into()), TreeRowKind::Section, label) + .icon(if open { + IconName::CaretDown + } else { + IconName::CaretRight + }) + .count(count) + .on_click(cx.listener(move |this, _event, _window, cx| { + toggle(this); + cx.notify(); + })), + ) + .into_any_element() +} + +fn channel_row( + id: ChannelId, + name: String, + private: bool, + selected: bool, + community: &Entity, + cx: &mut Context, +) -> AnyElement { + let community = community.clone(); + + div() + .flex_shrink_0() + .child( + TreeRow::new( + ElementId::Name(SharedString::from(format!( + "community-channel-{}", + id.to_hex() + ))), + TreeRowKind::Room, + name, + ) + .icon(if private { + IconName::Lock + } else { + IconName::Message + }) + .on_click(cx.listener(move |_this, _event, _window, cx| { + community.update(cx, |community, cx| community.set_active_channel(id, cx)); + cx.notify(); + })), + ) + .when(selected, |this| this.bg(cx.theme().ghost_element_active)) + .into_any_element() +} + +fn member_row(prefix: &str, public_key: PublicKey, cx: &App) -> AnyElement { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(&public_key, cx); + + div() + .flex_shrink_0() + .child( + TreeRow::new( + ElementId::Name(SharedString::from(format!( + "{prefix}-{}", + public_key.to_hex() + ))), + TreeRowKind::Room, + person.name(), + ) + .avatar(person.avatar_seed()) + .picture(person.avatar()), + ) + .into_any_element() } diff --git a/docs/community-messages-panel-plan.md b/docs/community-messages-panel-plan.md deleted file mode 100644 index af011a40..00000000 --- a/docs/community-messages-panel-plan.md +++ /dev/null @@ -1,243 +0,0 @@ -# Community messages panel - -A community opens as a panel in the center dock when its sidebar row is clicked, -shaped like every Discord-style client (Vector, Armada): - -``` -+----------------+----------------------------------+ -| Channels | | -| # general | messages | -| # random | | -+----------------+ | -| Members | | -| @alice +----------------------------------+ -| @bob | [ composer ] | -+----------------+----------------------------------+ -``` - -One panel holds both columns. The left column scrolls its two sections; the right -column is the timeline plus the composer. The panel is per community, so its -`panel_id` is `community-` and re-clicking a community focuses -the existing panel (`ui::dock::add_panel` already does this by `panel_id`). - -## What already exists - -- `sync::planes` already derives a `Plane` for the Control plane, the Guestbook, - and every public channel; `CommunityRegistry::sync_subscriptions` subscribes to - all of them as one filter, so live channel wraps already reach the client. -- `sync::fold` already opens channel wraps and calls `store::cache_rumor`, so the - local DB already holds the timeline; it folds the Control Plane and the member - list and emits `CommunityEvent::Updated` on every inbound wrap. -- `cord03::fold` turns cached rumors into `ChatMessage`s with edits, deletes and - reactions resolved; `store::query_rumors` / `store::backfill` are the read paths. -- `Community` exposes `channels()`, `members()`, `control()`, `name()`, `icon()`. -- `community::init` is already called by `desktop/src/main.rs`. - -So the panel needs no new protocol work: one reader, one writer, and an event that -asks the workspace to open the panel. - -## 1. `concord`: a cached rumor is a `ChatRumor` - -`store::query_rumors` hands back `UnsignedEvent`s, and `cord03::typed` (already -used by `open`) is private. Add one public wrapper in `crates/concord/src/cords/cord03.rs`: - -```rust -/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch. -pub fn parse_rumor(rumor: &UnsignedEvent) -> Result { - let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)? - .ok_or(ChatError::MissingTag(TAG_CHANNEL))? - .parse() - .map_err(|_| ChatError::BadTag(TAG_CHANNEL))?; - - let epoch = unique_tag(rumor, TAG_EPOCH)? - .ok_or(ChatError::MissingTag(TAG_EPOCH)) - .and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?; - - typed(rumor, &channel, Epoch(epoch)) -} -``` - -The tags are read with `cord01::unique_tag`, the same reader `check_channel_binding` -uses (both become `pub(crate)`), so a cached copy is parsed by exactly the rule that -accepted it at ingest. `canonical_decimal` and `typed` are already in this file. - -## 2. `community`: channel history and sending - -All in `crates/community/src/community.rs` on `Community`, mirroring `Room`. - -```rust -const MESSAGE_LIMIT: usize = 200; - -/// The secret a channel's plane derives from, and the epoch it is held at. -/// A private channel uses the key it was granted; a public one the community root. -fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])>; - -/// Page a channel's history into the local cache, once, when the channel is opened. -pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task>; - -/// The channel's timeline, folded from the local cache. -pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task>>; - -/// Seal a message to the channel plane, cache it, then publish it to the relays. -pub fn send( - &self, - channel: &ChannelId, - content: &str, - reply_to: Option, - cx: &App, -) -> Option>>; -``` - -- `backfill`: `store::backfill(&client, channel, &[(epoch, secret)], None, MESSAGE_LIMIT)`, - skipped when `store::query_rumors` already finds wraps for the channel, so it runs - once per channel. `store::backfill` walks up to `MAX_PAGES` pages itself. It - fetches through the client, so the community's relays must be in the pool — - `sync_subscriptions` already adds them on load. -- `messages`: `store::query_rumors(&client, channel, None, MESSAGE_LIMIT)`, then - `cord03::parse_rumor` over each, then `cord03::fold(&rumors, Timestamp::now(), can_delete)`. - The closure is the community's own policy: - `citation_ok(&owner, &id, actor, citation, &floors) && roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)`, - built from `self.state.owner`, `self.state.id`, `self.state.floors()` and - `self.control.roles` cloned into the background task. `cord03::fold` returns - newest-first, so reverse it for the bottom-aligned list. -- `send`: `cord03::build_message(author, channel, epoch, content, reply_to.as_ref(), at_ms, timer)` - where `timer` is `control.community.message_expiration` and `at_ms` is now in ms; - `cord03::seal_rumor(&rumor, &plane, &signer, false)`; then — the order matters — - `cord03::open(&wrap, &plane, channel, epoch)` and `store::cache_rumor` *before* - publishing, so the author's own row exists whether or not a relay answers. The - publish is `pub(crate) sync::connect_relays(client, &relays)` (the - `add_relay(..).and_connect()` loop the genesis path already ran) followed by - `pub(crate) sync::publish_wrap(client, &wrap, &relays)`, so one copy serves both - paths; failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey` - from `derive::channel_group_key(secret, channel, epoch)`, and the epoch from - `channel_secret`. Returns `None` without a signer or a held secret, and the rumor - id so the panel can reload. - -`CommunityEvent` gains one variant, and the registry a way to request an open, -mirroring `ChatRegistry::emit_room`: - -```rust -pub enum CommunityEvent { - Updated(CommunityId), - Open(CommunityId), - Error(String), -} - -impl CommunityRegistry { - /// Ask the workspace to open a community's panel. - pub fn emit_community(&mut self, community: &Entity, window: &mut Window, cx: &mut Context); -} -``` - -`emit_community` reads the id and emits `CommunityEvent::Open` through -`cx.defer_in(window, ...)` so the click never re-enters the registry. - -Private channels stay out of this pass: `sync::planes` does not subscribe them and -`CommunityState` has no room for a rotated key yet, so `channel_secret` returning -the granted `key` is the only support they get. - -## 3. `community_ui`: the new crate - -`crates/community_ui`, shaped like `chat_ui` (which is the reference for every -detail: `Panel` impl, notification routing, input handling, message list). - -``` -crates/community_ui/Cargo.toml deps: community, state, ui, theme, common, person, settings, gpui, nostr-sdk, smallvec, anyhow, log -crates/community_ui/src/lib.rs init + CommunityPanel -crates/community_ui/src/message.rs one message row's rendering -``` - -`community` re-exports the types the panel names, as `chat` already does for -`Message`: `ChatMessage`, `ReplyRef`, `ChannelId`, `CommunityId`. - -```rust -pub fn init(community: Entity, window: &mut Window, cx: &mut App) -> Entity; - -pub struct CommunityPanel { - id: SharedString, // "community-" - focus_handle: FocusHandle, - community: WeakEntity, - channel: Option, // the selected channel - messages: Vec, // ascending, bottom-aligned list - list_state: ListState, - input: Entity, - tasks: Vec>>, - _subscriptions: SmallVec<[Subscription; 2]>, -} -``` - -- `new` takes the strong `Entity`, subscribes with - `cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the - weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split). - It picks `channels().first()` (the genesis `#general`) and, in the subscription, - `CommunityEvent::Updated(id)` reloads the open channel through `cx.defer_in`, - because the emit sits inside the community's own update; `CommunityEvent::Error(error)` - becomes a window notification. A `cx.defer_in` does the first `backfill` + `messages` - load, exactly as `ChatPanel::new` defers `connect`. -- The channel and member lists are read live in `render` through the weak entity - (as the sidebar reads `Community::channels()`), so a new channel or member needs no - invalidation; a dropped entity renders an empty state instead. -- `select_channel(channel, window, cx)` swaps the selection, resets the list and - loads: `backfill` once per channel, then `messages`. -- `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages` and - `list_state.reset(len)` (then `scroll_to_end`). Edits, deletes and reactions are - folded server-side of the UI, so a full replace is the honest update and stays - small at `MESSAGE_LIMIT`. A failed load becomes a window notification. -- `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the - input, and reloads when the task resolves. Empty input is refused with a - notification, like `ChatPanel`. -- `render`: `v_flex` holding `h_flex` - - left: `w(px(220.))`, `border_r_1`, `.overflow_y_scrollbar()` column with a - `Channels` section (row = icon `IconName::Message`, or `Lock` when private, plus - `ChannelKeyRef.name`; the selected row takes `cx.theme().ghost_element_selected`) and - a `Members` section (row = `Avatar` from - `PersonRegistry::global(cx).read(cx).get(&pk, cx)` plus the profile name, - honouring `AppSettings::get_hide_avatar` like `TreeRow`). - - right: `v_flex().flex_1().min_w_0()` with `gpui::list(self.list_state, ...)` over - `message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the - composer row: `Textarea` (`InputEvent::PressEnter` sends) and a - `Button::new("send").icon(IconName::PaperPlaneFill)`. -- A message row: author name (person profile, "Unknown" fallback), `to_ago()` from - `common::TimestampExt`, the content as plain text (no markdown, media or file - rendering in this pass), a muted `(edited)` marker when `edited_at` is set, an - emoji summary line from `reactions`, and `"Message deleted"` in - `cx.theme().text_placeholder` when `deleted`. -- `Panel`: `panel_id` = the id above, `title` = the community icon (`Avatar`) plus - `community.name()`, `closable` = true, no toolbar buttons. - -## 4. `workspace`: open the panel from the sidebar - -- `crates/workspace/Cargo.toml`: add `community_ui = { path = "../community_ui" }`. -- `crates/workspace/src/lib.rs`: subscribe to `CommunityRegistry` beside the chat - subscription and, on `CommunityEvent::Open(id)`, look the community up with - `registry.read(cx).community(&id)` and - `add_panel_to_dock(community_ui::init(community, window, cx), DockPlacement::Center, window, cx)`. - `CommunityEvent::Error` keeps its single handler in the sidebar. -- `crates/workspace/src/sidebar/mod.rs`: `open_community` keeps recording the - recent community and now ends with - `CommunityRegistry::global(cx).update(cx, |registry, cx| registry.emit_community(&community, window, cx))`, - so the row's click handler needs the `window`. - -## 5. Order of work - -1. `cord03::parse_rumor`. -2. `community`: `channel_secret`, `backfill`, `messages`, `send`, `CommunityEvent::Open`, - `emit_community`. -3. `community_ui`: `message.rs`, then the panel with the channel list, the timeline - and the composer, then the member list. -4. `workspace`: the dependency, the registry subscription, the sidebar click. -5. `cargo check -p workspace` (the panel only compiles through it), then a manual - run: create a community, click its sidebar row, send a message and see it through - a second account. - -No tests: the crate follows the "no `unwrap`, errors to the UI" rule and validation -is the manual run above. - -## Out of scope - -Files, reactions as a composer action, edits, threads, pins, typing indicators, -unread badges, notifications, message expiration purging (`store::purge_expired`), -private-channel subscriptions (a rekey cannot be persisted yet), moderation actions, -and community management (metadata, roles, invites). Also unchanged: -`crates/chat/src/lib.rs::handle_notifications` already routes kind 1059 wraps by -subscription id, so concord traffic does not land in the DM trash. diff --git a/docs/community-sidebar-plan.md b/docs/community-sidebar-plan.md new file mode 100644 index 00000000..6fe33bdb --- /dev/null +++ b/docs/community-sidebar-plan.md @@ -0,0 +1,98 @@ +# Community sidebar + +When a community is opened from the sidebar, the sidebar's content becomes the +community's own channel/member browser. Going back restores the normal tabs. + +``` ++------------------+--------------------------------+ +| [<-] community | | +| banner | | +| v Channels 3 | messages | +| # general | | +| # random | | +| v Admins 1 | | +| @owner | | +| v Members 2 +--------------------------------+ +| @alice | [ composer ] | +| @bob | | ++------------------+--------------------------------+ +``` + +## Current state + +* `community_ui::CommunityPanel` renders its own 220px left column (channels + + members) next to the timeline. +* `Sidebar` renders the Recents/Chats/Communities tabs, and `Sidebar::open_community` + records the community as recent and asks `CommunityRegistry` to emit + `CommunityEvent::Open`, which `Workspace` turns into a center dock panel. +* `Community` already folds channels (`state().channels`), members, the control + roster (`control().roles`), the community icon, and the banner (`metadata.banner`). + +## Plan + +### 1. Backend: community crate + +`crates/community/src/community.rs` + +* Add `CommunityEvent::Close(CommunityId)` and `CommunityEvent::Channel(CommunityId, ChannelId)`. +* Add `active: Option` to `Community`, with: + * `pub fn active_channel(&self) -> Option` — the selected channel, + defaulting to the first one. + * `pub fn set_active_channel(&mut self, ChannelId, &mut Context)` — stores + it and emits `Channel`. +* Add `banner: Option` resolved from `metadata.banner` the same way the + icon already is, exposed as `pub fn banner(&self) -> Option`. + +`crates/community/src/lib.rs` + +* Add `CommunityRegistry::emit_close(&mut self, CommunityId, &mut Window, &mut Context)`, + mirroring `emit_community`, emitting `Close`. + +`crates/community/src/sync.rs` + +* Rename `resolve_icon` to `resolve_image` (it takes any `ImageRef`). + +### 2. `community_ui`: panel becomes timeline-only + +* Drop the internal channel/member column and its helpers. +* Resolve the shown channel from `Community::active_channel` on every load and + reload, and subscribe to `CommunityEvent::Channel` to follow sidebar clicks + (deferred, because the community is being updated when it emits). + +### 3. `workspace`: sidebar renders the community + +`crates/workspace/src/sidebar/mod.rs` + +* New state: `community: Option>` and three section flags. +* `open_community` also sets `self.community`; new `close_community` clears it and + asks the registry to emit `Close`. +* `render` picks between the existing tabs and `render_community`. +* `render_user` keeps the account button as usual and adds a back button on the + right when a community is open. +* `render_community` pins the optional banner image and scrolls everything below + it: collapsible Channels, Admins (members where `control().roles.is_staff`), and + Members sections. Every row is a `TreeRow` — the component the sidebar's own + lists use — so height, padding, typography, hover, and selection match, and each + row is wrapped `flex_shrink_0` so the list scrolls instead of squashing. +* Observe the registry so member/roster changes re-render the lists. + +`crates/workspace/src/lib.rs` + +* Remember the opened `CommunityPanel` so `CommunityEvent::Close` can close it: + activate it, focus the group, then dispatch `ClosePanel`. + +### 4. `community`: a list refresh keeps entities + +`CommunityRegistry::track` used to rebuild every community, so an open panel and a +browsing sidebar went stale on any list signal — and `sync::load` merges join +material into a loaded `CommunityState`, so the state really can change. `track` +now keeps the entity for an id it still tracks and hands it the new state through +`Community::adopt`, which also cancels a fold in flight that would otherwise write +the pre-adoption state back. Observers move to a +`HashMap` so the survivors keep theirs, and the ids the +list dropped lose their observer and their synced key. + +## Out of scope + +Moderation, invites, community management, channel icons, unread badges, member +search, and persisting the selected channel across restarts.