@@ -12,8 +12,8 @@ state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
community = { path = "../community" }
|
||||
community_ui = { path = "../community_ui" }
|
||||
nip29 = { path = "../nip29" }
|
||||
nip29_ui = { path = "../nip29_ui" }
|
||||
settings = { path = "../settings" }
|
||||
person = { path = "../person" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use gpui::{App, AppContext, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupReference, GroupsRegistry};
|
||||
use ui::WindowExtension;
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…"));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
let input = input.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
.confirm()
|
||||
.title("Join Group")
|
||||
.child(Input::new(&input).cleanable(true).flex_1().text_sm())
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
|
||||
let reference = match GroupReference::parse(input.read(cx).value().trim()) {
|
||||
Ok(reference) => reference,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
groups.update(cx, |registry, cx| {
|
||||
registry.join(reference, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod import;
|
||||
pub mod join_group;
|
||||
pub mod new_chat;
|
||||
pub mod new_community;
|
||||
pub mod new_group;
|
||||
pub mod profile;
|
||||
pub mod restore;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
use community::{CommunityMetadata, CommunityRegistry};
|
||||
use gpui::{App, AppContext, ParentElement, Window, px};
|
||||
use ui::WindowExtension;
|
||||
use ui::input::{Input, InputState};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name"));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
let name_input = name_input.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
.confirm()
|
||||
.title("New community")
|
||||
.child(Input::new(&name_input))
|
||||
.on_ok(move |_event, _window, cx| {
|
||||
let community = CommunityRegistry::global(cx);
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let metadata = CommunityMetadata {
|
||||
name,
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
community.update(cx, |registry, cx| {
|
||||
registry.create(metadata, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use gpui::{App, AppContext, Div, Entity, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name"));
|
||||
let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id"));
|
||||
let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let name = name.clone();
|
||||
let id = id.clone();
|
||||
let relay = relay.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
.confirm()
|
||||
.title("New group")
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("Group id", &id, cx))
|
||||
.child(field("Relay", &relay, cx)),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| create(&name, &id, &relay, window, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
}
|
||||
|
||||
fn create(
|
||||
name: &Entity<InputState>,
|
||||
id: &Entity<InputState>,
|
||||
relay: &Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let name_value = name.read(cx).value().trim().to_owned();
|
||||
let raw_id = id.read(cx).value().trim().to_owned();
|
||||
let raw_relay = relay.read(cx).value().trim().to_owned();
|
||||
|
||||
if name_value.is_empty() {
|
||||
window.push_notification(Notification::error("Enter a group name"), cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
let id = if raw_id.is_empty() {
|
||||
slug(&name_value)
|
||||
} else {
|
||||
raw_id
|
||||
};
|
||||
|
||||
let id = match GroupId::new(id) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let relay = match RelayUrl::parse(&raw_relay) {
|
||||
Ok(relay) => relay,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let mut metadata = GroupMetadata::default();
|
||||
metadata.name = Some(name_value);
|
||||
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.create(GroupKey::new(relay, id), metadata, cx);
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
let mut slug = String::with_capacity(name.len());
|
||||
let mut separator = false;
|
||||
|
||||
for character in name.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
slug.push(character.to_ascii_lowercase());
|
||||
separator = false;
|
||||
} else if !separator && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
separator = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_end_matches('-').to_owned()
|
||||
}
|
||||
@@ -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,
|
||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nip29::{GroupId, GroupKey};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -21,7 +22,7 @@ use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::{new_chat, new_community, settings};
|
||||
use crate::dialogs::{join_group, new_chat, new_group, settings};
|
||||
use crate::panels::{
|
||||
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
|
||||
search,
|
||||
@@ -60,11 +61,13 @@ enum Command {
|
||||
ShowBrowse,
|
||||
ShowSearch,
|
||||
NewChat,
|
||||
NewCommunity,
|
||||
NewGroup,
|
||||
JoinGroup,
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
sidebar: Entity<Sidebar>,
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Event subscriptions
|
||||
@@ -79,6 +82,7 @@ impl Workspace {
|
||||
|
||||
let (dock, _) = dock::dock_area("coop", window, cx);
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||
let left_sidebar = sidebar.clone();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -186,7 +190,7 @@ impl Workspace {
|
||||
);
|
||||
|
||||
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));
|
||||
|
||||
this.dock.update(cx, |this, cx| {
|
||||
@@ -200,8 +204,32 @@ impl Workspace {
|
||||
});
|
||||
});
|
||||
|
||||
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
|
||||
let workspace = cx.entity().downgrade();
|
||||
|
||||
App::on_action(cx, {
|
||||
let workspace = workspace.clone();
|
||||
|
||||
move |action: &nip29_ui::ShowGroupMeta, cx| {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
|
||||
}
|
||||
});
|
||||
|
||||
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
sidebar,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -282,7 +310,8 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
Command::ShowBrowse => {
|
||||
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
|
||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::ShowSearch => {
|
||||
let panel = search::init(self.dock.downgrade(), window, cx);
|
||||
@@ -291,8 +320,11 @@ impl Workspace {
|
||||
Command::NewChat => {
|
||||
new_chat::open(self.dock.downgrade(), window, cx);
|
||||
}
|
||||
Command::NewCommunity => {
|
||||
new_community::open(window, cx);
|
||||
Command::NewGroup => {
|
||||
new_group::open(window, cx);
|
||||
}
|
||||
Command::JoinGroup => {
|
||||
join_group::open(window, cx);
|
||||
}
|
||||
Command::ShowBackup => {
|
||||
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
|
||||
@@ -377,6 +409,32 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the sidebar's information for the group a panel belongs to.
|
||||
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, 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.show_group_meta(&key, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Hide the sidebar's information for the group a panel belongs to.
|
||||
fn hide_group_meta(&mut self, action: &nip29_ui::HideGroupMeta, 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.hide_group_meta(&key, cx);
|
||||
});
|
||||
}
|
||||
|
||||
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. \
|
||||
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
||||
|
||||
@@ -1,32 +1,249 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
||||
div, uniform_list,
|
||||
};
|
||||
use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
|
||||
use nostr_sdk::prelude::RelayUrl;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||
cx.new(|cx| BrowsePanel::new(window, cx))
|
||||
const TITLE: &str = "Browse";
|
||||
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
|
||||
|
||||
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||
cx.new(|cx| BrowsePanel::new(dock, window, cx))
|
||||
}
|
||||
|
||||
pub struct BrowsePanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
dock: WeakEntity<DockArea>,
|
||||
/// The relay input
|
||||
input: Entity<InputState>,
|
||||
/// The relay currently being browsed.
|
||||
relay: Option<RelayUrl>,
|
||||
/// The groups the relay has announced so far
|
||||
candidates: Vec<GroupCandidate>,
|
||||
/// Whether the relay is still answering
|
||||
loading: bool,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl BrowsePanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER));
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.browse(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe(®istry, |this, _registry, event, cx| match event {
|
||||
GroupsEvent::Browsed(candidate) => {
|
||||
this.push_candidate(candidate.as_ref().clone(), cx)
|
||||
}
|
||||
GroupsEvent::BrowseSettled(relay) => {
|
||||
this.settle(relay.clone(), cx);
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Browse".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock,
|
||||
input,
|
||||
relay: None,
|
||||
candidates: Vec::new(),
|
||||
loading: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse the relay in the input.
|
||||
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let value = self.input.read(cx).value().trim().to_owned();
|
||||
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let relay = match RelayUrl::parse(&value) {
|
||||
Ok(relay) => relay,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.relay = Some(relay.clone());
|
||||
self.candidates.clear();
|
||||
self.loading = true;
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.browse(relay, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn push_candidate(&mut self, candidate: GroupCandidate, cx: &mut Context<Self>) {
|
||||
if self.relay.as_ref() != Some(candidate.key.relay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.candidates.iter_mut().find(|e| e.key == candidate.key) {
|
||||
Some(existing) => *existing = candidate,
|
||||
None => self.candidates.push(candidate),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The relay stopped answering: EOSE, a refusal, or the auto-close timeout.
|
||||
fn settle(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||
if self.relay.as_ref() != Some(&relay) {
|
||||
return;
|
||||
}
|
||||
self.loading = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Join a browsed group and open it.
|
||||
fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||
let dock = self.dock.clone();
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.join_key(key.clone(), None, cx);
|
||||
});
|
||||
|
||||
let Some(group) = registry.read(cx).group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
ui::dock::add_panel_to(
|
||||
&dock,
|
||||
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_rows(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let panel = cx.entity().downgrade();
|
||||
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let joined: Vec<GroupKey> = registry
|
||||
.read(cx)
|
||||
.groups()
|
||||
.iter()
|
||||
.map(|group| group.read(cx).key().clone())
|
||||
.collect();
|
||||
|
||||
self.candidates
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|candidate| {
|
||||
let key = candidate.key.clone();
|
||||
let name = SharedString::from(candidate.display_name().to_owned());
|
||||
let picture = candidate
|
||||
.display_image()
|
||||
.map(|picture| SharedString::from(picture.to_owned()));
|
||||
let seed = key.cache_tag();
|
||||
let is_joined = joined.contains(&key);
|
||||
|
||||
let action = if is_joined {
|
||||
Button::new(format!("browse-joined-{seed}"))
|
||||
.label("Joined")
|
||||
.small()
|
||||
.disabled(true)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let panel = panel.clone();
|
||||
let clicked = key.clone();
|
||||
|
||||
Button::new(format!("browse-join-{seed}"))
|
||||
.label("Join")
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.join(clicked.clone(), window, cx);
|
||||
}) {
|
||||
log::error!("Failed to join a browsed group: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.px_1p5()
|
||||
.py_1()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::from_source(picture)
|
||||
.seed(seed)
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(div().text_sm().font_medium().truncate().child(name))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.truncate()
|
||||
.child(SharedString::from(key.id().as_str().to_owned())),
|
||||
),
|
||||
)
|
||||
.child(action)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn notice(text: &'static str, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl Panel for BrowsePanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
TITLE.into()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
@@ -37,7 +254,7 @@ impl Panel for BrowsePanel {
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.child(TITLE)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -52,11 +269,44 @@ impl Focusable for BrowsePanel {
|
||||
|
||||
impl Render for BrowsePanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
let count = self.candidates.len();
|
||||
|
||||
let body = if self.relay.is_none() {
|
||||
notice("Enter a relay to browse its groups.", cx)
|
||||
} else if count > 0 {
|
||||
uniform_list(
|
||||
"browse-groups",
|
||||
count,
|
||||
cx.processor(|this, range, _window, cx| this.render_rows(range, cx)),
|
||||
)
|
||||
.h_full()
|
||||
.into_any_element()
|
||||
} else if self.loading {
|
||||
notice("Loading groups…", cx)
|
||||
} else {
|
||||
notice("No groups found.", cx)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.flex_shrink_0()
|
||||
.child(Input::new(&self.input).cleanable(true).flex_1())
|
||||
.child(
|
||||
Button::new("browse-go")
|
||||
.label("Go")
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.browse(window, cx);
|
||||
})),
|
||||
)
|
||||
.when(self.loading, |this| {
|
||||
this.child(Indicator::new().small().color(cx.theme().icon_accent))
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
+365
-263
@@ -6,14 +6,14 @@ use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
use common::TimestampExt;
|
||||
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
|
||||
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
||||
px, retain_all, uniform_list,
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString,
|
||||
Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
|
||||
div, img, px, relative, retain_all, uniform_list,
|
||||
};
|
||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
@@ -38,70 +38,79 @@ use ui::{
|
||||
};
|
||||
|
||||
use crate::Command;
|
||||
use crate::dialogs::{import, screening};
|
||||
use crate::dialogs::{import, profile, screening};
|
||||
|
||||
mod tab;
|
||||
mod utils;
|
||||
|
||||
use tab::SidebarTab;
|
||||
pub(crate) use utils::{nav_icon, pick_banner};
|
||||
pub(crate) use utils::pick_banner;
|
||||
|
||||
pub enum SidebarRow {
|
||||
Room { room: Entity<Room> },
|
||||
Community { community: Entity<Community> },
|
||||
Group { group: Entity<Group> },
|
||||
Contact { public_key: PublicKey },
|
||||
}
|
||||
|
||||
/// A collapsible group of rows in the sidebar's community view.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommunitySection {
|
||||
Channels,
|
||||
/// A collapsible section of the group view that replaces the tab list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GroupSection {
|
||||
Admins,
|
||||
Members,
|
||||
}
|
||||
|
||||
/// A row in the sidebar's community view.
|
||||
pub enum CommunityRow {
|
||||
Section(CommunitySection),
|
||||
Channel {
|
||||
id: ChannelId,
|
||||
name: SharedString,
|
||||
private: bool,
|
||||
selected: bool,
|
||||
},
|
||||
impl GroupSection {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Admins => "Admins",
|
||||
Self::Members => "Members",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row in the group view.
|
||||
enum GroupRow {
|
||||
Section(GroupSection),
|
||||
Member {
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handles: [UniformListScrollHandle; 2],
|
||||
/// Scroll state of the community's channel and member lists
|
||||
community_scroll: UniformListScrollHandle,
|
||||
/// The dock the sidebar opens its panels in
|
||||
dock: WeakEntity<DockArea>,
|
||||
/// Background shown behind the signed-out screen, picked at random
|
||||
/// Random background shown behind the signed-out screen.
|
||||
banner: SharedString,
|
||||
active_tab: SidebarTab,
|
||||
/// The community the sidebar is browsing, if any
|
||||
community: Option<WeakEntity<Community>>,
|
||||
/// Whether the inbox list is showing contacts to start a chat with
|
||||
contacts_open: bool,
|
||||
/// The signed-in user's contacts, loaded when the contact picker opens
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Contacts selected in the contact picker
|
||||
selected_contacts: HashSet<PublicKey>,
|
||||
/// Scroll state of the contact picker's list
|
||||
contacts_scroll: UniformListScrollHandle,
|
||||
/// Whether the inbox list is showing chat requests instead of rooms
|
||||
requests_open: bool,
|
||||
/// Scroll state of the requests list
|
||||
requests_scroll: UniformListScrollHandle,
|
||||
channels_open: bool,
|
||||
|
||||
/// The group whose information the sidebar is browsing, if any.
|
||||
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.
|
||||
group_scroll: UniformListScrollHandle,
|
||||
/// Whether the group view's admins section is expanded.
|
||||
admins_open: bool,
|
||||
/// Whether the group view's members section is expanded.
|
||||
members_open: bool,
|
||||
|
||||
/// Whether the inbox list is showing contacts to start a chat with.
|
||||
contacts_open: bool,
|
||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Contacts selected in the contact picker.
|
||||
selected_contacts: HashSet<PublicKey>,
|
||||
/// Scroll state of the contact picker's list.
|
||||
contacts_scroll: UniformListScrollHandle,
|
||||
|
||||
/// Whether the inbox list is showing chat requests instead of rooms.
|
||||
requests_open: bool,
|
||||
/// Scroll state of the requests list.
|
||||
requests_scroll: UniformListScrollHandle,
|
||||
new_requests: bool,
|
||||
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
@@ -109,7 +118,7 @@ pub struct Sidebar {
|
||||
impl Sidebar {
|
||||
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let communities = CommunityRegistry::global(cx);
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -123,23 +132,23 @@ impl Sidebar {
|
||||
}),
|
||||
);
|
||||
|
||||
// Keep the room and request counts in sync with the chat registry.
|
||||
subscriptions.push(cx.observe(&chat, |_this, _chat, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&communities,
|
||||
window,
|
||||
|_this, _communities, event, window, cx| {
|
||||
if let CommunityEvent::Error(error) = event {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||
match event {
|
||||
GroupsEvent::Updated => {
|
||||
// TODO: find a better way?
|
||||
cx.notify();
|
||||
}
|
||||
GroupsEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
|
||||
cx.subscribe_in(&nostr, window, |this, _, event, _window, cx| {
|
||||
// Re-pick the background each time the signed-out screen is shown.
|
||||
if let StateEvent::NoSigner = event {
|
||||
this.banner = pick_banner();
|
||||
@@ -154,26 +163,67 @@ impl Sidebar {
|
||||
UniformListScrollHandle::new(),
|
||||
UniformListScrollHandle::new(),
|
||||
],
|
||||
community_scroll: UniformListScrollHandle::new(),
|
||||
dock,
|
||||
banner: pick_banner(),
|
||||
active_tab: SidebarTab::Inbox,
|
||||
community: None,
|
||||
group: None,
|
||||
group_open: false,
|
||||
group_scroll: UniformListScrollHandle::new(),
|
||||
admins_open: true,
|
||||
members_open: true,
|
||||
contacts_open: false,
|
||||
contacts: None,
|
||||
selected_contacts: HashSet::new(),
|
||||
contacts_scroll: UniformListScrollHandle::new(),
|
||||
requests_open: false,
|
||||
requests_scroll: UniformListScrollHandle::new(),
|
||||
channels_open: true,
|
||||
admins_open: true,
|
||||
members_open: true,
|
||||
new_requests: false,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the group view, replacing the tab list.
|
||||
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
|
||||
self.group = Some(group.downgrade());
|
||||
self.group_open = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Show the group view without toggling it closed.
|
||||
pub(crate) fn show_group_meta(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
|
||||
let Some(group) = groups.read(cx).group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.show_group(&group, cx);
|
||||
}
|
||||
|
||||
/// Hide the group view if the sidebar is showing the given group.
|
||||
pub(crate) fn hide_group_meta(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let showing = self
|
||||
.group
|
||||
.as_ref()
|
||||
.and_then(|group| group.upgrade())
|
||||
.is_some_and(|group| group.read(cx).key() == key);
|
||||
|
||||
if showing {
|
||||
self.hide_group(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the group view, returning the sidebar to its tab list.
|
||||
fn hide_group(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.group_open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.group_open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
|
||||
if self.active_tab == tab {
|
||||
return;
|
||||
@@ -328,14 +378,6 @@ impl Sidebar {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Leave the community view, returning the sidebar to its tab list.
|
||||
fn reset_community(&mut self, cx: &mut Context<Self>) {
|
||||
if self.community.take().is_none() {
|
||||
return;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(current_user, cx);
|
||||
@@ -418,7 +460,7 @@ impl Sidebar {
|
||||
.when_some(AutoUpdater::try_global(cx), |this, updater| {
|
||||
this.child(self.render_updater(updater, cx))
|
||||
})
|
||||
.when(self.community.is_some(), |this| {
|
||||
.when(self.group_open, |this| {
|
||||
this.child(
|
||||
Button::new("sidebar-back")
|
||||
.icon(IconName::ArrowLeft)
|
||||
@@ -426,7 +468,7 @@ impl Sidebar {
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.reset_community(cx);
|
||||
this.hide_group(cx);
|
||||
})),
|
||||
)
|
||||
})
|
||||
@@ -504,7 +546,7 @@ impl Sidebar {
|
||||
.segmented(true)
|
||||
.selected_index(active_tab.index())
|
||||
.child(Tab::new().label(SidebarTab::Inbox.label()))
|
||||
.child(Tab::new().label(SidebarTab::Communities.label()))
|
||||
.child(Tab::new().label(SidebarTab::Groups.label()))
|
||||
.on_click({
|
||||
let sidebar = sidebar.clone();
|
||||
move |index, _window, cx| {
|
||||
@@ -580,18 +622,28 @@ impl Sidebar {
|
||||
})),
|
||||
),
|
||||
),
|
||||
SidebarTab::Communities => this.child(
|
||||
SidebarTab::Groups => this.child(
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
NavItem::new(
|
||||
"new-community",
|
||||
"New Community",
|
||||
"new-group",
|
||||
"New Group",
|
||||
Icon::new(IconName::Group).small(),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::NewCommunity), cx)
|
||||
window.dispatch_action(Box::new(Command::NewGroup), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
NavItem::new(
|
||||
"join-group",
|
||||
"Join Group",
|
||||
Icon::new(IconName::Door).small(),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::JoinGroup), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -668,127 +720,6 @@ impl Sidebar {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_community(
|
||||
&mut self,
|
||||
community: Entity<Community>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let (banner, rows) = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let active = community.active_channel();
|
||||
let banner = community.banner();
|
||||
|
||||
let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)];
|
||||
if self.channels_open {
|
||||
rows.extend(
|
||||
community
|
||||
.channels()
|
||||
.iter()
|
||||
.map(|channel| CommunityRow::Channel {
|
||||
id: channel.id,
|
||||
name: channel.name.clone().into(),
|
||||
private: channel.private,
|
||||
selected: active == Some(channel.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(CommunityRow::Section(CommunitySection::Admins));
|
||||
if self.admins_open {
|
||||
rows.extend(
|
||||
admins
|
||||
.into_iter()
|
||||
.map(|public_key| CommunityRow::Member { public_key }),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(CommunityRow::Section(CommunitySection::Members));
|
||||
if self.members_open {
|
||||
rows.extend(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|public_key| CommunityRow::Member { public_key }),
|
||||
);
|
||||
}
|
||||
|
||||
(banner, rows)
|
||||
};
|
||||
|
||||
let rows = Rc::new(rows);
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.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(
|
||||
div()
|
||||
.min_h_0()
|
||||
.flex_1()
|
||||
.child(
|
||||
uniform_list(
|
||||
"community-rows",
|
||||
rows.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_community_rows(range, rows.as_slice(), &community, cx)
|
||||
}),
|
||||
)
|
||||
.track_scroll(&self.community_scroll)
|
||||
.h_full()
|
||||
.px_2(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&self.community_scroll)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_community_rows(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
rows: &[CommunityRow],
|
||||
community: &Entity<Community>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
rows.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|row| match row {
|
||||
CommunityRow::Section(section) => self.section_row(section, cx),
|
||||
CommunityRow::Member { public_key } => self.member_row(public_key, cx),
|
||||
CommunityRow::Channel {
|
||||
id,
|
||||
name,
|
||||
private,
|
||||
selected,
|
||||
} => self.channel_row(*id, name.clone(), *private, *selected, community, cx),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
|
||||
match tab {
|
||||
SidebarTab::Inbox if self.contacts_open => self
|
||||
@@ -814,14 +745,14 @@ impl Sidebar {
|
||||
.map(|room| SidebarRow::Room { room })
|
||||
.collect()
|
||||
}
|
||||
SidebarTab::Communities => {
|
||||
let registry = CommunityRegistry::global(cx);
|
||||
SidebarTab::Groups => {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
registry
|
||||
.read(cx)
|
||||
.communities()
|
||||
.groups()
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|community| SidebarRow::Community { community })
|
||||
.map(|group| SidebarRow::Group { group })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -929,17 +860,28 @@ impl Sidebar {
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::Community { community } => {
|
||||
let name = community.read(cx).name();
|
||||
let seed = community.read(cx).id().to_hex();
|
||||
let picture = community.read(cx).icon();
|
||||
SidebarRow::Group { group } => {
|
||||
let dock = self.dock.clone();
|
||||
let sidebar = cx.entity().downgrade();
|
||||
let community = community.clone();
|
||||
let group = group.clone();
|
||||
|
||||
let name = group.read(cx).display_name();
|
||||
let picture = group.read(cx).display_image();
|
||||
let seed = group.read(cx).key().cache_tag();
|
||||
|
||||
let suffix = match group.read(cx).membership() {
|
||||
Membership::Pending { .. } => Some("Requested"),
|
||||
Membership::Refused { .. } => Some("Refused"),
|
||||
Membership::Removed => Some("Removed"),
|
||||
Membership::Unknown | Membership::Member => None,
|
||||
}
|
||||
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
|
||||
.map(SharedString::from);
|
||||
|
||||
Nav::new(index)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(picture)
|
||||
@@ -948,24 +890,28 @@ impl Sidebar {
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.when_some(suffix, |this, suffix| {
|
||||
this.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(suffix),
|
||||
)
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
ui::dock::add_panel_to(
|
||||
&dock,
|
||||
PanelHandle::new(community_ui::init(
|
||||
community.clone(),
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
if let Err(error) = sidebar.update(cx, |this, cx| {
|
||||
this.community = Some(community.downgrade());
|
||||
cx.notify();
|
||||
this.show_group(&group, cx);
|
||||
}) {
|
||||
log::error!("Failed to show community in sidebar: {error}");
|
||||
log::error!("Failed to show the group in the sidebar: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
@@ -975,84 +921,243 @@ impl Sidebar {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section_row(&self, section: &CommunitySection, cx: &mut Context<Sidebar>) -> AnyElement {
|
||||
let section = *section;
|
||||
let (label, open) = match section {
|
||||
CommunitySection::Channels => ("Channels", self.channels_open),
|
||||
CommunitySection::Admins => ("Admins", self.admins_open),
|
||||
CommunitySection::Members => ("Members", self.members_open),
|
||||
fn render_group(&mut self, group: Entity<Group>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let admin = NostrRegistry::global(cx)
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.is_some_and(|me| group.read(cx).admins().contains(&me));
|
||||
|
||||
let (name, seed, picture, total_members, rows) = {
|
||||
let group = group.read(cx);
|
||||
let admins = group.admins();
|
||||
|
||||
let mut admin_rows = Vec::new();
|
||||
let mut member_rows = Vec::new();
|
||||
|
||||
for (public_key, roles) in admins.entries() {
|
||||
admin_rows.push(GroupRow::Member {
|
||||
public_key: *public_key,
|
||||
roles: roles.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
for public_key in group.members().iter() {
|
||||
if admins.contains(public_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
member_rows.push(GroupRow::Member {
|
||||
public_key: *public_key,
|
||||
roles: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
|
||||
if !admin_rows.is_empty() {
|
||||
rows.push(GroupRow::Section(GroupSection::Admins));
|
||||
if self.admins_open {
|
||||
rows.append(&mut admin_rows);
|
||||
}
|
||||
}
|
||||
|
||||
if !member_rows.is_empty() {
|
||||
rows.push(GroupRow::Section(GroupSection::Members));
|
||||
if self.members_open {
|
||||
rows.append(&mut member_rows);
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
group.display_name(),
|
||||
group.key().cache_tag(),
|
||||
group.display_image(),
|
||||
SharedString::from(format!("{} members", group.total_members())),
|
||||
rows,
|
||||
)
|
||||
};
|
||||
|
||||
let rows = Rc::new(rows);
|
||||
let member_group = group.downgrade();
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.pl_0p5()
|
||||
.gap_1()
|
||||
.flex_shrink_1()
|
||||
.child(Avatar::from_source(picture).seed(seed).small())
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.truncate()
|
||||
.line_height(relative(1.2))
|
||||
.font_semibold()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(total_members),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(nip29_ui::actions(group.downgrade(), cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_h_0()
|
||||
.flex_1()
|
||||
.child(
|
||||
uniform_list(
|
||||
"group-rows",
|
||||
rows.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_group_rows(
|
||||
range,
|
||||
rows.as_slice(),
|
||||
&member_group,
|
||||
admin,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.track_scroll(&self.group_scroll)
|
||||
.h_full()
|
||||
.px_2(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&self.group_scroll)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_group_rows(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
rows: &[GroupRow],
|
||||
group: &WeakEntity<Group>,
|
||||
admin: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
rows.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|row| match row {
|
||||
GroupRow::Section(section) => self.section_row(*section, cx),
|
||||
GroupRow::Member { public_key, roles } => {
|
||||
self.member_row(public_key, roles, group, admin, cx)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section_row(&self, section: GroupSection, cx: &mut Context<Self>) -> AnyElement {
|
||||
let open = match section {
|
||||
GroupSection::Admins => self.admins_open,
|
||||
GroupSection::Members => self.members_open,
|
||||
};
|
||||
let icon = if open {
|
||||
IconName::CaretDown
|
||||
} else {
|
||||
IconName::CaretRight
|
||||
};
|
||||
let label = section.label();
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.suffix(nav_icon(icon, cx))
|
||||
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
match section {
|
||||
CommunitySection::Channels => this.channels_open = !this.channels_open,
|
||||
CommunitySection::Admins => this.admins_open = !this.admins_open,
|
||||
CommunitySection::Members => this.members_open = !this.members_open,
|
||||
GroupSection::Admins => this.admins_open = !this.admins_open,
|
||||
GroupSection::Members => this.members_open = !this.members_open,
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn channel_row(
|
||||
fn member_row(
|
||||
&self,
|
||||
id: ChannelId,
|
||||
name: SharedString,
|
||||
private: bool,
|
||||
selected: bool,
|
||||
community: &Entity<Community>,
|
||||
cx: &mut Context<Sidebar>,
|
||||
public_key: &PublicKey,
|
||||
roles: &[String],
|
||||
group: &WeakEntity<Group>,
|
||||
admin: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let community = community.clone();
|
||||
let icon = if private {
|
||||
IconName::Lock
|
||||
} else {
|
||||
IconName::Hashtag
|
||||
};
|
||||
|
||||
Nav::new(id.to_hex())
|
||||
.label(name)
|
||||
.prefix(nav_icon(icon, cx))
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.selected(selected)
|
||||
.on_click(cx.listener(move |_this, _event, _window, cx| {
|
||||
community.update(cx, |community, cx| {
|
||||
community.set_active_channel(id, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let person = persons.read(cx).get(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
|
||||
let group = group.clone();
|
||||
|
||||
Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.clickable(true)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(person.avatar())
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.when(!roles.is_empty(), |this| {
|
||||
this.suffix(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(roles.join(", "))),
|
||||
)
|
||||
})
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
let menu = menu
|
||||
.item(
|
||||
PopupMenuItem::new("View profile").on_click(move |_, window, cx| {
|
||||
profile::open(public_key, window, cx);
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| {
|
||||
let npub = public_key.to_bech32().unwrap_or_default();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(npub));
|
||||
}));
|
||||
|
||||
if !admin {
|
||||
return menu;
|
||||
}
|
||||
|
||||
menu.separator()
|
||||
.item(PopupMenuItem::new("Remove").on_click({
|
||||
let group = group.clone();
|
||||
move |_event, window, cx| {
|
||||
nip29_ui::remove_member(group.clone(), public_key, window, cx);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1092,10 +1197,7 @@ impl Render for Sidebar {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let loading = chat.read(cx).loading();
|
||||
|
||||
let community = self
|
||||
.community
|
||||
.clone()
|
||||
.and_then(|community| community.upgrade());
|
||||
let group = self.group.clone().and_then(|group| group.upgrade());
|
||||
|
||||
v_flex()
|
||||
.image_cache(retain_all("sidebar"))
|
||||
@@ -1157,9 +1259,9 @@ impl Render for Sidebar {
|
||||
),
|
||||
)
|
||||
})
|
||||
.map(|this| match community {
|
||||
Some(community) => this.child(self.render_community(community, cx)),
|
||||
None => this.child(self.render_tabs(cx)),
|
||||
.map(|this| match group {
|
||||
Some(group) if self.group_open => this.child(self.render_group(group, cx)),
|
||||
_ => this.child(self.render_tabs(cx)),
|
||||
})
|
||||
.when(loading && logged_in, |this| {
|
||||
this.child(
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SidebarTab {
|
||||
Inbox,
|
||||
Communities,
|
||||
Groups,
|
||||
}
|
||||
|
||||
impl SidebarTab {
|
||||
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities];
|
||||
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Groups];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbox => "Inbox",
|
||||
Self::Communities => "Communities",
|
||||
Self::Groups => "Groups",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,21 @@ impl SidebarTab {
|
||||
pub fn list_title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbox => "Direct Messages",
|
||||
Self::Communities => "Communities",
|
||||
Self::Groups => "Groups",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbox => "sidebar-inbox",
|
||||
Self::Communities => "sidebar-communities",
|
||||
Self::Groups => "sidebar-groups",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Inbox => 0,
|
||||
Self::Communities => 1,
|
||||
Self::Groups => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w(px(20.))
|
||||
.justify_center()
|
||||
.text_color(cx.theme().icon_muted)
|
||||
.child(Icon::new(icon).small())
|
||||
.into_any_element()
|
||||
}
|
||||
use gpui::SharedString;
|
||||
|
||||
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
|
||||
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
|
||||
|
||||
Reference in New Issue
Block a user