update community sidebar
This commit is contained in:
@@ -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(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+48
-11
@@ -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,25 +235,55 @@ 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 = 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));
|
||||
}
|
||||
|
||||
community
|
||||
}
|
||||
None => {
|
||||
let community = cx.new(|_| Community::new(state));
|
||||
|
||||
self.observers
|
||||
.push(cx.observe(&community, |this, _community, cx| {
|
||||
self.observers.insert(
|
||||
id,
|
||||
cx.observe(&community, |this, _community, cx| {
|
||||
this.sync_subscriptions(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
self.index.insert(id, community.clone());
|
||||
self.communities.push(community);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+56
-187
@@ -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)
|
||||
.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)
|
||||
}),
|
||||
)
|
||||
.child(section_label("Members", cx))
|
||||
.children(
|
||||
members
|
||||
.iter()
|
||||
.map(|public_key| render_member(*public_key, cx)),
|
||||
),
|
||||
.size_full(),
|
||||
)
|
||||
.child(self.render_timeline(cx))
|
||||
}
|
||||
})
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
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()
|
||||
|
||||
@@ -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,15 +134,29 @@ 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()
|
||||
let mut row = h_flex()
|
||||
.id("sidebar-user")
|
||||
.w_full()
|
||||
.h(TABBAR_HEIGHT)
|
||||
@@ -135,15 +166,16 @@ impl Sidebar {
|
||||
.px_2()
|
||||
.when(cfg!(target_os = "macos"), |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
});
|
||||
|
||||
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.child(
|
||||
row = row.child(
|
||||
Button::new("current-user")
|
||||
.child(
|
||||
Avatar::new(avatar.clone())
|
||||
@@ -173,11 +205,7 @@ impl Sidebar {
|
||||
.child(name.clone())
|
||||
}))
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Inbox",
|
||||
IconName::Inbox,
|
||||
Box::new(Command::ShowInbox),
|
||||
)
|
||||
.menu_with_icon("Inbox", IconName::Inbox, Box::new(Command::ShowInbox))
|
||||
.menu_with_icon(
|
||||
"Search",
|
||||
IconName::Search,
|
||||
@@ -198,11 +226,7 @@ impl Sidebar {
|
||||
IconName::UserKey,
|
||||
Box::new(Command::ShowBackup),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Themes",
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
|
||||
.when(AutoUpdater::is_available(cx), |this| {
|
||||
this.separator().menu_with_icon(
|
||||
"Check for Updates",
|
||||
@@ -216,11 +240,23 @@ impl Sidebar {
|
||||
Box::new(Command::ShowSettings),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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-<community_id hex>` 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<ChatRumor, ChatError> {
|
||||
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<Result<()>>;
|
||||
|
||||
/// The channel's timeline, folded from the local cache.
|
||||
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>>;
|
||||
|
||||
/// 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<ReplyRef>,
|
||||
cx: &App,
|
||||
) -> Option<Task<Result<EventId>>>;
|
||||
```
|
||||
|
||||
- `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<Community>, window: &mut Window, cx: &mut Context<Self>);
|
||||
}
|
||||
```
|
||||
|
||||
`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<Community>, window: &mut Window, cx: &mut App) -> Entity<CommunityPanel>;
|
||||
|
||||
pub struct CommunityPanel {
|
||||
id: SharedString, // "community-<hex>"
|
||||
focus_handle: FocusHandle,
|
||||
community: WeakEntity<Community>,
|
||||
channel: Option<ChannelId>, // the selected channel
|
||||
messages: Vec<ChatMessage>, // ascending, bottom-aligned list
|
||||
list_state: ListState,
|
||||
input: Entity<TextareaState>,
|
||||
tasks: Vec<Task<Result<()>>>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
```
|
||||
|
||||
- `new` takes the strong `Entity<Community>`, 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.
|
||||
@@ -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<ChannelId>` to `Community`, with:
|
||||
* `pub fn active_channel(&self) -> Option<ChannelId>` — the selected channel,
|
||||
defaulting to the first one.
|
||||
* `pub fn set_active_channel(&mut self, ChannelId, &mut Context<Self>)` — stores
|
||||
it and emits `Channel`.
|
||||
* Add `banner: Option<PathBuf>` resolved from `metadata.banner` the same way the
|
||||
icon already is, exposed as `pub fn banner(&self) -> Option<PathBuf>`.
|
||||
|
||||
`crates/community/src/lib.rs`
|
||||
|
||||
* Add `CommunityRegistry::emit_close(&mut self, CommunityId, &mut Window, &mut Context<Self>)`,
|
||||
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<WeakEntity<Community>>` 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<CommunityId, Subscription>` 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.
|
||||
Reference in New Issue
Block a user