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)]
+58 -189
View File
@@ -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<ChannelId> {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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::<Vec<_>>(),
community.members().iter().copied().collect::<Vec<_>>(),
)
};
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()
}
+2 -4
View File
@@ -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()
}
+40 -13
View File
@@ -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<DockArea>,
title_bar_chrome: Rc<dock::TitleBarChrome>,
/// The community panel currently docked, if any
community_panel: Option<WeakEntity<CommunityPanel>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// 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<P: Panel>(
panel: Entity<P>,
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()
+358 -109
View File
@@ -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<WeakEntity<Community>>,
/// 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<Self>) -> impl IntoElement {
fn close_community(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Community>,
cx: &mut Context<Self>,
) -> 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::<Vec<_>>(),
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<Sidebar>,
) -> 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<Community>,
cx: &mut Context<Sidebar>,
) -> 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()
}