This commit is contained in:
2026-09-25 09:12:35 +07:00
parent ce9147c09c
commit 2315a6ffdf
13 changed files with 476 additions and 454 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Onboarding")
.title("Get Started")
.child(import.clone())
});
}
+22 -13
View File
@@ -19,10 +19,11 @@ use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::input::{Input, InputEvent, InputState};
use ui::nav::Nav;
use ui::notification::Notification;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use crate::sidebar::{TreeRow, TreeRowKind};
use crate::sidebar::nav_avatar;
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
@@ -357,13 +358,17 @@ impl SearchPanel {
this.select(&pkey_clone, cx);
});
TreeRow::new(
ElementId::NamedInteger("search-result".into(), (range.start + ix) as u64),
TreeRowKind::Room,
profile.name(),
Nav::new(ElementId::NamedInteger(
"search-result".into(),
(range.start + ix) as u64,
))
.label(profile.name())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|this, avatar| this.prefix(avatar),
)
.avatar(profile.avatar_seed())
.picture(profile.avatar())
.on_click(handler)
.selected(selected)
.into_any_element()
@@ -398,13 +403,17 @@ impl SearchPanel {
this.select(&pkey_clone, cx);
});
TreeRow::new(
ElementId::NamedInteger("contact".into(), (range.start + ix) as u64),
TreeRowKind::Room,
profile.name().trim(),
Nav::new(ElementId::NamedInteger(
"contact".into(),
(range.start + ix) as u64,
))
.label(profile.name().trim())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|this, avatar| this.prefix(avatar),
)
.avatar(profile.avatar_seed())
.picture(profile.avatar())
.on_click(handler)
.selected(selected)
.into_any_element()
+187 -161
View File
@@ -2,12 +2,12 @@ use std::ops::Range;
use std::rc::Rc;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, RoomKind};
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt;
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px,
retain_all, uniform_list,
@@ -15,32 +15,59 @@ use gpui::{
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::nav::Nav;
use ui::nav_item::NavItem;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::tab::Tab;
use ui::tab::tab_bar::TabBar;
use ui::{
Disableable, Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension,
h_flex, title_bar_drag_handlers, v_flex,
Disableable, Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING,
WindowExtension, h_flex, title_bar_drag_handlers, v_flex,
};
use crate::Command;
use crate::dialogs::import;
mod tab;
mod tree;
mod utils;
use tab::SidebarTab;
use tree::{CommunityRow, CommunitySection, SidebarRow};
pub(crate) use tree::{TreeRow, TreeRowKind};
pub(crate) use utils::{nav_avatar, nav_icon, pick_banner};
pub enum SidebarRow {
Room { room: Entity<Room> },
Community { community: Entity<Community> },
}
/// A collapsible group of rows in the sidebar's community view.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CommunitySection {
Channels,
Admins,
Members,
}
/// A row in the sidebar's community view.
pub enum CommunityRow {
Section(CommunitySection),
Channel {
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
},
Member {
public_key: PublicKey,
},
}
pub struct Sidebar {
focus_handle: FocusHandle,
@@ -49,6 +76,8 @@ pub struct Sidebar {
community_scroll: UniformListScrollHandle,
/// The dock the sidebar opens its panels in
dock: WeakEntity<DockArea>,
/// Background shown behind the signed-out screen, picked at random
banner: SharedString,
active_tab: SidebarTab,
/// The community the sidebar is browsing, if any
community: Option<WeakEntity<Community>>,
@@ -63,6 +92,7 @@ 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 nostr = NostrRegistry::global(cx);
let mut subscriptions = smallvec![];
@@ -85,6 +115,16 @@ impl Sidebar {
},
));
subscriptions.push(
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
// Re-pick the background each time the signed-out screen is shown.
if let StateEvent::NoSigner = event {
this.banner = pick_banner();
cx.notify();
}
}),
);
Self {
focus_handle: cx.focus_handle(),
scroll_handles: [
@@ -93,6 +133,7 @@ impl Sidebar {
],
community_scroll: UniformListScrollHandle::new(),
dock,
banner: pick_banner(),
active_tab: SidebarTab::Inbox,
community: None,
channels_open: true,
@@ -381,6 +422,7 @@ impl Sidebar {
let (banner, rows) = {
let community = community.read(cx);
let owner = community.state().owner;
let mut admins = Vec::new();
let mut members = Vec::new();
@@ -412,25 +454,26 @@ impl Sidebar {
rows.push(CommunityRow::Section(CommunitySection::Admins));
if self.admins_open {
rows.extend(admins.into_iter().map(|public_key| CommunityRow::Member {
prefix: "community-admin",
public_key,
}));
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 {
prefix: "member",
public_key,
}));
rows.extend(
members
.into_iter()
.map(|public_key| CommunityRow::Member { public_key }),
);
}
(banner, rows)
};
let rows = Rc::new(rows);
let scroll_handle = &self.community_scroll;
v_flex()
.flex_1()
@@ -439,7 +482,7 @@ impl Sidebar {
.gap_2()
.when_some(banner, |this, banner| {
this.child(
div().px_2().child(
div().px_2().flex_shrink_0().child(
img(banner)
.w_full()
.h_20()
@@ -460,11 +503,11 @@ impl Sidebar {
this.render_community_rows(range, rows.as_slice(), &community, cx)
}),
)
.track_scroll(scroll_handle)
.track_scroll(&self.community_scroll)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(scroll_handle)),
.child(Scrollbar::vertical(&self.community_scroll)),
)
.into_any_element()
}
@@ -480,36 +523,14 @@ impl Sidebar {
.into_iter()
.flatten()
.map(|row| match row {
CommunityRow::Section(CommunitySection::Channels) => self.section_row(
"channels",
"Channels",
self.channels_open,
|sidebar| sidebar.channels_open = !sidebar.channels_open,
cx,
),
CommunityRow::Section(CommunitySection::Admins) => self.section_row(
"admins",
"Admins",
self.admins_open,
|sidebar| sidebar.admins_open = !sidebar.admins_open,
cx,
),
CommunityRow::Section(CommunitySection::Members) => self.section_row(
"members",
"Members",
self.members_open,
|sidebar| sidebar.members_open = !sidebar.members_open,
cx,
),
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),
CommunityRow::Member { prefix, public_key } => {
self.member_row(prefix, *public_key, cx)
}
})
.collect()
}
@@ -559,26 +580,30 @@ impl Sidebar {
let dock = self.dock.clone();
let room = room.clone();
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Room,
name,
)
.avatar(seed)
.picture(picture)
.created_at(created_at)
// Run in an app context: docking a new panel reads every
// docked panel's id, which would panic inside a sidebar update.
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
window,
cx,
);
})
.into_any_element()
Nav::new(SharedString::from(format!("room-{index}")))
.label(name)
.text_sm()
.font_medium()
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
this.prefix(avatar)
})
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(created_at),
)
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
window,
cx,
);
})
.into_any_element()
}
SidebarRow::Community { community } => {
let name = community.read(cx).name();
@@ -588,58 +613,66 @@ impl Sidebar {
let sidebar = cx.entity().downgrade();
let community = community.clone();
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
name,
)
.avatar(seed)
.picture(picture)
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(community_ui::init(community.clone(), window, cx)),
DockPlacement::Center,
window,
cx,
);
Nav::new(SharedString::from(format!("com-{index}")))
.label(name)
.text_sm()
.when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| {
this.prefix(avatar)
})
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(community_ui::init(
community.clone(),
window,
cx,
)),
DockPlacement::Center,
window,
cx,
);
if let Err(error) = sidebar.update(cx, |this, cx| {
this.community = Some(community.downgrade());
cx.notify();
}) {
log::error!("Failed to show community in sidebar: {error}");
}
})
.into_any_element()
if let Err(error) = sidebar.update(cx, |this, cx| {
this.community = Some(community.downgrade());
cx.notify();
}) {
log::error!("Failed to show community in sidebar: {error}");
}
})
.into_any_element()
}
}
})
.collect()
}
fn section_row(
&self,
id: &'static str,
label: &'static str,
open: bool,
toggle: impl Fn(&mut Sidebar) + 'static,
cx: &mut Context<Sidebar>,
) -> AnyElement {
div()
.flex_shrink_0()
.child(
TreeRow::new(ElementId::Name(id.into()), TreeRowKind::Section, label)
.icon(if open {
IconName::CaretDown
} else {
IconName::CaretRight
})
.on_click(cx.listener(move |this, _event, _window, cx| {
toggle(this);
cx.notify();
})),
)
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),
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
Nav::new(label)
.label(label)
.suffix(nav_icon(icon, cx))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _ev, _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,
}
cx.notify();
}))
.into_any_element()
}
@@ -653,50 +686,38 @@ impl Sidebar {
cx: &mut Context<Sidebar>,
) -> AnyElement {
let community = community.clone();
let icon = if private {
IconName::Lock
} else {
IconName::Hashtag
};
div()
.flex_shrink_0()
.rounded(cx.theme().radius)
.child(
TreeRow::new(
ElementId::Name(SharedString::from(format!(
"community-channel-{}",
id.to_hex()
))),
TreeRowKind::Room,
name,
)
.icon(if private {
IconName::Lock
} else {
IconName::Message
})
.on_click(cx.listener(move |_this, _event, _window, cx| {
community.update(cx, |community, cx| community.set_active_channel(id, cx));
cx.notify();
})),
)
.when(selected, |this| this.bg(cx.theme().ghost_element_active))
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, prefix: &str, public_key: PublicKey, cx: &App) -> AnyElement {
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(&public_key, cx);
let person = persons.read(cx).get(public_key, cx);
div()
.flex_shrink_0()
.child(
TreeRow::new(
ElementId::Name(SharedString::from(format!(
"{prefix}-{}",
public_key.to_hex()
))),
TreeRowKind::Room,
person.name(),
)
.avatar(person.avatar_seed())
.picture(person.avatar()),
Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.font_medium()
.when_some(
nav_avatar(Some(person.avatar_seed()), person.avatar(), cx),
|this, avatar| this.prefix(avatar),
)
.into_any_element()
}
@@ -770,27 +791,32 @@ impl Render for Sidebar {
window,
cx,
))
.child(
div().absolute().inset_0().child(
img(self.banner.clone())
.size_full()
.object_fit(ObjectFit::Cover),
),
)
.child(
v_flex()
.size_full()
.justify_end()
.gap_4()
.p_4()
.child(img("brand/headline.png").max_w_48())
.child(
v_flex().child(div().font_semibold().child("Coop")).child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child("TODO"),
),
)
.child(
Button::new("import-identity")
.label("Import identity")
.primary()
Button::new("import")
.label("Import Identity")
.custom(
ButtonCustomVariant::new(window, cx)
.color(gpui::white())
.foreground(gpui::black())
.hover(gpui::white().opacity(0.9))
.active(gpui::white().opacity(0.8)),
)
.large()
.font_semibold()
.h_8()
.w_full()
.on_click(|_, window, cx| {
import::open(window, cx);
}),
-221
View File
@@ -1,221 +0,0 @@
use std::rc::Rc;
use chat::Room;
use community::{ChannelId, Community};
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use nostr_sdk::prelude::PublicKey;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
pub enum SidebarRow {
Room { room: Entity<Room> },
Community { community: Entity<Community> },
}
/// A collapsible group of rows in the sidebar's community view.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CommunitySection {
Channels,
Admins,
Members,
}
/// A row in the sidebar's community view.
pub enum CommunityRow {
Section(CommunitySection),
Channel {
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
},
Member {
prefix: &'static str,
public_key: PublicKey,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind {
Section,
Room,
Community,
}
#[derive(IntoElement)]
pub struct TreeRow {
id: ElementId,
kind: TreeRowKind,
label: SharedString,
avatar: Option<SharedString>,
picture: Option<ImageSource>,
icon: Option<IconName>,
created_at: Option<SharedString>,
selected: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl TreeRow {
pub fn new(
id: impl Into<ElementId>,
kind: TreeRowKind,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
kind,
label: label.into(),
avatar: None,
picture: None,
icon: None,
created_at: None,
selected: false,
on_click: None,
}
}
/// Sets the seed for the row's generated avatar.
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into());
self
}
/// Shows `picture` instead of the generated avatar.
pub fn picture(mut self, picture: Option<impl Into<ImageSource>>) -> Self {
self.picture = picture.map(Into::into);
self
}
/// Shows `icon` in the avatar slot when the row has no avatar or picture.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for TreeRow {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl RenderOnce for TreeRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let is_section = self.kind == TreeRowKind::Section;
let is_room = self.kind == TreeRowKind::Room;
let is_community = self.kind == TreeRowKind::Community;
let is_selected = self.selected;
let avatar = if hide_avatar {
None
} else {
match (self.avatar, self.picture) {
(None, None) => None,
(seed, Some(picture)) => Some(
Avatar::from_source(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.small()
.flex_shrink_0()
.into_any_element(),
),
(Some(seed), None) => Some(
PixelAvatar::new(seed)
.small()
.flex_shrink_0()
.into_any_element(),
),
}
};
let avatar = avatar.or_else(|| {
self.icon.map(|icon| {
h_flex()
.flex_shrink_0()
.w(px(20.))
.justify_center()
.text_color(cx.theme().icon_muted)
.child(Icon::new(icon).small())
.into_any_element()
})
});
h_flex()
.id(self.id)
.w_full()
.h_10()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.when(is_section, |this| {
this.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
})
.when(is_room || is_community, |this| this.text_sm())
.when_some(avatar, |this, avatar| this.child(avatar))
.child(
h_flex()
.gap_1()
.flex_1()
.child(
div()
.truncate()
.min_w_0()
.when(is_room, |this| this.font_medium())
.child(self.label),
)
.child(div().flex_1())
.when(is_selected, |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.flex_shrink_0()
.text_color(cx.theme().icon_accent),
)
})
.when_some(self.created_at, |this, created_at| {
this.child(
div()
.flex_shrink_0()
.text_color(cx.theme().text_placeholder)
.text_xs()
.child(created_at),
)
}),
)
.when_some(self.on_click, |this, handler| {
this.cursor_pointer()
.when(!is_section, |this| {
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
+58
View File
@@ -0,0 +1,58 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::prelude::FluentBuilder as _;
use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px};
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Sizable, h_flex};
pub(crate) fn nav_avatar(
seed: Option<impl Into<SharedString>>,
picture: Option<impl Into<ImageSource>>,
cx: &App,
) -> Option<AnyElement> {
if AppSettings::get_hide_avatar(cx) {
return None;
}
match (seed.map(Into::into), picture.map(Into::into)) {
(None, None) => None,
(seed, Some(picture)) => Some(
Avatar::from_source(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.small()
.flex_shrink_0()
.into_any_element(),
),
(Some(seed), None) => Some(
PixelAvatar::new(seed)
.small()
.flex_shrink_0()
.into_any_element(),
),
}
}
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()
}
/// 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"];
/// Pick one of the signed-out backgrounds at random.
pub(crate) fn pick_banner() -> SharedString {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or_default();
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
SIGNED_OUT_BANNERS[index].into()
}