feat: add support for NIP-29 #56
Generated
+1
@@ -4588,6 +4588,7 @@ dependencies = [
|
|||||||
"nip29",
|
"nip29",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
|
"serde",
|
||||||
"settings",
|
"settings",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"state",
|
"state",
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ gpui.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|||||||
+198
-26
@@ -4,15 +4,18 @@ use anyhow::Result;
|
|||||||
use chat_ui::text::rendered_text;
|
use chat_ui::text::rendered_text;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
|
Action, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter,
|
||||||
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState,
|
||||||
Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity,
|
||||||
|
Window, div, list, px,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row};
|
use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, upload};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariant, ButtonVariants};
|
use ui::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
@@ -24,7 +27,7 @@ use ui::menu::{DropdownMenu, PopupMenuItem};
|
|||||||
use ui::message::WelcomeMessage;
|
use ui::message::WelcomeMessage;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
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 details;
|
||||||
mod invite;
|
mod invite;
|
||||||
@@ -32,6 +35,14 @@ mod message;
|
|||||||
mod metadata;
|
mod metadata;
|
||||||
mod users;
|
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 LOAD_OLDER_THRESHOLD: usize = 20;
|
||||||
const RUN_WINDOW_SECS: u64 = 300;
|
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 {
|
fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||||
let (Some(current), Some(previous)) = (
|
let (Some(current), Some(previous)) = (
|
||||||
rows.get(index),
|
rows.get(index),
|
||||||
@@ -146,6 +167,10 @@ pub struct GroupPanel {
|
|||||||
head: Option<EventId>,
|
head: Option<EventId>,
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
input: Entity<TextareaState>,
|
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]>,
|
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||||
}
|
}
|
||||||
@@ -212,6 +237,8 @@ impl GroupPanel {
|
|||||||
head,
|
head,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
input,
|
input,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
uploading: false,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -281,7 +308,22 @@ impl GroupPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
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() {
|
if content.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -297,6 +339,7 @@ impl GroupPanel {
|
|||||||
self.input.update(cx, |input, cx| {
|
self.input.update(cx, |input, cx| {
|
||||||
input.set_value("", window, cx);
|
input.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
|
self.attachments.clear();
|
||||||
|
|
||||||
self.tasks
|
self.tasks
|
||||||
.push(cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
.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> {
|
fn notice(&self, cx: &App) -> Option<Notice> {
|
||||||
self.group
|
self.group
|
||||||
.upgrade()
|
.upgrade()
|
||||||
@@ -546,17 +640,24 @@ impl GroupPanel {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let (name, picture, seed, admin) = {
|
let (about, relay, group_id, admin) = {
|
||||||
let group = group.read(cx);
|
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)
|
let admin = NostrRegistry::global(cx)
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.current_user()
|
.current_user()
|
||||||
.is_some_and(|me| group.admins().contains(&me));
|
.is_some_and(|me| group.admins().contains(&me));
|
||||||
|
|
||||||
(
|
(
|
||||||
group.display_name(),
|
about,
|
||||||
group.display_image(),
|
group.key().relay().clone(),
|
||||||
group.key().cache_tag(),
|
group.key().id().as_str().to_owned(),
|
||||||
admin,
|
admin,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -573,15 +674,30 @@ impl GroupPanel {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.child(Avatar::from_source(picture).seed(seed).small())
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.text_sm()
|
|
||||||
.font_medium()
|
|
||||||
.truncate()
|
.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(
|
.child(
|
||||||
Button::new("group-details")
|
Button::new("group-details")
|
||||||
@@ -908,25 +1024,81 @@ impl GroupPanel {
|
|||||||
supports_text && writable
|
supports_text && writable
|
||||||
});
|
});
|
||||||
|
|
||||||
h_flex()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
.p_2()
|
.p_2()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_end()
|
.children(self.render_attachments(cx))
|
||||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
h_flex()
|
||||||
.icon(IconName::PaperPlaneFill)
|
.gap_1()
|
||||||
.tooltip("Send")
|
.items_end()
|
||||||
.ghost()
|
.child(
|
||||||
.large()
|
Button::new("upload")
|
||||||
.disabled(!can_send)
|
.icon(IconName::Upload)
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.tooltip("Upload file")
|
||||||
this.send(window, cx);
|
.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 {
|
impl Panel for GroupPanel {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ impl ThemeColors {
|
|||||||
window_border: hsl(240.0, 5.9, 78.0),
|
window_border: hsl(240.0, 5.9, 78.0),
|
||||||
|
|
||||||
border: neutral().light().step_6(),
|
border: neutral().light().step_6(),
|
||||||
border_variant: neutral().light().step_5(),
|
border_variant: neutral().light().step_4(),
|
||||||
border_focused: brand().light().step_7(),
|
border_focused: brand().light().step_7(),
|
||||||
border_selected: brand().light().step_7(),
|
border_selected: brand().light().step_7(),
|
||||||
border_transparent: gpui::transparent_black(),
|
border_transparent: gpui::transparent_black(),
|
||||||
@@ -203,7 +203,7 @@ impl ThemeColors {
|
|||||||
window_border: hsl(240.0, 3.7, 28.0),
|
window_border: hsl(240.0, 3.7, 28.0),
|
||||||
|
|
||||||
border: neutral().dark().step_6(),
|
border: neutral().dark().step_6(),
|
||||||
border_variant: neutral().dark().step_5(),
|
border_variant: neutral().dark().step_4(),
|
||||||
border_focused: brand().dark().step_7(),
|
border_focused: brand().dark().step_7(),
|
||||||
border_selected: brand().dark().step_7(),
|
border_selected: brand().dark().step_7(),
|
||||||
border_transparent: gpui::transparent_black(),
|
border_transparent: gpui::transparent_black(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod import;
|
|||||||
pub mod join_group;
|
pub mod join_group;
|
||||||
pub mod new_chat;
|
pub mod new_chat;
|
||||||
pub mod new_group;
|
pub mod new_group;
|
||||||
|
pub mod profile;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
pub mod screening;
|
pub mod screening;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, ClipboardItem, Context, Div, IntoElement, ParentElement, Render, SharedString,
|
||||||
|
Styled, Task, Window, div, px,
|
||||||
|
};
|
||||||
|
use instant::Duration;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
|
/// Open a read-only profile for `public_key`.
|
||||||
|
pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) {
|
||||||
|
let profile = cx.new(|_| ProfileDialog::new(public_key));
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(360.)).title("Profile").child(profile.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProfileDialog {
|
||||||
|
public_key: PublicKey,
|
||||||
|
copied: bool,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileDialog {
|
||||||
|
fn new(public_key: PublicKey) -> Self {
|
||||||
|
Self {
|
||||||
|
public_key,
|
||||||
|
copied: false,
|
||||||
|
tasks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile(&self, cx: &App) -> Person {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
persons.read(cx).get(&self.public_key, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let item = ClipboardItem::new_string(value);
|
||||||
|
cx.write_to_clipboard(item);
|
||||||
|
|
||||||
|
self.set_copied(true, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.copied = status;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
if status {
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Reset the copied state after a delay
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_copied(false, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ProfileDialog {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let profile = self.profile(cx);
|
||||||
|
let metadata = profile.metadata();
|
||||||
|
|
||||||
|
// Get the public key as short string
|
||||||
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
|
let about = metadata
|
||||||
|
.about
|
||||||
|
.filter(|about| !about.trim().is_empty())
|
||||||
|
.map(SharedString::from);
|
||||||
|
|
||||||
|
let website = metadata
|
||||||
|
.website
|
||||||
|
.filter(|website| !website.trim().is_empty())
|
||||||
|
.map(SharedString::from);
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.h_40()
|
||||||
|
.w_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(
|
||||||
|
Avatar::new(profile.avatar())
|
||||||
|
.seed(profile.avatar_seed())
|
||||||
|
.large(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(label("Name", cx))
|
||||||
|
.child(div().text_sm().child(profile.name())),
|
||||||
|
)
|
||||||
|
.when_some(about, |this, about| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(label("About", cx))
|
||||||
|
.child(div().text_sm().child(about)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(website, |this, website| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(label("Website", cx))
|
||||||
|
.child(div().text_sm().child(website)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex().gap_1p5().child(label("Public Key", cx)).child(
|
||||||
|
h_flex()
|
||||||
|
.h_8()
|
||||||
|
.w_full()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().secondary_background)
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().secondary_foreground)
|
||||||
|
.child(shorten_pkey)
|
||||||
|
.child(
|
||||||
|
Button::new("copy")
|
||||||
|
.icon({
|
||||||
|
if self.copied {
|
||||||
|
IconName::CheckCircle
|
||||||
|
} else {
|
||||||
|
IconName::Copy
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.xsmall()
|
||||||
|
.secondary()
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.copy(this.public_key.to_bech32().unwrap(), window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(label: &'static str, cx: &App) -> Div {
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||||
};
|
};
|
||||||
|
use nip29::{GroupId, GroupKey};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -66,6 +67,7 @@ enum Command {
|
|||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
sidebar: Entity<Sidebar>,
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
@@ -80,6 +82,7 @@ impl Workspace {
|
|||||||
|
|
||||||
let (dock, _) = dock::dock_area("coop", window, cx);
|
let (dock, _) = dock::dock_area("coop", window, cx);
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
|
let left_sidebar = sidebar.clone();
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -187,7 +190,7 @@ impl Workspace {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let sidebar = PanelHandle::new(sidebar);
|
let sidebar = PanelHandle::new(left_sidebar);
|
||||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||||
|
|
||||||
this.dock.update(cx, |this, cx| {
|
this.dock.update(cx, |this, cx| {
|
||||||
@@ -201,8 +204,20 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen app-wide, so a group panel can toggle the sidebar without holding it.
|
||||||
|
let workspace = cx.entity().downgrade();
|
||||||
|
|
||||||
|
App::on_action(cx, move |action: &nip29_ui::ToggleGroupMeta, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
workspace.update(cx, |this, cx| this.toggle_group_meta(action, cx));
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
|
sidebar,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -382,6 +397,19 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show or hide the sidebar's information for the group a panel belongs to.
|
||||||
|
fn toggle_group_meta(&mut self, action: &nip29_ui::ToggleGroupMeta, cx: &mut Context<Self>) {
|
||||||
|
let Ok(id) = GroupId::new(action.group.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = GroupKey::new(action.relay.clone(), id);
|
||||||
|
|
||||||
|
self.sidebar.update(cx, |sidebar, cx| {
|
||||||
|
sidebar.toggle_group(&key, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||||
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use gpui::{
|
|||||||
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
||||||
px, retain_all, uniform_list,
|
px, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupsEvent, GroupsRegistry, Membership};
|
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -38,7 +38,7 @@ use ui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::Command;
|
use crate::Command;
|
||||||
use crate::dialogs::{import, screening};
|
use crate::dialogs::{import, profile, screening};
|
||||||
|
|
||||||
mod tab;
|
mod tab;
|
||||||
mod utils;
|
mod utils;
|
||||||
@@ -87,6 +87,8 @@ pub struct Sidebar {
|
|||||||
|
|
||||||
/// The group whose information the sidebar is browsing, if any.
|
/// The group whose information the sidebar is browsing, if any.
|
||||||
group: Option<WeakEntity<Group>>,
|
group: Option<WeakEntity<Group>>,
|
||||||
|
/// Whether the sidebar is showing the group's information instead of its tabs.
|
||||||
|
group_open: bool,
|
||||||
/// Scroll state of the group's member list.
|
/// Scroll state of the group's member list.
|
||||||
group_scroll: UniformListScrollHandle,
|
group_scroll: UniformListScrollHandle,
|
||||||
/// Whether the group view's admins section is expanded.
|
/// Whether the group view's admins section is expanded.
|
||||||
@@ -165,6 +167,7 @@ impl Sidebar {
|
|||||||
banner: pick_banner(),
|
banner: pick_banner(),
|
||||||
active_tab: SidebarTab::Inbox,
|
active_tab: SidebarTab::Inbox,
|
||||||
group: None,
|
group: None,
|
||||||
|
group_open: false,
|
||||||
group_scroll: UniformListScrollHandle::new(),
|
group_scroll: UniformListScrollHandle::new(),
|
||||||
admins_open: true,
|
admins_open: true,
|
||||||
members_open: true,
|
members_open: true,
|
||||||
@@ -183,14 +186,41 @@ impl Sidebar {
|
|||||||
/// Show the group view, replacing the tab list.
|
/// Show the group view, replacing the tab list.
|
||||||
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
|
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
|
||||||
self.group = Some(group.downgrade());
|
self.group = Some(group.downgrade());
|
||||||
|
self.group_open = true;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show or hide the group view depending on which group is being viewed.
|
||||||
|
pub(crate) fn toggle_group(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||||
|
let groups = GroupsRegistry::global(cx);
|
||||||
|
|
||||||
|
let Some(entity) = groups.read(cx).group(key, cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = self
|
||||||
|
.group
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|group| group.upgrade())
|
||||||
|
.is_some_and(|current| current.entity_id() == entity.entity_id());
|
||||||
|
|
||||||
|
if open {
|
||||||
|
self.group_open = !self.group_open;
|
||||||
|
} else {
|
||||||
|
self.group = Some(entity.downgrade());
|
||||||
|
self.group_open = true;
|
||||||
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leave the group view, returning the sidebar to its tab list.
|
/// Leave the group view, returning the sidebar to its tab list.
|
||||||
fn reset_group(&mut self, cx: &mut Context<Self>) {
|
fn hide_group(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.group.take().is_none() {
|
if !self.group_open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.group_open = false;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +460,7 @@ impl Sidebar {
|
|||||||
.when_some(AutoUpdater::try_global(cx), |this, updater| {
|
.when_some(AutoUpdater::try_global(cx), |this, updater| {
|
||||||
this.child(self.render_updater(updater, cx))
|
this.child(self.render_updater(updater, cx))
|
||||||
})
|
})
|
||||||
.when(self.group.is_some(), |this| {
|
.when(self.group_open, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("sidebar-back")
|
Button::new("sidebar-back")
|
||||||
.icon(IconName::ArrowLeft)
|
.icon(IconName::ArrowLeft)
|
||||||
@@ -438,7 +468,7 @@ impl Sidebar {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.reset_group(cx);
|
this.hide_group(cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -935,7 +965,9 @@ impl Sidebar {
|
|||||||
let metadata = group.metadata();
|
let metadata = group.metadata();
|
||||||
|
|
||||||
(
|
(
|
||||||
metadata.and_then(|metadata| metadata.banner.clone()),
|
metadata.and_then(|metadata| {
|
||||||
|
metadata.banner.clone().or_else(|| metadata.picture.clone())
|
||||||
|
}),
|
||||||
group.display_name(),
|
group.display_name(),
|
||||||
group.key().cache_tag(),
|
group.key().cache_tag(),
|
||||||
group.display_image(),
|
group.display_image(),
|
||||||
@@ -950,6 +982,18 @@ impl Sidebar {
|
|||||||
.min_h_0()
|
.min_h_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
.when_some(banner, |this, banner| {
|
||||||
|
this.child(
|
||||||
|
div().px_2().flex_shrink_0().child(
|
||||||
|
div()
|
||||||
|
.h_40()
|
||||||
|
.w_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.child(img(banner).size_full().object_fit(ObjectFit::Cover)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.px_2()
|
.px_2()
|
||||||
@@ -966,17 +1010,6 @@ impl Sidebar {
|
|||||||
.child(name),
|
.child(name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when_some(banner, |this, banner| {
|
|
||||||
this.child(
|
|
||||||
div().px_2().flex_shrink_0().child(
|
|
||||||
img(banner)
|
|
||||||
.w_full()
|
|
||||||
.h_20()
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.object_fit(ObjectFit::Cover),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
@@ -1073,6 +1106,11 @@ impl Sidebar {
|
|||||||
.child(SharedString::from(roles.join(", "))),
|
.child(SharedString::from(roles.join(", "))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.on_click({
|
||||||
|
let public_key = *public_key;
|
||||||
|
|
||||||
|
move |_event, window, cx| profile::open(public_key, window, cx)
|
||||||
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1175,8 +1213,8 @@ impl Render for Sidebar {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.map(|this| match group {
|
.map(|this| match group {
|
||||||
Some(group) => this.child(self.render_group(group, cx)),
|
Some(group) if self.group_open => this.child(self.render_group(group, cx)),
|
||||||
None => this.child(self.render_tabs(cx)),
|
_ => this.child(self.render_tabs(cx)),
|
||||||
})
|
})
|
||||||
.when(loading && logged_in, |this| {
|
.when(loading && logged_in, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
|
|||||||
+11
-5
@@ -591,11 +591,17 @@ before they reach the consumer, so a replay cannot double-hydrate.
|
|||||||
from a result. The panel has a read-only details dialog, a header `Manage` menu
|
from a result. The panel has a read-only details dialog, a header `Manage` menu
|
||||||
(edit metadata, invite link, members and roles) shown to admins, a message menu
|
(edit metadata, invite link, members and roles) shown to admins, a message menu
|
||||||
with reactions and admin pin/delete, a pins strip, a fork banner, and leave.
|
with reactions and admin pin/delete, a pins strip, a fork banner, and leave.
|
||||||
Opening a group also fills the sidebar with the group itself — avatar, banner
|
Opening a group also fills the sidebar with the group itself — banner (the
|
||||||
and the collapsible `Admins`/`Members` lists — with a back button in the title
|
`39000` `banner` tag, falling back to its `picture`), avatar and name, and the
|
||||||
bar, the same shape the sidebar used for a community before the migration.
|
collapsible `Admins`/`Members` lists. Members are clickable and open a profile
|
||||||
What a given account may actually do is the relay's call; the client shows the
|
dialog. The panel header shows the group's `about` instead of its name, plus a
|
||||||
refusal verbatim.
|
button that shows or hides the sidebar's group information. What a given account
|
||||||
|
may actually do is the relay's call; the client shows the refusal verbatim.
|
||||||
|
- **The composer uploads to the configured blossom server.** `GroupPanel`'s
|
||||||
|
paperclip uploads one file at a time, unencrypted, and appends its URL to the
|
||||||
|
message on send — the same plain-attachment path the chat composer uses when a
|
||||||
|
message is already being typed. The server comes from
|
||||||
|
`AppSettings::get_file_server`; the upload itself is `state::upload`.
|
||||||
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
- **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.
|
standing REQ, so only our own accepted deletes evict the row.
|
||||||
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
||||||
|
|||||||
Reference in New Issue
Block a user