add group attachments and sidebar toggle

This commit is contained in:
2026-10-01 08:25:52 +07:00
parent 61aa7c4a70
commit 857795a92e
9 changed files with 467 additions and 54 deletions
+1
View File
@@ -19,4 +19,5 @@ gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
serde.workspace = true
smallvec.workspace = true
+198 -26
View File
@@ -4,15 +4,18 @@ use anyhow::Result;
use chat_ui::text::rendered_text;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
Action, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter,
FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState,
ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity,
Window, div, list, px,
};
use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use state::{NostrRegistry, upload};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariant, ButtonVariants};
@@ -24,7 +27,7 @@ use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::message::WelcomeMessage;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
mod details;
mod invite;
@@ -32,6 +35,14 @@ mod message;
mod metadata;
mod users;
/// Ask the shell to show or hide the sidebar's information for a group.
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = nip29_ui, no_json)]
pub struct ToggleGroupMeta {
pub relay: RelayUrl,
pub group: String,
}
const LOAD_OLDER_THRESHOLD: usize = 20;
const RUN_WINDOW_SECS: u64 = 300;
@@ -87,6 +98,16 @@ fn display_name(group: &Group) -> SharedString {
)
}
fn attachment_name(url: &Url) -> SharedString {
url.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|name| !name.is_empty())
.map_or_else(
|| SharedString::from(url.as_str()),
|name| SharedString::from(name.to_owned()),
)
}
fn opens_run(rows: &[Row], index: usize) -> bool {
let (Some(current), Some(previous)) = (
rows.get(index),
@@ -146,6 +167,10 @@ pub struct GroupPanel {
head: Option<EventId>,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
uploading: bool,
tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
}
@@ -212,6 +237,8 @@ impl GroupPanel {
head,
rendered_texts_by_id: BTreeMap::new(),
input,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![],
_subscriptions: subscriptions,
}
@@ -281,7 +308,22 @@ impl GroupPanel {
}
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let content = self.input.read(cx).value().trim().to_owned();
let mut content = self.input.read(cx).value().trim().to_owned();
if !self.attachments.is_empty() {
let urls = self
.attachments
.iter()
.map(Url::to_string)
.collect::<Vec<_>>()
.join("\n");
content = if content.is_empty() {
urls
} else {
format!("{content}\n{urls}")
};
}
if content.is_empty() {
return;
@@ -297,6 +339,7 @@ impl GroupPanel {
self.input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
self.attachments.clear();
self.tasks
.push(cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
@@ -309,6 +352,57 @@ impl GroupPanel {
}));
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
return Ok(());
};
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
match upload(server, path, cx).await {
Ok(url) => {
this.update_in(cx, |this, _window, cx| {
this.attachments.push(url);
this.set_uploading(false, cx);
})?;
}
Err(error) => {
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
}));
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading;
cx.notify();
}
fn remove_attachment(&mut self, url: &Url, cx: &mut Context<Self>) {
self.attachments.retain(|attachment| attachment != url);
cx.notify();
}
fn notice(&self, cx: &App) -> Option<Notice> {
self.group
.upgrade()
@@ -546,17 +640,24 @@ impl GroupPanel {
return div().into_any_element();
};
let (name, picture, seed, admin) = {
let (about, relay, group_id, admin) = {
let group = group.read(cx);
let about = group
.metadata()
.and_then(|metadata| metadata.about.clone())
.filter(|about| !about.trim().is_empty())
.map(SharedString::from);
let admin = NostrRegistry::global(cx)
.read(cx)
.current_user()
.is_some_and(|me| group.admins().contains(&me));
(
group.display_name(),
group.display_image(),
group.key().cache_tag(),
about,
group.key().relay().clone(),
group.key().id().as_str().to_owned(),
admin,
)
};
@@ -573,15 +674,30 @@ impl GroupPanel {
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.child(Avatar::from_source(picture).seed(seed).small())
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.font_medium()
.truncate()
.child(name),
.text_sm()
.text_color(cx.theme().text_muted)
.when_some(about, |this, about| this.child(about)),
)
.child(
Button::new("group-info")
.icon(IconName::PanelLeftOpen)
.tooltip("Toggle group meta panel in the sidebar")
.ghost()
.small()
.on_click(move |_event, window, cx| {
window.dispatch_action(
Box::new(ToggleGroupMeta {
relay: relay.clone(),
group: group_id.clone(),
}),
cx,
);
}),
)
.child(
Button::new("group-details")
@@ -908,25 +1024,81 @@ impl GroupPanel {
supports_text && writable
});
h_flex()
v_flex()
.flex_shrink_0()
.w_full()
.p_2()
.gap_1()
.items_end()
.child(Textarea::new(&self.input).appearance(false).flex_1())
.children(self.render_attachments(cx))
.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);
})),
h_flex()
.gap_1()
.items_end()
.child(
Button::new("upload")
.icon(IconName::Upload)
.tooltip("Upload file")
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
)
.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);
})),
),
)
}
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
self.attachments
.iter()
.enumerate()
.map(|(ix, url)| {
let url = url.clone();
h_flex()
.items_center()
.gap_1()
.h_8()
.px_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.text_xs()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::Link).xsmall())
.child(
div()
.max_w(px(200.))
.truncate()
.child(attachment_name(&url)),
)
.child(
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
.icon(IconName::Close)
.tooltip("Remove")
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _event, _window, cx| {
this.remove_attachment(&url, cx);
})),
)
.into_any_element()
})
.collect()
}
}
impl Panel for GroupPanel {