diff --git a/Cargo.lock b/Cargo.lock index 583adc7d..ae99581b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,7 +561,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.20", + "thiserror 2.0.21", "v_frame", "y4m", ] @@ -1788,6 +1788,46 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1989,13 +2029,12 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -2145,10 +2184,12 @@ dependencies = [ [[package]] name = "faster-hex" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" dependencies = [ + "autocfg", + "defmt 0.3.100", "heapless 0.8.0", "serde", ] @@ -2685,7 +2726,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows 0.62.2", ] @@ -2805,7 +2846,7 @@ dependencies = [ "spin 0.10.1", "strum", "taffy", - "thiserror 2.0.20", + "thiserror 2.0.21", "ttf-parser", "url", "usvg", @@ -3293,7 +3334,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "ureq", ] @@ -3624,16 +3665,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -3954,7 +3996,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link 0.2.1", ] @@ -4210,9 +4252,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" [[package]] name = "lru-slab" @@ -4487,27 +4529,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -4536,7 +4557,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-ident", ] @@ -5912,7 +5933,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -5934,7 +5955,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -6102,7 +6123,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.20", + "thiserror 2.0.21", "v_frame", "wasm-bindgen", ] @@ -6500,9 +6521,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -6521,9 +6542,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -7426,7 +7447,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", "windows 0.61.3", "windows-version", ] @@ -7490,11 +7511,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -7510,9 +7531,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -7958,7 +7979,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.20", + "thiserror 2.0.21", "utf-8", ] @@ -8641,7 +8662,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-wasm", @@ -8730,7 +8751,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "wasm-bindgen", "wayland-sys", "web-sys", @@ -8858,7 +8879,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows 0.61.3", "windows-future 0.2.1", ] @@ -9810,18 +9831,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" dependencies = [ "proc-macro2", "quote", diff --git a/assets/brand/bg1.jpg b/assets/brand/bg1.jpg new file mode 100644 index 00000000..b93eea22 Binary files /dev/null and b/assets/brand/bg1.jpg differ diff --git a/assets/brand/bg2.jpg b/assets/brand/bg2.jpg new file mode 100644 index 00000000..8f317235 Binary files /dev/null and b/assets/brand/bg2.jpg differ diff --git a/assets/brand/headline.png b/assets/brand/headline.png new file mode 100644 index 00000000..56b31520 Binary files /dev/null and b/assets/brand/headline.png differ diff --git a/assets/icons/hashtag.svg b/assets/icons/hashtag.svg new file mode 100644 index 00000000..2459408b --- /dev/null +++ b/assets/icons/hashtag.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index fef5db14..e47f01df 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -67,6 +67,7 @@ pub enum IconName { Ship, Shield, Group, + Hashtag, History, UserKey, Upload, @@ -148,6 +149,7 @@ impl IconNamed for IconName { Self::Upload => "icons/upload.svg", Self::Usb => "icons/usb.svg", Self::Group => "icons/group.svg", + Self::Hashtag => "icons/hashtag.svg", Self::History => "icons/history.svg", Self::PanelLeft => "icons/panel-left.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg", diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 626216e5..8815144d 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -19,6 +19,7 @@ pub mod input; pub mod menu; pub mod message; pub mod modal; +pub mod nav; pub mod nav_item; pub mod notification; pub mod popover; diff --git a/crates/ui/src/nav.rs b/crates/ui/src/nav.rs new file mode 100644 index 00000000..21a17538 --- /dev/null +++ b/crates/ui/src/nav.rs @@ -0,0 +1,123 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, + div, +}; +use theme::ActiveTheme; + +use crate::{Selectable, StyledExt, h_flex, v_flex}; + +/// A single navigation row, such as an entry in a sidebar list. +#[derive(IntoElement)] +pub struct Nav { + id: ElementId, + style: StyleRefinement, + prefix: Option, + label: SharedString, + suffix: Option, + selected: bool, + #[allow(clippy::type_complexity)] + on_click: Option>, +} + +impl Nav { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + style: StyleRefinement::default(), + prefix: None, + label: SharedString::default(), + suffix: None, + selected: false, + on_click: None, + } + } + + /// Sets the element shown before the label, such as an avatar or icon. + pub fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } + + /// Sets the row's label. + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + + /// Sets the element shown after the label, such as a timestamp or badge. + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + 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 Nav { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Styled for Nav { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Nav { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let clickable = self.on_click.is_some(); + + v_flex() + .id(format!("nav-{}", self.id)) + .w_full() + .h_10() + .child( + h_flex() + .id(self.id) + .h_9() + .w_full() + .px_1p5() + .gap_1p5() + .rounded(cx.theme().radius_lg) + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child( + h_flex() + .gap_1() + .flex_1() + .child(div().truncate().min_w_0().child(self.label)) + .child(div().flex_1()) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }), + ) + .when(clickable, |this| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .when(self.selected, |this| { + this.bg(cx.theme().ghost_element_active) + }) + }) + .when_some(self.on_click, |this, handler| { + this.on_click(move |event, window, cx| handler(event, window, cx)) + }) + .refine_style(&self.style), + ) + } +} diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index a43c7449..ed118245 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -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()) }); } diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 64feab68..10b6db2e 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -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() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index c52254b8..4e19ffb8 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -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 }, + Community { community: Entity }, +} + +/// 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, + /// 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>, @@ -63,6 +92,7 @@ impl Sidebar { pub fn new(window: &mut Window, dock: WeakEntity, cx: &mut Context) -> 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, - ) -> 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) -> 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, ) -> 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); }), diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs deleted file mode 100644 index 2bd5db00..00000000 --- a/crates/workspace/src/sidebar/tree.rs +++ /dev/null @@ -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 }, - Community { community: Entity }, -} - -/// 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, - picture: Option, - icon: Option, - created_at: Option, - selected: bool, - #[allow(clippy::type_complexity)] - on_click: Option>, -} - -impl TreeRow { - pub fn new( - id: impl Into, - kind: TreeRowKind, - label: impl Into, - ) -> 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) -> Self { - self.avatar = Some(seed.into()); - self - } - - /// Shows `picture` instead of the generated avatar. - pub fn picture(mut self, picture: Option>) -> 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) -> 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)) - }) - } -} diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs new file mode 100644 index 00000000..b01f3520 --- /dev/null +++ b/crates/workspace/src/sidebar/utils.rs @@ -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>, + picture: Option>, + cx: &App, +) -> Option { + 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() +}