add community ui
This commit is contained in:
Generated
+18
@@ -1291,6 +1291,24 @@ dependencies = [
|
|||||||
"state",
|
"state",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "community_ui"
|
||||||
|
version = "1.0.2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"community",
|
||||||
|
"gpui-pre",
|
||||||
|
"log",
|
||||||
|
"nostr-sdk",
|
||||||
|
"person",
|
||||||
|
"settings",
|
||||||
|
"smallvec",
|
||||||
|
"state",
|
||||||
|
"theme",
|
||||||
|
"ui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use concord::CommunityId;
|
|
||||||
use concord::cord01::KIND_WRAP;
|
use concord::cord01::KIND_WRAP;
|
||||||
pub use concord::cord02::CommunityMetadata;
|
pub use concord::cord02::CommunityMetadata;
|
||||||
|
pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||||
use concord::store::CommunityState;
|
use concord::store::CommunityState;
|
||||||
|
pub use concord::{ChannelId, CommunityId};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "community_ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
community = { path = "../community" }
|
||||||
|
state = { path = "../state" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
log.workspace = true
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
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};
|
||||||
|
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
pub fn init(
|
||||||
|
community: Entity<Community>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Entity<CommunityPanel> {
|
||||||
|
cx.new(|cx| CommunityPanel::new(community, window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Community Panel
|
||||||
|
pub struct CommunityPanel {
|
||||||
|
id: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// Community
|
||||||
|
community: WeakEntity<Community>,
|
||||||
|
|
||||||
|
/// The selected channel
|
||||||
|
channel: Option<ChannelId>,
|
||||||
|
|
||||||
|
/// The selected channel's timeline (oldest first)
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
|
||||||
|
/// Message list state
|
||||||
|
list_state: ListState,
|
||||||
|
|
||||||
|
/// Message input state
|
||||||
|
input: Entity<TextareaState>,
|
||||||
|
|
||||||
|
/// Async operations
|
||||||
|
tasks: Vec<Task<Result<()>>>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityPanel {
|
||||||
|
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let (id, name, channel) = {
|
||||||
|
let community = community.read(cx);
|
||||||
|
|
||||||
|
(
|
||||||
|
SharedString::from(format!("community-{}", community.id().to_hex())),
|
||||||
|
community.name(),
|
||||||
|
community.channels().first().map(|channel| channel.id),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let input = cx.new(|cx| {
|
||||||
|
TextareaState::new(window, cx)
|
||||||
|
.placeholder(format!("Message {name}"))
|
||||||
|
.auto_grow(1, 20)
|
||||||
|
.clean_on_escape()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.send(window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(cx.subscribe_in(
|
||||||
|
&community,
|
||||||
|
window,
|
||||||
|
|_this, _community, event, window, cx| match event {
|
||||||
|
// The fold holds the community, so reload once it is released.
|
||||||
|
CommunityEvent::Updated(_) => {
|
||||||
|
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
||||||
|
}
|
||||||
|
CommunityEvent::Error(error) => {
|
||||||
|
window
|
||||||
|
.push_notification(Notification::error(error.clone()).autohide(false), cx);
|
||||||
|
}
|
||||||
|
CommunityEvent::Open(_) => {}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let panel = Self {
|
||||||
|
id,
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
community: community.downgrade(),
|
||||||
|
channel,
|
||||||
|
messages: Vec::new(),
|
||||||
|
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||||
|
input,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
||||||
|
|
||||||
|
panel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(backfill) = self
|
||||||
|
.community
|
||||||
|
.read_with(cx, |community, cx| community.backfill(&channel, cx))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
if let Err(error) = backfill.await {
|
||||||
|
log::warn!("community panel: backfill failed: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(messages) = self
|
||||||
|
.community
|
||||||
|
.read_with(cx, |community, cx| community.messages(&channel, cx))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match messages.await {
|
||||||
|
Ok(messages) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.messages = messages;
|
||||||
|
this.list_state.reset(this.messages.len());
|
||||||
|
this.list_state.scroll_to_end();
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.update_in(cx, |_this, window, cx| {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if content.is_empty() {
|
||||||
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(channel) = self.channel else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(send) = self.community.read_with(cx, |community, cx| {
|
||||||
|
community.send(&channel, &content, None, cx)
|
||||||
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(send) = send else {
|
||||||
|
window.push_notification(Notification::error("Failed to send the message"), cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.input.update(cx, |input, cx| {
|
||||||
|
input.set_value("", window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match send.await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.update_in(cx, |_this, window, cx| {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let Some(message) = self.messages.get(ix) else {
|
||||||
|
return div().into_any_element();
|
||||||
|
};
|
||||||
|
|
||||||
|
message::render(ix, message, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.w_full()
|
||||||
|
.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")
|
||||||
|
.icon(IconName::PaperPlaneFill)
|
||||||
|
.tooltip("Send")
|
||||||
|
.ghost()
|
||||||
|
.large()
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.send(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for CommunityPanel {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.id.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, cx: &App) -> AnyElement {
|
||||||
|
self.community
|
||||||
|
.read_with(cx, |community, _cx| {
|
||||||
|
let seed = community.id().to_hex();
|
||||||
|
let avatar = match community.icon() {
|
||||||
|
Some(path) => Avatar::from_source(path).seed(seed).xsmall(),
|
||||||
|
None => Avatar::new(None).seed(seed).xsmall(),
|
||||||
|
};
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(avatar)
|
||||||
|
.child(SharedString::from(community.name()))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| div().child("Unknown").into_any_element())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for CommunityPanel {}
|
||||||
|
|
||||||
|
impl Focusable for CommunityPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
.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)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(self.render_timeline(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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use common::TimestampExt;
|
||||||
|
use community::ChatMessage;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::{StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
|
pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(ix)
|
||||||
|
.w_full()
|
||||||
|
.py_1()
|
||||||
|
.px_3()
|
||||||
|
.hover(|this| this.bg(cx.theme().surface_background))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_3()
|
||||||
|
.when(!hide_avatar, |this| {
|
||||||
|
this.child(
|
||||||
|
Avatar::new(author.avatar())
|
||||||
|
.seed(author.avatar_seed())
|
||||||
|
.flex_shrink_0(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(div().font_semibold().child(author.name()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(Timestamp::from_secs(message.at_ms / 1000).to_ago()),
|
||||||
|
)
|
||||||
|
.when(message.edited_at.is_some(), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child("(edited)"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(content(message, cx))
|
||||||
|
.when(!message.reactions.is_empty(), |this| {
|
||||||
|
this.child(reactions(message, cx))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||||
|
if message.deleted {
|
||||||
|
return div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child("Message deleted")
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(message.content.clone()))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reactions(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||||
|
let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
|
||||||
|
|
||||||
|
for emoji in message.reactions.values() {
|
||||||
|
*grouped.entry(emoji.as_str()).or_default() += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.mt_1()
|
||||||
|
.gap_1()
|
||||||
|
.children(grouped.into_iter().map(|(emoji, count)| {
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.py_0p5()
|
||||||
|
.px_1()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.text_xs()
|
||||||
|
.child(SharedString::from(emoji))
|
||||||
|
.child(SharedString::from(count.to_string()))
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
@@ -147,6 +147,9 @@ crates/community_ui/src/lib.rs init + CommunityPanel
|
|||||||
crates/community_ui/src/message.rs one message row's rendering
|
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
|
```rust
|
||||||
pub fn init(community: Entity<Community>, window: &mut Window, cx: &mut App) -> Entity<CommunityPanel>;
|
pub fn init(community: Entity<Community>, window: &mut Window, cx: &mut App) -> Entity<CommunityPanel>;
|
||||||
|
|
||||||
@@ -156,11 +159,10 @@ pub struct CommunityPanel {
|
|||||||
community: WeakEntity<Community>,
|
community: WeakEntity<Community>,
|
||||||
channel: Option<ChannelId>, // the selected channel
|
channel: Option<ChannelId>, // the selected channel
|
||||||
messages: Vec<ChatMessage>, // ascending, bottom-aligned list
|
messages: Vec<ChatMessage>, // ascending, bottom-aligned list
|
||||||
message_index: HashMap<EventId, usize>,
|
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
tasks: Vec<Task<Result<()>>>,
|
tasks: Vec<Task<Result<()>>>,
|
||||||
subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,18 +170,19 @@ pub struct CommunityPanel {
|
|||||||
`cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the
|
`cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the
|
||||||
weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split).
|
weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split).
|
||||||
It picks `channels().first()` (the genesis `#general`) and, in the subscription,
|
It picks `channels().first()` (the genesis `#general`) and, in the subscription,
|
||||||
`CommunityEvent::Updated(id)` reloads the open channel while
|
`CommunityEvent::Updated(id)` reloads the open channel through `cx.defer_in`,
|
||||||
`CommunityEvent::Error(error)` becomes a window notification. A `cx.defer_in` does
|
because the emit sits inside the community's own update; `CommunityEvent::Error(error)`
|
||||||
the first `backfill` + `messages` load, exactly as `ChatPanel::new` defers `connect`.
|
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
|
- 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
|
(as the sidebar reads `Community::channels()`), so a new channel or member needs no
|
||||||
invalidation; a dropped entity renders an empty state instead.
|
invalidation; a dropped entity renders an empty state instead.
|
||||||
- `select_channel(channel, window, cx)` swaps the selection, resets the list and
|
- `select_channel(channel, window, cx)` swaps the selection, resets the list and
|
||||||
loads: `backfill` once per channel, then `messages`.
|
loads: `backfill` once per channel, then `messages`.
|
||||||
- `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages`,
|
- `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages` and
|
||||||
rebuilds `message_index` and `list_state.reset(len)` (then `scroll_to_end`).
|
`list_state.reset(len)` (then `scroll_to_end`). Edits, deletes and reactions are
|
||||||
Edits, deletes and reactions are folded server-side of the UI, so a full replace
|
folded server-side of the UI, so a full replace is the honest update and stays
|
||||||
is the honest update and stays small at `MESSAGE_LIMIT`.
|
small at `MESSAGE_LIMIT`. A failed load becomes a window notification.
|
||||||
- `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the
|
- `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the
|
||||||
input, and reloads when the task resolves. Empty input is refused with a
|
input, and reloads when the task resolves. Empty input is refused with a
|
||||||
notification, like `ChatPanel`.
|
notification, like `ChatPanel`.
|
||||||
@@ -194,7 +197,7 @@ pub struct CommunityPanel {
|
|||||||
`message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the
|
`message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the
|
||||||
composer row: `Textarea` (`InputEvent::PressEnter` sends) and a
|
composer row: `Textarea` (`InputEvent::PressEnter` sends) and a
|
||||||
`Button::new("send").icon(IconName::PaperPlaneFill)`.
|
`Button::new("send").icon(IconName::PaperPlaneFill)`.
|
||||||
- A message row: author name (person profile, "Unknown" fallback), `at_ago()` from
|
- A message row: author name (person profile, "Unknown" fallback), `to_ago()` from
|
||||||
`common::TimestampExt`, the content as plain text (no markdown, media or file
|
`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
|
rendering in this pass), a muted `(edited)` marker when `edited_at` is set, an
|
||||||
emoji summary line from `reactions`, and `"Message deleted"` in
|
emoji summary line from `reactions`, and `"Message deleted"` in
|
||||||
|
|||||||
Reference in New Issue
Block a user