add nip29_ui crate with group panel
This commit is contained in:
Generated
+18
@@ -4612,6 +4612,24 @@ dependencies = [
|
||||
"state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nip29_ui"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
"chat_ui",
|
||||
"common",
|
||||
"gpui-pre",
|
||||
"nip29",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"theme",
|
||||
"ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
|
||||
@@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock<Regex> =
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
mod text;
|
||||
pub mod text;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "nip29_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
common = { path = "../common" }
|
||||
nip29 = { path = "../nip29" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,553 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use chat::Message;
|
||||
use chat_ui::text::rendered_text;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
|
||||
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||
};
|
||||
use nip29::{Group, GroupEvent, GroupsRegistry, Membership};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
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::markdown::RenderedText;
|
||||
use ui::message::WelcomeMessage;
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
mod message;
|
||||
|
||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||
const RUN_WINDOW_SECS: u64 = 300;
|
||||
|
||||
enum Notice {
|
||||
Relay(String),
|
||||
Join {
|
||||
refused: Option<String>,
|
||||
closed: bool,
|
||||
},
|
||||
Pending,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
fn of(group: &Group) -> Option<Self> {
|
||||
if let Some(reason) = group.refused() {
|
||||
return Some(Self::Relay(reason.to_owned()));
|
||||
}
|
||||
|
||||
let closed = group.metadata().is_some_and(|metadata| metadata.closed);
|
||||
|
||||
match group.membership() {
|
||||
Membership::Member => {}
|
||||
Membership::Pending { .. } => return Some(Self::Pending),
|
||||
Membership::Refused { reason } => {
|
||||
return Some(Self::Join {
|
||||
refused: reason.clone(),
|
||||
closed,
|
||||
});
|
||||
}
|
||||
Membership::Unknown | Membership::Removed => {
|
||||
return Some(Self::Join {
|
||||
refused: None,
|
||||
closed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
group
|
||||
.metadata()
|
||||
.is_some_and(|metadata| !metadata.supports(Kind::ChatMessage))
|
||||
.then_some(Self::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(group: &Group) -> SharedString {
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.map_or_else(
|
||||
|| SharedString::from(group.key().id().as_str()),
|
||||
SharedString::from,
|
||||
)
|
||||
}
|
||||
|
||||
fn opens_run(messages: &[Message], index: usize) -> bool {
|
||||
let Some(current) = messages.get(index) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(previous) = index.checked_sub(1).and_then(|index| messages.get(index)) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
current.author != previous.author
|
||||
|| current
|
||||
.created_at
|
||||
.as_secs()
|
||||
.saturating_sub(previous.created_at.as_secs())
|
||||
> RUN_WINDOW_SECS
|
||||
}
|
||||
|
||||
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel> {
|
||||
cx.new(|cx| GroupPanel::new(group, window, cx))
|
||||
}
|
||||
|
||||
pub struct GroupPanel {
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
group: WeakEntity<Group>,
|
||||
list_state: ListState,
|
||||
head: Option<EventId>,
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
input: Entity<TextareaState>,
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl GroupPanel {
|
||||
pub fn new(group: Entity<Group>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let (id, name, count, head) = {
|
||||
let group = group.read(cx);
|
||||
let messages = group.messages();
|
||||
|
||||
(
|
||||
SharedString::from(format!("group-{}", group.key().cache_tag())),
|
||||
display_name(group),
|
||||
messages.len(),
|
||||
messages.first().map(|message| message.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(&group, |this, _group, event, cx| {
|
||||
if !matches!(event, GroupEvent::Sent(_)) {
|
||||
this.sync(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&group, |this, _group, cx| {
|
||||
this.sync(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.list_state.set_follow_mode(FollowMode::Tail);
|
||||
this.list_state.set_scroll_handler(cx.listener(
|
||||
|this, event: &ListScrollEvent, _window, cx| {
|
||||
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||
this.load_older(cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
});
|
||||
|
||||
Self {
|
||||
id,
|
||||
focus_handle: cx.focus_handle(),
|
||||
group: group.downgrade(),
|
||||
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
|
||||
head,
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
input,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_count(&self, cx: &App) -> usize {
|
||||
self.group
|
||||
.upgrade()
|
||||
.map_or(2, |group| group.read(cx).messages().len() + 2)
|
||||
}
|
||||
|
||||
fn sync(&mut self, cx: &App) {
|
||||
let Some(group) = self.group.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let group = group.read(cx);
|
||||
let messages = group.messages();
|
||||
let head = messages.first().map(|message| message.id);
|
||||
let total = messages.len() + 2;
|
||||
let old_total = self.list_state.item_count();
|
||||
|
||||
if total == old_total {
|
||||
self.head = head;
|
||||
return;
|
||||
}
|
||||
|
||||
let old_len = old_total.saturating_sub(2);
|
||||
let prepended = self
|
||||
.head
|
||||
.and_then(|old_head| messages.iter().position(|message| message.id == old_head))
|
||||
.filter(|prepended| prepended + old_len <= messages.len());
|
||||
|
||||
match prepended {
|
||||
Some(prepended) => {
|
||||
if prepended > 0 {
|
||||
self.list_state.splice(2..2, prepended);
|
||||
}
|
||||
|
||||
let appended = messages.len() - old_len - prepended;
|
||||
|
||||
if appended > 0 {
|
||||
let at = old_total + prepended;
|
||||
self.list_state.splice(at..at, appended);
|
||||
}
|
||||
}
|
||||
None => self.list_state.reset(total),
|
||||
}
|
||||
|
||||
self.head = head;
|
||||
}
|
||||
|
||||
fn load_older(&mut self, cx: &mut App) {
|
||||
let Some(key) = self
|
||||
.group
|
||||
.upgrade()
|
||||
.map(|group| group.read(cx).key().clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
|
||||
groups.update(cx, |registry, cx| {
|
||||
registry.load_more(&key, 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() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(send) = self
|
||||
.group
|
||||
.update(cx, |group, cx| group.send_message(&content, vec![], cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
|
||||
self.tasks
|
||||
.push(cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
if let Err(error) = send.await {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn notice(&self, cx: &App) -> Option<Notice> {
|
||||
self.group
|
||||
.upgrade()
|
||||
.and_then(|group| Notice::of(group.read(cx)))
|
||||
}
|
||||
|
||||
fn render_welcome(&self, cx: &App) -> AnyElement {
|
||||
let (name, avatar) = match self.group.upgrade() {
|
||||
Some(group) => {
|
||||
let group = group.read(cx);
|
||||
let seed = group.key().cache_tag();
|
||||
let picture = group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.picture.clone());
|
||||
|
||||
(
|
||||
display_name(group),
|
||||
Avatar::from_source(picture).seed(seed).large(),
|
||||
)
|
||||
}
|
||||
None => (SharedString::from("this group"), Avatar::new(None)),
|
||||
};
|
||||
|
||||
WelcomeMessage::new("welcome")
|
||||
.icon(avatar)
|
||||
.title(format!("Welcome to {name}"))
|
||||
.message(format!("This is the start of {name}."))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(group) = self.group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let (has_older, loading) = {
|
||||
let group = group.read(cx);
|
||||
|
||||
(group.oldest().is_some(), group.loading_more())
|
||||
};
|
||||
|
||||
if !has_older {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(
|
||||
Button::new("load-older")
|
||||
.label(if loading {
|
||||
"Loading earlier messages…"
|
||||
} else {
|
||||
"Load earlier messages"
|
||||
})
|
||||
.ghost()
|
||||
.small()
|
||||
.loading(loading)
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| this.load_older(cx))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_notice(&self, notice: &Notice, cx: &mut Context<Self>) -> AnyElement {
|
||||
let row = h_flex()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder);
|
||||
|
||||
match notice {
|
||||
Notice::Relay(reason) => row
|
||||
.child(SharedString::from(reason.as_str()))
|
||||
.into_any_element(),
|
||||
Notice::Join { refused, closed } => {
|
||||
let row = match refused {
|
||||
Some(reason) => row.child(SharedString::from(reason.as_str())),
|
||||
None => row,
|
||||
};
|
||||
|
||||
if *closed {
|
||||
return row
|
||||
.child("This group is closed; an invite link is required.")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let Some(key) = self
|
||||
.group
|
||||
.upgrade()
|
||||
.map(|group| group.read(cx).key().clone())
|
||||
else {
|
||||
return row.into_any_element();
|
||||
};
|
||||
|
||||
row.child(
|
||||
Button::new("join-group")
|
||||
.label("Request to join")
|
||||
.small()
|
||||
.on_click(cx.listener(move |_this, _event, _window, cx| {
|
||||
GroupsRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.join_key(key.clone(), None, cx);
|
||||
});
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
Notice::Pending => row
|
||||
.child("Waiting for the relay to approve.")
|
||||
.into_any_element(),
|
||||
Notice::Unsupported => row
|
||||
.child("This group doesn't support text messages.")
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if ix == 0 {
|
||||
return self.render_welcome(cx);
|
||||
}
|
||||
|
||||
if ix == 1 {
|
||||
return self.render_older(cx);
|
||||
}
|
||||
|
||||
let Some(group) = self.group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let group = group.read(cx);
|
||||
let messages = group.messages();
|
||||
|
||||
let Some(message) = messages.get(ix - 2) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let show_author = opens_run(messages, ix - 2);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
let content = self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
rendered_text(&message.content, &message.mentions, &persons, true, cx)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
|
||||
message::render(ix, message, content, show_author, cx)
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let can_send = self.group.upgrade().is_some_and(|group| {
|
||||
let group = group.read(cx);
|
||||
let metadata = group.metadata();
|
||||
let supports_text =
|
||||
metadata.is_none_or(|metadata| metadata.supports(Kind::ChatMessage));
|
||||
let writable = metadata.is_none_or(|metadata| !metadata.restricted)
|
||||
|| matches!(group.membership(), Membership::Member);
|
||||
|
||||
supports_text && writable
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.items_end()
|
||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.tooltip("Send")
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(!can_send)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for GroupPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.group
|
||||
.upgrade()
|
||||
.map(|group| {
|
||||
let group = group.read(cx);
|
||||
let seed = group.key().cache_tag();
|
||||
let picture = group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.picture.clone());
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(Avatar::from_source(picture).seed(seed).xsmall())
|
||||
.child(display_name(group))
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or_else(|| div().text_xs().child("Unknown").into_any_element())
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for GroupPanel {}
|
||||
|
||||
impl Focusable for GroupPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for GroupPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let notice = self.notice(cx);
|
||||
let empty = self.item_count(cx) == 2;
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.map(|this| {
|
||||
if empty {
|
||||
this.child(
|
||||
v_flex().size_full().justify_center().child(match notice {
|
||||
Some(notice) => self.render_notice(¬ice, cx),
|
||||
None => h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("No messages yet")
|
||||
.into_any_element(),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.when_some(notice.as_ref(), |this, notice| {
|
||||
this.child(self.render_notice(notice, cx))
|
||||
})
|
||||
.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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use chat::Message;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled,
|
||||
StyledImage, div, img, px,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::message::MessageRow;
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
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);
|
||||
|
||||
MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(media(&message.media, cx))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||
if let [only] = media {
|
||||
return div()
|
||||
.child(
|
||||
img(only.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.children(media.iter().enumerate().map(|(ix, item)| {
|
||||
div().id(format!("media-{ix}")).child(
|
||||
img(item.clone())
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
+4
-4
@@ -545,10 +545,10 @@ before they reach the consumer, so a replay cannot double-hydrate.
|
||||
## Not wired up yet
|
||||
|
||||
- **No screen consumes this crate yet.** The only references outside
|
||||
`crates/nip29` are the two `nip29::init(cx)` calls, so the sidebar section, the
|
||||
group panel and the join dialog are still to be built. The panel needs
|
||||
`chat::Message` rendering lifted (or copied) out of `ChatPanel`: the data type
|
||||
is already shared, only the row rendering is not. `crates/workspace/src/panels/browse.rs`
|
||||
`crates/nip29` are the two `nip29::init(cx)` calls. The group panel is built in
|
||||
`crates/nip29_ui` (`GroupPanel`, which also owns the `chat::Message` row
|
||||
rendering), but nothing opens it yet: the sidebar section, the join dialog and
|
||||
their entry points are still to be built. `crates/workspace/src/panels/browse.rs`
|
||||
is the stub for browsing.
|
||||
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
||||
standing REQ, so only our own accepted deletes evict the row.
|
||||
|
||||
Reference in New Issue
Block a user