feat: adopt the native OS theme (#66)

Reviewed-on: reya/coop#66
This commit is contained in:
2026-10-23 04:07:11 +00:00
parent f44f0704f3
commit 30da985ac1
257 changed files with 5490 additions and 12236 deletions
+18 -19
View File
@@ -4,20 +4,19 @@ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, px, relative,
};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration;
use nostr_connect::prelude::*;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, WindowExtension, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Continue with your Nostr Identity")
@@ -31,7 +30,7 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Unlock your identity")
@@ -239,15 +238,15 @@ impl Render for ImportIdentity {
v_flex()
.size_full()
.gap_4()
.text_style(TextStyle::Callout)
.text_xs()
.child(
v_flex()
.gap(px(theme::SPACE))
.gap_2()
.when(!is_unlock, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
@@ -256,7 +255,7 @@ impl Render for ImportIdentity {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(if is_unlock {
"Passphrase to unlock your identity:"
} else {
@@ -269,14 +268,14 @@ impl Render for ImportIdentity {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child("Passphrase to encrypt your key on this device:")
.child(Input::new(&self.pass_input)),
)
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child("Repeat Passphrase:")
.child(Input::new(&self.repass_input)),
)
@@ -285,9 +284,9 @@ impl Render for ImportIdentity {
this.child(
div()
.p_2()
.rounded(px(surface_radius()))
.bg(cx.theme().warning_muted)
.text_style(TextStyle::Caption)
.rounded(cx.theme().radius_lg)
.bg(cx.theme().warning.opacity(0.15))
.text_xs()
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(BUNKER_WARN)),
@@ -297,9 +296,9 @@ impl Render for ImportIdentity {
this.child(
div()
.p_2()
.rounded(px(surface_radius()))
.bg(cx.theme().warning_muted)
.text_style(TextStyle::Caption)
.rounded(cx.theme().radius_lg)
.bg(cx.theme().warning.opacity(0.15))
.text_xs()
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(KEY_WARN)),
@@ -330,7 +329,7 @@ impl Render for ImportIdentity {
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_style(TextStyle::Caption)
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error.clone()),
+5 -6
View File
@@ -1,11 +1,12 @@
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
@@ -57,10 +58,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
.child(
Button::new("join")
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Join")
.primary()
.font_semibold()
.on_click({
let join = join.clone();
move |event, window, cx| {
+22 -28
View File
@@ -9,17 +9,19 @@ use gpui::{
Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Task, WeakEntity,
Window, div, px, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::dock::{DockArea, DockPlacement, PanelHandle};
use ui::input::{Input, InputEvent, InputState};
use ui::nav::Nav;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::Avatar;
use crate::nav::Nav;
/// Maximum number of members a chat can have.
const MAX_MEMBERS: usize = 3;
@@ -301,7 +303,7 @@ impl NewChat {
room
});
ui::dock::add_panel_to(
crate::dock::add_panel_to(
&self.dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
@@ -336,13 +338,12 @@ impl NewChat {
Nav::new(start + offset)
.label(profile.name())
.text_style(TextStyle::Callout)
.text_sm()
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.prefix(
Avatar::from_source(profile.avatar())
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small()
.flex_shrink_0(),
.small(),
)
})
.on_click(cx.listener(move |this, _event, _window, cx| {
@@ -367,14 +368,14 @@ impl NewChat {
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().surface_raised)
.text_style(TextStyle::Caption2)
.hover(|this| this.bg(cx.theme().element_hover))
.bg(cx.theme().secondary)
.text_xs()
.font_medium()
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::from_source(profile.avatar())
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall()
.flex_shrink_0(),
.xsmall(),
)
.child(div().max_w_32().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
@@ -414,17 +415,12 @@ impl Render for NewChat {
})
.map(|this| {
if let Some(error) = self.error.clone() {
this.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child(error),
)
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
} else {
this.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(notice),
)
}
@@ -452,10 +448,8 @@ impl Render for NewChat {
.child(
Button::new("start")
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Start")
.primary()
.font_semibold()
.on_click(cx.listener(move |this, _event, window, cx| {
if this.start_chat(window, cx) {
window.close_dialog(cx);
+116 -110
View File
@@ -3,29 +3,30 @@ use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _,
Styled, Subscription, Task, WeakEntity, Window, div, px, rems,
StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, div, px, rems,
};
use gpui_base::{Popup, Select};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use state::upload;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, control_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, PanelHandle};
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use ui::switch::Switch;
use ui::{
Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex,
};
use ui::{Avatar, settings_row};
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
const PRIVATE_DESC: &str = "Only members can read group messages";
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
const RESTRICTED_DESC: &str = "Only members can post messages";
const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable";
const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see you joined";
const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see";
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
open_with(dock, None, window, cx);
@@ -257,7 +258,7 @@ impl NewGroup {
let registry = GroupsRegistry::global(cx);
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
ui::dock::add_panel_to(
crate::dock::add_panel_to(
&self.dock,
PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center,
@@ -347,8 +348,8 @@ impl NewGroup {
if let Some(parent) = &self.parent {
return v_flex().gap_1p5().child(field_label("Relay", cx)).child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!(
"Created on {} (same relay as the parent group).",
relay_label(parent.relay())
@@ -365,17 +366,17 @@ impl NewGroup {
.w_full()
.h_8()
.px_3()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.bg(if open {
cx.theme().element_active
cx.theme().list_active
} else {
cx.theme().surface_raised
cx.theme().secondary
})
.when(!open, |this| {
this.hover(|this| this.bg(cx.theme().element_hover))
.active(|this| this.bg(cx.theme().element_active))
this.hover(|this| this.bg(cx.theme().primary))
.active(|this| this.bg(cx.theme().list_active))
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.relay_select_open = !open;
@@ -385,7 +386,7 @@ impl NewGroup {
.child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().text_muted),
.text_color(cx.theme().muted_foreground),
);
let select = Select::new("relay-select")
@@ -412,36 +413,40 @@ impl NewGroup {
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.set_relay_select_open(false, cx);
}))
.popover_style(cx)
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius_lg)
.p_1()
.gap_y_1()
.min_w(rems(8.))
.text_color(cx.theme().text)
.text_color(cx.theme().foreground)
.children(self.relays.iter().enumerate().map(|(index, relay)| {
h_flex()
.id(SharedString::from(format!("option-{relay}")))
.px_1p5()
.py_1()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.hover(|this| this.bg(cx.theme().element_hover))
.hover(|this| this.bg(cx.theme().primary))
.child(relay_label(relay))
.on_click(cx.listener(move |this, _, _, cx| {
this.activate_relay_option(index, cx);
}))
}))
.child(divider(cx))
.child(Separator::horizontal())
.child(
h_flex()
.id("relay-add")
.px_1p5()
.py_1()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.child("Custom relay")
.hover(|this| this.bg(cx.theme().element_hover))
.hover(|this| this.bg(cx.theme().primary))
.on_click(cx.listener(|this, _, _, cx| {
this.activate_relay_option(this.relays.len(), cx);
})),
@@ -457,65 +462,72 @@ impl NewGroup {
}
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
let private = Switch::new("private")
.label("Private")
.description(PRIVATE_DESC)
.checked(self.private)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.private = *checked;
cx.notify();
}));
let closed = Switch::new("closed")
.label("Closed")
.description(CLOSED_DESC)
.checked(self.closed)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.closed = *checked;
cx.notify();
}));
let restricted = Switch::new("restricted")
.label("Restricted")
.description(RESTRICTED_DESC)
.checked(self.restricted)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.restricted = *checked;
cx.notify();
}));
let hidden = Switch::new("hidden")
.label("Hidden")
.description(HIDDEN_DESC)
.checked(self.hidden)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.hidden = *checked;
cx.notify();
}));
v_flex()
.gap_2()
.child(field_label("Access settings", cx))
.child(switch_row(private, cx))
.child(switch_row(closed, cx))
.child(switch_row(restricted, cx))
.child(switch_row(hidden, cx))
GroupBox::new()
.fill()
.title("Access settings")
.title_style(group_box_title_style(cx))
.child(settings_row(
"Private",
PRIVATE_DESC,
Switch::new("private")
.checked(self.private)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.private = *checked;
cx.notify();
})),
cx,
))
.child(settings_row(
"Closed",
CLOSED_DESC,
Switch::new("closed")
.checked(self.closed)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.closed = *checked;
cx.notify();
})),
cx,
))
.child(settings_row(
"Restricted",
RESTRICTED_DESC,
Switch::new("restricted")
.checked(self.restricted)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.restricted = *checked;
cx.notify();
})),
cx,
))
.child(settings_row(
"Hidden",
HIDDEN_DESC,
Switch::new("hidden")
.checked(self.hidden)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.hidden = *checked;
cx.notify();
})),
cx,
))
}
fn render_list_setting(&self, cx: &mut Context<Self>) -> impl IntoElement {
let list_private = Switch::new("list-private")
.label("Private on my list")
.description(LIST_PRIVATE_DESC)
.checked(self.list_private)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.list_private = *checked;
cx.notify();
}));
v_flex()
.gap_2()
.child(field_label("List", cx))
.child(switch_row(list_private, cx))
GroupBox::new()
.fill()
.title("List")
.title_style(group_box_title_style(cx))
.child(settings_row(
"Private on my list",
LIST_PRIVATE_DESC,
Switch::new("list-private")
.checked(self.list_private)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.list_private = *checked;
cx.notify();
})),
cx,
))
}
fn render_error(
@@ -530,7 +542,7 @@ impl NewGroup {
.child(
div()
.flex_1()
.text_style(TextStyle::Caption)
.text_xs()
.text_color(cx.theme().danger)
.child(error.to_owned()),
)
@@ -541,7 +553,7 @@ impl NewGroup {
.cursor_pointer()
.flex_shrink_0()
.underline()
.text_style(TextStyle::Caption)
.text_xs()
.text_color(cx.theme().danger)
.child("Open relay website")
.on_click({
@@ -560,14 +572,6 @@ impl NewGroup {
impl Render for NewGroup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let name = self.name_input.read(cx).value().trim().to_owned();
let seed: String = if name.is_empty() {
"group".to_owned()
} else {
name
};
let selected = self
.selected_relay
.as_ref()
@@ -593,7 +597,11 @@ impl Render for NewGroup {
h_flex()
.items_center()
.gap_3()
.child(Avatar::new(self.picture.clone()).seed(seed).large())
.child(
Avatar::new(self.picture.clone())
.seed(self.name_input.read(cx).value().trim().to_owned())
.large(),
)
.child(
Button::new("upload-picture")
.label(if self.picture.is_some() {
@@ -610,9 +618,9 @@ impl Render for NewGroup {
})),
),
)
.child(field("Name", Input::new(&self.name_input), cx))
.child(input_field("Name", Input::new(&self.name_input), cx))
.child(self.render_relay(&selected, cx))
.child(field("About", Textarea::new(&self.about_input), cx))
.child(input_field("About", Textarea::new(&self.about_input), cx))
.child(self.render_access(cx))
.child(self.render_list_setting(cx))
.overflow_y_scroll(),
@@ -635,7 +643,6 @@ impl Render for NewGroup {
"Create Group"
})
.primary()
.font_semibold()
.loading(self.creating)
.disabled(self.creating || self.uploading)
.on_click(cx.listener(|this, _event, _window, cx| this.create(cx))),
@@ -644,25 +651,24 @@ impl Render for NewGroup {
}
}
fn field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
v_flex().gap_1p5().child(field_label(text, cx)).child(input)
}
fn field_label(text: &'static str, cx: &App) -> Div {
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.font_semibold()
.child(text)
}
fn switch_row(switch: impl IntoElement, cx: &App) -> Div {
div()
.w_full()
.p_2()
.rounded(px(control_radius()))
.bg(cx.theme().surface_raised)
.child(switch)
/// Match [`field_label`]'s design for GroupBox titles.
fn group_box_title_style(cx: &App) -> StyleRefinement {
StyleRefinement::default()
.text_xs()
.text_color(cx.theme().muted_foreground)
.font_semibold()
}
fn relay_label(relay: &RelayUrl) -> SharedString {
+25 -34
View File
@@ -5,20 +5,21 @@ use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, px, retain_all,
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::DockPlacement;
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
use state::{NostrRegistry, upload};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::dock::DockPlacement;
use ui::input::{Input, InputState, Textarea, TextareaState};
use ui::notification::Notification;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::Avatar;
use crate::Workspace;
@@ -372,17 +373,15 @@ impl Render for ProfileDialog {
.w_full()
.items_center()
.justify_center()
.bg(cx.theme().surface)
.bg(cx.theme().sidebar)
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
.when(self.editing, |this| {
this.child(
Button::new("upload")
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Add an avatar")
.xsmall()
.ghost()
.rounded()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
@@ -398,15 +397,15 @@ impl Render for ProfileDialog {
.w_full()
.justify_center()
.gap_3()
.rounded(px(button_radius()))
.bg(cx.theme().surface)
.text_style(TextStyle::Callout)
.rounded(cx.theme().radius)
.bg(cx.theme().sidebar)
.text_xs()
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
IconName::CircleCheck
} else {
IconName::Copy
}
@@ -485,11 +484,9 @@ impl Render for ProfileDialog {
.when(self.is_current_user && !self.editing, |this| {
this.child(
Button::new("update")
.icon(IconName::Edit)
.icon_position(IconPosition::End)
.icon(IconName::SquarePen)
.label("Update")
.primary()
.font_semibold()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.toggle_edit(window, cx);
})),
@@ -498,11 +495,9 @@ impl Render for ProfileDialog {
.when(!self.is_current_user, |this| {
this.child(
Button::new("message")
.icon(IconName::Message)
.icon_position(IconPosition::End)
.icon(IconName::MessageSquare)
.label("Message")
.primary()
.font_semibold()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.message(window, cx);
})),
@@ -520,11 +515,9 @@ impl Render for ProfileDialog {
)
.child(
Button::new("save")
.icon(IconName::CheckCircle)
.icon_position(IconPosition::End)
.icon(IconName::CircleCheck)
.label("Save")
.primary()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
@@ -538,8 +531,8 @@ impl Render for ProfileDialog {
fn label(label: &'static str, cx: &App) -> Div {
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.font_semibold()
.child(label)
}
@@ -551,14 +544,12 @@ fn field(
cx: &App,
) -> Div {
let (content, color) = match value {
Some(value) => (value, cx.theme().text),
None => (SharedString::from(placeholder), cx.theme().text_muted),
Some(value) => (value, cx.theme().foreground),
None => (SharedString::from(placeholder), cx.theme().muted_foreground),
};
v_flex().gap_1p5().child(label(text, cx)).child(
div()
.text_style(TextStyle::Callout)
.text_color(color)
.child(content),
)
v_flex()
.gap_1p5()
.child(label(text, cx))
.child(div().text_xs().text_color(color).child(content))
}
+28 -29
View File
@@ -1,3 +1,11 @@
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use std::collections::HashSet;
use anyhow::{Error, anyhow};
@@ -5,20 +13,12 @@ use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClickEvent, Context, Div, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign,
WeakEntity, Window, div, px, rems,
WeakEntity, Window, div, rems,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputEvent, InputState};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::scroll::ScrollableElement;
use ui::tab::Tab;
use ui::tab::tab_bar::TabBar;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const GOSSIP_MSG: &str = "Gossip Relays are the relays where you publish all your events and discover events from others.";
const INBOX_MSG: &str = "Messaging Relays host your private messages. Others find these relays and send you messages there.";
@@ -108,11 +108,11 @@ impl RelayManager {
let manager = cx.entity().downgrade();
v_flex()
.gap(px(theme::SPACE))
.gap_2()
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(GOSSIP_MSG)),
)
.child(
@@ -132,7 +132,7 @@ impl RelayManager {
.tooltip("Relay metadata")
.ghost()
.h(rems(2.))
.text_style(TextStyle::Caption)
.text_xs()
.dropdown_menu(move |menu, _window, _cx| {
menu.item(metadata_item(&manager, RelayMetadata::Read))
.item(metadata_item(&manager, RelayMetadata::Write))
@@ -153,7 +153,7 @@ impl RelayManager {
this.child(
div()
.italic()
.text_style(TextStyle::Caption)
.text_xs()
.text_color(cx.theme().danger)
.child(error.clone()),
)
@@ -197,11 +197,11 @@ impl RelayManager {
fn render_inbox(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap(px(theme::SPACE))
.gap_2()
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(INBOX_MSG)),
)
.child(
@@ -224,7 +224,7 @@ impl RelayManager {
this.child(
div()
.italic()
.text_style(TextStyle::Caption)
.text_xs()
.text_color(cx.theme().danger)
.child(error.clone()),
)
@@ -264,7 +264,7 @@ impl RelayManager {
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
TabBar::new("relays-tabs")
.segmented(true)
.segmented()
.selected_index(self.active_tab)
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
this.active_tab = *ix;
@@ -284,10 +284,8 @@ impl RelayManager {
h_flex().gap_1().justify_end().child(
Button::new("update")
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Update")
.primary()
.font_semibold()
.loading(updating)
.disabled(updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
@@ -608,7 +606,7 @@ impl Render for RelayManager {
v_flex()
.gap_4()
.text_style(TextStyle::Callout)
.text_xs()
.child(self.render_tab_bar(cx))
.child(content)
.child(self.render_actions(cx))
@@ -647,26 +645,27 @@ fn relay_row(
.h_8()
.px_2()
.justify_between()
.rounded(px(button_radius()))
.bg(cx.theme().surface)
.rounded(cx.theme().radius)
.bg(cx.theme().sidebar)
.child(
h_flex()
.gap_1()
.text_style(TextStyle::Callout)
.text_xs()
.child(label)
.when_some(badge, |this, badge| {
this.child(
div()
.p_0p5()
.rounded_xs()
.text_style(TextStyle::Caption2)
.text_xs()
.font_medium()
.child(badge),
)
}),
)
.child(
Button::new("remove")
.icon(IconName::Close)
.icon(IconName::X)
.xsmall()
.ghost()
.invisible()
@@ -682,8 +681,8 @@ fn empty_relays(cx: &App) -> impl IntoElement {
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(px(panel_radius()))
.text_style(TextStyle::Callout)
.rounded(cx.theme().radius_lg)
.text_xs()
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
+8 -10
View File
@@ -4,13 +4,13 @@ use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
use instant::Duration;
use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants, IconPosition};
use ui::input::{Input, InputEvent, InputState};
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
#[derive(Debug)]
pub struct RestoreEncryption {
@@ -100,12 +100,12 @@ impl Render for RestoreEncryption {
v_flex()
.size_full()
.gap_3()
.text_style(TextStyle::Callout)
.text_xs()
.child(
v_flex()
.gap_1()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Secret Key")
.child(Input::new(&self.key_input)),
)
@@ -113,10 +113,8 @@ impl Render for RestoreEncryption {
h_flex().gap_1().justify_end().child(
Button::new("restore")
.icon(IconName::ArrowRight)
.icon_position(IconPosition::End)
.label("Restore")
.primary()
.font_semibold()
.on_click(cx.listener(move |this, _, window, cx| {
this.restore(window, cx);
})),
@@ -125,7 +123,7 @@ impl Render for RestoreEncryption {
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_style(TextStyle::Caption)
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error.clone()),
+51 -49
View File
@@ -7,16 +7,17 @@ use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::spinner::Spinner;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::Avatar;
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -310,10 +311,14 @@ impl Screening {
.w_full()
.px_2()
.gap_1p5()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.hover(|this| this.bg(cx.theme().element_hover))
.child(Avatar::new(profile.avatar()).small())
.rounded(cx.theme().radius)
.text_xs()
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()),
);
}
@@ -365,7 +370,11 @@ impl Render for Screening {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar()).large())
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
)
.child(
div()
.font_semibold()
@@ -383,8 +392,8 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().surface_raised)
.text_style(TextStyle::Callout)
.bg(cx.theme().secondary)
.text_xs()
.truncate()
.text_ellipsis()
.text_center()
@@ -398,9 +407,8 @@ impl Render for Screening {
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.ghost_alt()
.ghost()
.small()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
@@ -408,9 +416,8 @@ impl Render for Screening {
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Warning)
.icon(IconName::TriangleAlert)
.warning()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
@@ -423,36 +430,33 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.gap_2()
.text_xs()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex()
.text_style(TextStyle::Callout)
.child("Contact")
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
v_flex().text_xs().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().muted_foreground)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.gap_2()
.text_xs()
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_style(TextStyle::Callout)
.text_xs()
.child(
h_flex()
.gap_0p5()
@@ -462,7 +466,6 @@ impl Render for Screening {
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip(RELAY_INFO),
),
)
@@ -470,7 +473,7 @@ impl Render for Screening {
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.map(|this| {
if let Some(t) = self.last_active {
this.child(SharedString::from(format!(
@@ -487,11 +490,11 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap(px(theme::SPACE))
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_style(TextStyle::Callout)
.text_xs()
.child({
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
@@ -504,7 +507,7 @@ impl Render for Screening {
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child({
if self.address(cx).is_some() {
if self.verified {
@@ -522,15 +525,15 @@ impl Render for Screening {
.child(
h_flex()
.items_start()
.gap(px(theme::SPACE))
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
.text_style(TextStyle::Callout)
.text_xs()
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child({
if mutuals > 0 {
SharedString::from(mutuals_str)
@@ -544,7 +547,6 @@ impl Render for Screening {
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(
@@ -567,15 +569,15 @@ fn status_badge(status: Option<bool>, cx: &App) -> Div {
.flex_shrink_0()
.map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircle).small().text_color({
this.child(Icon::new(IconName::CircleCheck).small().text_color({
if status {
cx.theme().accent
cx.theme().primary
} else {
cx.theme().text_faint
cx.theme().muted_foreground
}
}))
} else {
this.child(Indicator::new().small())
this.child(Spinner::new().small())
}
})
}
+59 -57
View File
@@ -3,15 +3,17 @@ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
SharedString, Styled, Window, div,
};
use settings::AppSettings;
use theme::{ActiveTheme, AppExt as _, AppearanceMode, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::group_box::{GroupBox, GroupBoxVariants};
use ui::input::{Input, InputState};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification;
use ui::switch::Switch;
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use settings::{AppSettings, AppearanceMode};
use ui::settings_row;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx))
@@ -50,7 +52,9 @@ impl Preferences {
/// Set the appearance mode (system, light or dark)
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
AppSettings::update_appearance_mode(mode, cx);
cx.set_appearance_mode(mode);
AppSettings::global(cx).update(cx, |settings, cx| {
settings.apply_theme(cx);
});
}
/// Ask the user for an image to show behind chat and group panels
@@ -109,33 +113,36 @@ impl Render for Preferences {
.id("general")
.title("General")
.fill()
.child(
Switch::new("screening")
.label("Screening")
.description(SCREENING)
.checked(screening)
.on_click(move |_, _window, cx| {
.child(settings_row(
"Screening",
SCREENING,
Switch::new("screening").checked(screening).on_click(
move |_, _window, cx| {
AppSettings::update_screening(!screening, cx);
}),
)
.child(
},
),
cx,
))
.child(settings_row(
"Web of Trust",
WOT,
Switch::new("wot-auto-trust")
.label("Web of Trust")
.description(WOT)
.checked(wot_auto_trust)
.on_click(move |_, _window, cx| {
AppSettings::update_wot_auto_trust(!wot_auto_trust, cx);
}),
)
.child(
Switch::new("avatar")
.label("Hide user avatar")
.description(AVATAR)
.checked(hide_avatar)
.on_click(move |_, _window, cx| {
cx,
))
.child(settings_row(
"Hide user avatar",
AVATAR,
Switch::new("avatar").checked(hide_avatar).on_click(
move |_, _window, cx| {
AppSettings::update_hide_avatar(!hide_avatar, cx);
}),
),
},
),
cx,
)),
)
.child(
GroupBox::new()
@@ -148,22 +155,18 @@ impl Render for Preferences {
.justify_between()
.child(
v_flex()
.child(div().text_xs().child(SharedString::from("Mode")))
.child(
div()
.text_style(TextStyle::Callout)
.child(SharedString::from("Mode")),
)
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(MODE)),
),
)
.child(
Button::new("appearance-mode")
.label(appearance_mode.label())
.ghost_alt()
.ghost()
.small()
.dropdown_menu(|this, _window, _cx| {
AppearanceMode::ALL.iter().fold(this, |menu, mode| {
@@ -182,16 +185,17 @@ impl Render for Preferences {
.id("background")
.title("Background")
.fill()
.child(
.child(settings_row(
"Panel background",
BACKGROUND,
Switch::new("panel-background")
.label("Panel background")
.description(BACKGROUND)
.checked(panel_background)
.on_click(move |_, _window, cx| {
AppSettings::update_panel_background(!panel_background, cx);
cx.refresh_windows();
}),
)
cx,
))
.child(
h_flex()
.gap_3()
@@ -200,15 +204,11 @@ impl Render for Preferences {
v_flex()
.flex_1()
.min_w_0()
.child(div().text_xs().child(SharedString::from("Image")))
.child(
div()
.text_style(TextStyle::Callout)
.child(SharedString::from("Image")),
)
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.truncate()
.child(SharedString::from(background_label)),
),
@@ -220,7 +220,7 @@ impl Render for Preferences {
.child(
Button::new("choose-background")
.label("Choose image")
.ghost_alt()
.ghost()
.small()
.disabled(!panel_background)
.on_click(|_, _window, cx| {
@@ -230,7 +230,7 @@ impl Render for Preferences {
.child(
Button::new("reset-background")
.label("Reset")
.ghost_alt()
.ghost()
.small()
.disabled(
!panel_background
@@ -249,15 +249,16 @@ impl Render for Preferences {
.id("experiments")
.title("Experiments")
.fill()
.child(
.child(settings_row(
"Decoupling Encryption Key",
NIP4E,
Switch::new("nip4e")
.label("Decoupling Encryption Key")
.description(NIP4E)
.checked(nip4e)
.on_click(move |_, _window, cx| {
AppSettings::update_nip4e(!nip4e, cx);
}),
),
cx,
)),
)
.child(
GroupBox::new()
@@ -283,9 +284,10 @@ impl Render for Preferences {
)
.child(
div()
.text_style(TextStyle::Caption2)
.text_xs()
.font_medium()
.italic()
.text_color(cx.theme().text_faint)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("Only support blossom service")),
),
),
@@ -0,0 +1,66 @@
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div,
};
use gpui_base::dock::PanelState;
use gpui_kit::component::ActiveTheme as _;
use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent};
/// Stands in for a panel this build cannot construct.
/// It returns the state it was handed, so the layout survives a load and save.
pub(crate) struct InvalidPanel {
name: SharedString,
focus_handle: FocusHandle,
old_state: PanelState,
}
impl InvalidPanel {
pub(crate) fn new(
name: impl Into<SharedString>,
state: PanelState,
cx: &mut Context<Self>,
) -> Self {
Self {
focus_handle: cx.focus_handle(),
name: name.into(),
old_state: state,
}
}
}
impl BasePanel for InvalidPanel {
fn panel_name(&self) -> &'static str {
"InvalidPanel"
}
fn dump(&self, _: &App) -> PanelState {
self.old_state.clone()
}
}
impl Panel for InvalidPanel {}
impl EventEmitter<PanelEvent> for InvalidPanel {}
impl Focusable for InvalidPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InvalidPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.my_6()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_color(cx.theme().muted_foreground)
.child(format!(
"The `{}` panel type is not registered in PanelRegistry.",
self.name.clone()
))
}
}
+88
View File
@@ -0,0 +1,88 @@
use std::sync::Arc;
use gpui::{App, Pixels, WeakEntity, Window, px};
use gpui_kit::component::dock::{
DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId,
};
mod invalid_panel;
mod skin;
mod tab_panel;
mod window_controls;
pub use skin::CoopDockSkin;
/// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
/// The panel in any region of `area whose id is `key.
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
let placements = [
DockPlacement::Center,
DockPlacement::Left,
DockPlacement::Right,
DockPlacement::Bottom,
];
for placement in placements {
let Some(tree) = area.layout(placement) else {
continue;
};
for id in tree.panels() {
if id != key {
continue;
}
let node = tree.find_panel_node(id)?;
let PaneRef::Tabs { panels, .. } = tree.find_node(node)?.kind() else {
continue;
};
let ix = panels.iter().position(|panel| *panel == key)?;
return Some((id, node, ix));
}
}
None
}
/// Add `panel to the area, or move it to the end of its existing tab group.
pub fn add_panel(
area: &mut DockArea,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut gpui::Context<DockArea>,
) {
let key = panel.panel_id(cx);
if let Some((id, node, ix)) = find_panel(area, key, cx) {
area.move_panel(
id,
InsertTarget::Tabs {
node,
ix: Some(ix),
activate: true,
},
window,
cx,
);
return;
}
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
}
/// Add a panel to a dock reached through a weak handle.
pub fn add_panel_to(
dock: &WeakEntity<DockArea>,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut App,
) {
if let Err(error) = dock.update(cx, |area, cx| {
add_panel(area, panel, placement, window, cx);
}) {
log::warn!("Failed to add panel to dock: {error}");
}
}
+269
View File
@@ -0,0 +1,269 @@
use std::cell::Cell;
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _,
IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style,
Styled as _, WeakEntity, Window, div, px,
};
use gpui_base::dock::{
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
PanelView, TabGroupRenderer,
};
use gpui_base::{HandleEdge, resize_handle};
use gpui_kit::component::ActiveTheme as _;
use gpui_kit::component::dock::panel_handle;
use super::invalid_panel::InvalidPanel;
use super::tab_panel::CoopTabGroupSkin;
/// State the skin shares with its per-container renderers.
pub(crate) struct SkinShared {
area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
/// The dock whose resize handle is being dragged, if any. Only one can be.
resizing_dock: Cell<Option<DockPlacement>>,
}
impl SkinShared {
pub(crate) fn area(&self) -> &WeakEntity<DockArea> {
&self.area
}
pub(crate) fn is_toggle_button_visible(&self) -> bool {
self.toggle_button_visible.get()
}
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock
}
}
/// The coop appearance for a [`DockArea].
/// Install it in the constructor, the only place the area's weak handle is available.
pub struct CoopDockSkin {
shared: Rc<SkinShared>,
}
impl CoopDockSkin {
pub fn new(cx: &mut Context<DockArea>) -> Rc<Self> {
Rc::new(Self {
shared: Rc::new(SkinShared {
area: cx.weak_entity(),
toggle_button_visible: Cell::new(true),
resizing_dock: Cell::new(None),
}),
})
}
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
&self.shared
}
}
/// Payload a dock's resize handle drags.
///
/// It draws nothing, the handle element is the visible affordance.
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
impl DockAreaRenderer for CoopDockSkin {
fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.flex()
.flex_row()
}
fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
div()
.id("dock-area-center")
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
}
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
// `size_full and `flex_1 stop the frame collapsing in an unsizing parent.
div()
.id(("dock-split-frame", node.as_u64()))
.size_full()
.flex_1()
.min_h(px(0.))
.overflow_hidden()
.bg(cx.theme().tokens.tab_bar)
}
fn render_dock(
&self,
dock: &DockContext,
content: AnyElement,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
div()
.flex()
.size_full()
.relative()
.child(content)
.child(self.render_resize_handle(dock, window, cx))
.child(DockResizeTracker {
dock: dock.clone(),
shared: self.shared().clone(),
})
.into_any_element()
}
/// Placeholder for a panel this build cannot construct.
/// It dumps the state it was handed, so the layout survives a load and save.
fn build_placeholder(
&self,
state: &PanelState,
_: &mut Window,
cx: &mut App,
) -> Option<Arc<dyn PanelView>> {
let state = state.clone();
Some(panel_handle(cx.new(|cx| {
InvalidPanel::new(state.panel_name.clone(), state, cx)
})))
}
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(CoopTabGroupSkin::new(self.shared().clone()))
}
}
impl CoopDockSkin {
fn render_resize_handle(
&self,
dock: &DockContext,
_: &mut Window,
_: &mut App,
) -> impl IntoElement {
let placement = dock.placement();
let shared = self.shared().clone();
resize_handle("resize-handle", placement.axis())
.when(placement.is_left(), |this| {
this.inside(HandleEdge::Trailing)
})
.when(placement.is_right() || placement.is_bottom(), |this| {
this.inside(HandleEdge::Leading)
})
.on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing_dock().set(Some(placement));
cx.new(|_| info.deref().clone())
})
}
}
/// Turns the window's mouse stream into dock resizing.
/// It draws nothing, the `paint hook is the only window listener registration point.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
}
impl IntoElement for DockResizeTracker {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockResizeTracker {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Window,
_: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_: &mut App,
) {
let placement = self.dock.placement();
window.on_mouse_event({
let dock = self.dock.clone();
let shared = self.shared.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return;
}
// Dragging a closed dock's handle reopens it.
// Read the live state, the snapshot in `dock would toggle it shut again.
let open = shared
.area()
.upgrade()
.is_some_and(|area| area.read(cx).is_dock_open(placement));
if !open {
dock.toggle(window, cx);
}
dock.resize_to(event.position, window, cx);
}
});
window.on_mouse_event({
let shared = self.shared.clone();
move |_: &MouseUpEvent, phase, _, cx| {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return;
}
shared.resizing_dock().set(None);
// The size lives on the dock, not the layout tree.
// Nothing else tells a subscriber to persist it.
_ = shared
.area()
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
}
});
}
}
+779
View File
@@ -0,0 +1,779 @@
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
Styled as _, Window, div, px, size,
};
use gpui_base::dock::{
AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef,
PanelView as BasePanelView, TabGroupContext, TabGroupRenderer,
};
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
use gpui_kit::assets::IconName;
use gpui_kit::component::animation::{Lerp as _, ease_out_cubic};
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
use gpui_kit::component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
use ui::title_bar_drag_handlers;
use super::skin::SkinShared;
use super::{TAB_BAR_HEIGHT, window_controls};
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
const ACTIVE_TAB_SELECTOR: &str = "coop-dock-active-tab";
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "coop-dock-active-tab-close";
const TAB_HOVER_GROUP: &str = "coop-dock-tab";
/// A panel's title, or its registered name when the panel has no handle.
pub(crate) fn panel_title(
panel: &Arc<dyn BasePanelView>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
match PanelHandle::of(panel) {
Some(handle) => handle.title(window, cx),
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}
/// The preview that follows the cursor while a panel is dragged.
struct DragPanelPreview {
panel: Arc<dyn BasePanelView>,
}
impl Render for DragPanelPreview {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("drag-panel")
.cursor_grab()
.p_1()
.whitespace_nowrap()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.text_color(cx.theme().tab_foreground)
.bg(cx.theme().tokens.tab_active)
.opacity(0.75)
.child(panel_title(&self.panel, window, cx))
}
}
/// The zoom affordance `panel offers, if any.
///
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
panel
.zoomable(cx)
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
.flatten()
}
/// The left-most, top-most tab group in a container.
///
/// A left dock's collapse button lives in this group.
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
}
}
/// The right-most, top-most tab group.
///
/// A vertical split picks its first child, a horizontal split picks its last.
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { axis, children, .. } => match axis {
gpui::Axis::Vertical => children.first(),
gpui::Axis::Horizontal => children.last(),
}
.and_then(right_top_group),
}
}
/// The bottom or right dock whose root tab group is this one, if any.
///
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
fn dock_root_group(
shared: &SkinShared,
group: &TabGroupContext,
cx: &App,
) -> Option<DockPlacement> {
let area = shared.area().upgrade()?;
let area = area.read(cx);
[DockPlacement::Bottom, DockPlacement::Right]
.into_iter()
.find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.root().id() == group.node())
})
}
/// Close `panel, removing its dock instead when base would refuse an empty one.
///
/// A bottom or right dock's only panel is that case, so the whole dock goes.
fn close_panel(
shared: &SkinShared,
group: &TabGroupContext,
panel: &Arc<dyn BasePanelView>,
window: &mut Window,
cx: &mut App,
) {
if !panel.closable(cx) {
return;
}
let dock = (group.panels().len() <= 1)
.then(|| dock_root_group(shared, group, cx))
.flatten();
match dock {
Some(placement) => {
_ = shared.area().update(cx, |area, cx| {
area.remove_dock(placement, window, cx);
});
}
None => group.close(panel.panel_id(cx), window, cx),
}
}
/// One tab group's appearance, built once per container so its geometry is its own.
pub(crate) struct CoopTabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
/// The tab shown last frame, so a change scrolls the new one into view.
last_active_ix: Cell<Option<usize>>,
/// Bounds of the title bar row, measured to place the title-bar drag overlay.
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the empty strip after the last tab, where the drag region starts.
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the suffix area, where the drag region ends.
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl CoopTabGroupSkin {
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::default(),
last_active_ix: Cell::new(None),
title_bar_bounds: Rc::new(Cell::new(None)),
title_bar_strip_bounds: Rc::new(Cell::new(None)),
title_bar_suffix_bounds: Rc::new(Cell::new(None)),
}
}
/// A group that is the left dock's only group, with one panel, draws no chrome.
/// The sidebar is one.
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
let Some(area) = self.shared.area().upgrade() else {
return false;
};
let area = area.read(cx);
let Some(left) = area
.layout(DockPlacement::Left)
.map(|tree| tree.root().id())
else {
return false;
};
left == group.node() && group.panels().len() == 1
}
/// The tab's drag payload, or `None when the group must not be rearranged.
///
/// A locked group never is, a bottom or right dock root always is.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() {
return None;
}
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
return None;
}
group.drag_panel(ix, cx)
}
/// A dock's collapse button for this group's bar, or `None when it does not belong.
/// The icon direction depends on whether the dock is open.
fn dock_toggle_button(
&self,
placement: DockPlacement,
group: &TabGroupContext,
cx: &mut App,
) -> Option<Button> {
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
return None;
}
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
// A missing dock is not collapsible, this also covers the old `left_dock.is_some() test.
if !area.is_dock_collapsible(placement) {
return None;
}
let designated = match placement {
DockPlacement::Left => area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Right => area
.layout(DockPlacement::Center)
.and_then(|tree| right_top_group(tree.root())),
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => return None,
};
if designated != Some(group.node()) {
return None;
}
let is_open = area.is_dock_open(placement);
let icon = match (placement, is_open) {
(DockPlacement::Left, true) => IconName::PanelLeft,
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
(DockPlacement::Right, true) => IconName::PanelRight,
(DockPlacement::Right, false) => IconName::PanelRightOpen,
(DockPlacement::Bottom, true) => IconName::PanelBottom,
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
(DockPlacement::Center, _) => return None,
};
let area = self.shared.area().clone();
Some(
Button::new(SharedString::from(format!("toggle-dock:{placement:?}")))
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.tooltip(match is_open {
true => "Collapse",
false => "Expand",
})
.on_click(move |_, window, cx| {
_ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
)
}
/// One tab of the pill strip.
#[allow(clippy::too_many_arguments)]
fn render_tab(
&self,
group: &TabGroupContext,
ix: usize,
panel: Arc<dyn BasePanelView>,
active: bool,
is_bottom_dock: bool,
window: &mut Window,
cx: &mut App,
) -> Tab {
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel);
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
let closable = !collapsed
&& (group.is_closable()
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
// Whether a double-click zooms this tab in.
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
Tab::new(ix)
.group(TAB_HOVER_GROUP)
.when(active, |this| {
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
})
.flex()
.items_center()
.flex_shrink_0()
.h_7()
.min_w_40()
.gap_1()
.px_1p5()
.text_sm()
.overflow_hidden()
.rounded(cx.theme().radius)
.text_color(cx.theme().tab_foreground)
.child(
h_flex()
.flex_1()
.h_6()
.items_center()
.overflow_hidden()
.whitespace_nowrap()
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)),
}),
)
.when(closable, |this| {
this.child(
div()
.flex_shrink_0()
.when(active, |this| {
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
})
.when(!active, |this| {
this.invisible()
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
})
.child(
Button::new(id)
.icon(IconName::Close)
.tooltip("Close")
.ghost()
.xsmall()
.tab_stop(false)
.on_click({
let group = group.clone();
let shared = self.shared.clone();
let panel = panel.clone();
move |_, window, cx| {
cx.stop_propagation();
close_panel(&shared, &group, &panel, window, cx);
}
}),
),
)
})
.styles(|styles| {
styles.selected(|style| {
style
.text_color(cx.theme().tab_active_foreground)
.bg(cx.theme().tab_active)
})
})
.hover(|this| {
if active {
this
} else {
this.text_color(cx.theme().tab_foreground)
.bg(cx.theme().list_hover)
}
})
.selected(active)
.on_click({
let group = group.clone();
let area = self.shared.area().clone();
move |event, window, cx| {
group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock reopens it.
if is_bottom_dock && collapsed {
_ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, cx);
});
return;
}
// A double-click zooms the tab's panel
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
return;
}
group.toggle_zoom(window, cx);
}
})
.when(!collapsed, |this| {
this.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
drag.set_preview_size(DRAG_PREVIEW_SIZE);
cx.new(|_| DragPanelPreview {
panel: panel.clone(),
})
}
})
})
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop({
let group = group.clone();
move |drag: &DragPanel, window, cx| {
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop({
let group = group.clone();
move |item: &AnyDrag, window, cx| {
group.drop_item(item.clone(), None, window, cx);
}
})
})
})
}
/// The strip after the last tab, a drop target for panels and other drag items.
/// Its left edge marks where the title-bar drag overlay starts.
fn render_empty_space(
&self,
group: &TabGroupContext,
tabs_count: usize,
_cx: &mut App,
) -> AnyElement {
let strip_bounds = self.title_bar_strip_bounds.clone();
let shared = self.shared.clone();
let droppable = group.is_droppable();
let mut empty = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.on_prepaint(move |bounds, _, cx| {
if strip_bounds.get() != Some(bounds) {
strip_bounds.set(Some(bounds));
_ = shared.area().update(cx, |_, cx| cx.notify());
}
});
if droppable {
empty = empty
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let group = group.clone();
let node = group.node();
move |drag: &DragPanel, window, cx| {
// A panel dropped past its own last tab lands in the final slot.
// A panel from elsewhere is appended in the background.
let ix = (drag.source() == node).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let group = group.clone();
move |item: &AnyDrag, window, cx| {
group.drop_item(item.clone(), None, window, cx);
}
});
}
empty.into_any_element()
}
}
impl TabGroupRenderer for CoopTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = group
.active_panel()
.and_then(|panel| panel_zoom_control(panel, cx));
// An emptied group draws nothing, so no bare tab bar is left behind.
if group.panels().is_empty() {
return div().id("tab-panel");
}
let shared = self.shared.clone();
// `v_flex, a plain `div ignores `flex_grow and the content would collapse.
v_flex()
.id("tab-panel")
.size_full()
.overflow_hidden()
.bg(cx.theme().tokens.background)
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = group.clone();
move |_: &ToggleZoom, window, cx| {
if !group.is_zoomed() && control.is_none() {
return;
}
group.toggle_zoom(window, cx);
}
})
.on_action({
let group = group.clone();
let shared = shared.clone();
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
close_panel(&shared, &group, panel, window, cx);
}
})
})
}
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
v_flex()
.id("active-panel")
// A collapsed group draws its tab strip only, so the content claims no space.
.when(!group.is_collapsed(), |this| this.flex_1())
}
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// An emptied group draws no tab bar, the app prunes the emptied dock later.
if group.panels().is_empty() {
return Empty.into_any_element();
}
// The sidebar group draws no chrome, the sidebar carries its own header.
if self.is_plain_sidebar_group(group, cx) {
return Empty.into_any_element();
}
let collapsed = group.is_collapsed();
let active_ix = group.active_ix();
let tabs_count = group.panels().len();
let left_dock_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_dock_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// On macOS the traffic lights overlay the window's top-left corner, so the
// tab bar under them reserves the space. That is the center's top-left group
// when the left dock is closed or absent, and the zoomed group otherwise:
// a zoom fills the whole area, hiding the docks the frame would draw.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
match area.zoomed_group() {
Some(zoomed) => zoomed == group.node(),
None => {
!area.is_dock_open(DockPlacement::Left)
&& area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
}
});
// Bring a newly displayed tab into view.
// The group owns selection, so the skin watches for the change itself.
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let visible: Vec<usize> = group
.panels()
.iter()
.enumerate()
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, _)| ix)
.collect();
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
{
self.scroll_handle.scroll_to_item(visible_ix);
}
// The tab strip ends at the last tab, the bar has no element after it.
// Cover that dead zone with an overlay so it can drag the window.
let drag_overlay = match (
self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(),
self.title_bar_suffix_bounds.get(),
) {
(Some(title), Some(strip), Some(suffix)) => {
let left = strip.left() - title.left();
let right = title.right() - suffix.left();
(left + right < title.size.width).then(|| {
title_bar_drag_handlers(
div()
.id(("title-bar-drag", group.node().as_u64()))
.absolute()
.top_0()
.bottom_0()
.left(left)
.right(right),
window,
cx,
)
})
}
_ => None,
};
let tabs: Vec<_> = group
.panels()
.iter()
.enumerate()
.filter_map(|(ix, panel)| {
let mut active = displayed == Some(panel.panel_id(cx));
if !panel.visible(cx) {
return None;
}
// Collapsed tabs never show as active, the strip only reopens the dock.
if collapsed {
active = false;
}
Some(self.render_tab(group, ix, panel.clone(), active, is_bottom_dock, window, cx))
})
.collect();
let empty_space = self.render_empty_space(group, tabs_count, cx);
let title_bar_bounds = self.title_bar_bounds.clone();
let title_bar_suffix_bounds = self.title_bar_suffix_bounds.clone();
let shared = self.shared.clone();
let suffix_shared = self.shared.clone();
div()
.flex()
.flex_row()
.child(
div()
.relative()
.flex_1()
.min_w_0()
.on_prepaint(move |bounds, _, cx| {
if title_bar_bounds.get() != Some(bounds) {
title_bar_bounds.set(Some(bounds));
_ = shared.area().update(cx, |_, cx| cx.notify());
}
})
.child(
Tabs::new("tab-bar")
.px(px(-1.))
// A collapsed group stands in for a closed bottom dock,
// which base gives only its reserved strip. The bar keeps
// its own height there rather than the title-bar height,
// so it fits instead of being clipped away.
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
.flex()
.items_center()
.text_color(cx.theme().tab_foreground)
.child(
h_flex()
.items_center()
.top_0()
// -1 px so the border does not overlap the first tab.
.right(-px(1.))
.h_full()
.gap_2()
.px_2()
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
.children(left_dock_button)
.children(bottom_dock_button),
)
.child(
h_flex().id("tabs").flex_1().overflow_x_hidden().child(
h_flex()
.id("tabs-inner")
.relative()
.gap(px(4.))
.overflow_x_scroll()
.lock_scroll_axis()
.track_scroll(&self.scroll_handle)
.children(tabs)
.when(!collapsed, |this| this.child(empty_space)),
),
)
.when(!collapsed, |this| {
this.child(
h_flex()
.items_center()
.top_0()
.right_0()
.h_full()
.px_2()
.gap_1()
.on_prepaint(move |bounds, _, cx| {
if title_bar_suffix_bounds.get() != Some(bounds) {
title_bar_suffix_bounds.set(Some(bounds));
_ = suffix_shared
.area()
.update(cx, |_, cx| cx.notify());
}
})
.children(
group
.active_panel()
.and_then(PanelHandle::of)
.and_then(|handle| handle.title_suffix(window, cx)),
)
.children(right_dock_button),
)
}),
)
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
)
.child(window_controls::window_controls(window, cx))
.into_any_element()
}
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
_: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
div()
.id("tab-content")
.overflow_y_scroll()
.overflow_x_hidden()
.flex_1()
.child(panel.cached(StyleRefinement::default().absolute().size_full()))
.into_any_element()
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let (from, to) = (indicator.from(), indicator.to());
// The element sits at the drop target, the animation walks back from the source.
let offset = from.origin() - to.origin();
Some(
div()
.absolute()
.left(to.origin().x)
.top(to.origin().y)
.w(to.size().width)
.h(to.size().height)
.child(
div()
.absolute()
.bg(cx.theme().tokens.drop_target)
.with_animation(
gpui::ElementId::NamedInteger(
"drop-placeholder".into(),
indicator.epoch(),
),
Animation::new(Duration::from_millis(150)).with_easing(ease_out_cubic),
move |this, delta| {
let origin = offset.lerp(&Point::default(), delta);
let width = from.size().width.lerp(&to.size().width, delta);
let height = from.size().height.lerp(&to.size().height, delta);
this.left(origin.x).top(origin.y).w(width).h(height)
},
),
)
.into_any_element(),
)
}
}
@@ -0,0 +1,169 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window, div, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex};
use super::TAB_BAR_HEIGHT;
const CONTROL_WIDTH: f32 = 34.;
#[derive(IntoElement, Clone)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close,
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
fn close() -> Self {
Self::Close
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close => IconName::WindowClose,
}
}
fn window_control_area(&self) -> gpui::WindowControlArea {
match self {
Self::Minimize => gpui::WindowControlArea::Min,
Self::Restore | Self::Maximize => gpui::WindowControlArea::Max,
Self::Close => gpui::WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close)
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_foreground
} else {
cx.theme().secondary_foreground
}
}
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger
} else {
cx.theme().secondary_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
cx.theme().danger_active
} else {
cx.theme().secondary_active
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
let is_windows = cfg!(target_os = "windows");
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
let icon = self.clone();
div()
.id(self.id())
.flex()
.w(px(CONTROL_WIDTH))
.h_full()
.flex_shrink_0()
.justify_center()
.content_center()
.items_center()
.text_color(cx.theme().foreground)
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| {
this.window_control_area(self.window_control_area())
})
.when(is_linux, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close => window.remove_window(),
}
})
})
.child(Icon::new(self.icon()).small())
}
}
pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
return div().id("window-controls");
}
let supported = window.window_controls();
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
// The controls span the title bar but never grow past the tab bar height.
.when(cfg!(target_os = "windows"), |this| {
this.max_h(TAB_BAR_HEIGHT)
})
.border_l_1()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().tokens.tab_bar)
.when(supported.minimize, |this| {
this.child(ControlIcon::minimize())
})
.when(supported.maximize, |this| {
this.child(if window.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
})
})
.child(ControlIcon::close())
}
+44 -40
View File
@@ -1,24 +1,24 @@
use std::sync::Arc;
use ::settings::{AppSettings, AppearanceMode};
use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::download_dir;
use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use gpui_kit::component::button::Button;
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Theme, WindowExt, v_flex};
use nip4e::{DeviceEvent, DeviceRegistry};
use nip29::{GroupId, GroupKey};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TextStyle, Theme, Typeset as _};
use ui::button::Button;
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::notification::{Notification, NotificationKind};
use ui::{Root, WindowExtension, v_flex};
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
@@ -26,9 +26,16 @@ use crate::panels::{backup, browse, contact_list, greeter, inbox};
use crate::sidebar::Sidebar;
mod dialogs;
mod dock;
mod nav;
mod panels;
mod sidebar;
pub use dock::TAB_BAR_HEIGHT;
/// Default sidebar width.
const SIDEBAR_WIDTH: Pixels = px(240.);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
@@ -72,19 +79,21 @@ impl Workspace {
let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let dock = dock::dock_area("coop", window, cx);
let dock = cx.new(|cx| {
let skin = dock::CoopDockSkin::new(cx);
gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin)
});
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone();
let workspace = cx.entity().downgrade();
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe system appearance and update theme
cx.observe_window_appearance(window, |_this, window, cx| {
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}),
);
}));
}
subscriptions.push(
// Subscribe to the nostr events
@@ -109,7 +118,7 @@ impl Workspace {
.autohide(false)
.title(format!("Wait for approval · {code}"))
.message(MSG)
.with_kind(NotificationKind::Info);
.with_type(NotificationType::Info);
window.push_notification(note, cx);
}
@@ -121,7 +130,7 @@ impl Workspace {
let note = Notification::new()
.id::<DeviceNotifcation>()
.message(MSG)
.with_kind(NotificationKind::Info)
.with_type(NotificationType::Info)
.action(|_this, _window, _cx| {
Button::new("retry").label("Retry").on_click(
move |_this, window, cx| {
@@ -129,7 +138,7 @@ impl Workspace {
device.update(cx, |this, cx| {
this.get_announcement(cx);
});
window.clear_notification::<DeviceNotifcation>(cx);
window.remove_notification::<DeviceNotifcation>(cx);
},
)
});
@@ -140,7 +149,7 @@ impl Workspace {
let note = Notification::new()
.id::<DeviceNotifcation>()
.message("Encryption Key has been set")
.with_kind(NotificationKind::Success);
.with_type(NotificationType::Success);
window.push_notification(note, cx);
}
@@ -169,7 +178,7 @@ impl Workspace {
chat.update(cx, |this, cx| {
this.get_metadata(cx);
});
window.clear_notification::<MsgRelayNotification>(cx);
window.remove_notification::<MsgRelayNotification>(cx);
},
)
}),
@@ -230,20 +239,20 @@ impl Workspace {
}
}
/// Add a panel to the dock, from anywhere that has the window but not the workspace.
/// Add a panel to the workspace dock from anywhere that has the window.
/// The workspace is the window's root view, so it is reachable directly.
pub fn add_panel<P: Panel>(
panel: Entity<P>,
placement: DockPlacement,
window: &mut Window,
cx: &mut App,
) {
if let Some(root) = window.root::<Root>().flatten()
&& let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>()
{
workspace.update(cx, |this, cx| {
this.add_panel_to_dock(panel, placement, window, cx)
});
}
let Some(root) = window.root::<Self>().flatten() else {
return;
};
root.update(cx, |this, cx| {
this.add_panel_to_dock(panel, placement, window, cx)
});
}
/// Add a panel to the dock, or focus it if it is already docked.
@@ -255,7 +264,7 @@ impl Workspace {
cx: &mut Context<Self>,
) {
self.dock.update(cx, |area, cx| {
ui::dock::add_panel(area, PanelHandle::new(panel), placement, window, cx)
crate::dock::add_panel(area, PanelHandle::new(panel), placement, window, cx)
});
}
@@ -267,7 +276,7 @@ impl Workspace {
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(520.))
.show_close(true)
.close_button(true)
.pb_2()
.title("Preferences")
.child(view.clone())
@@ -426,16 +435,16 @@ impl Workspace {
let device = DeviceRegistry::global(cx);
let ent = device.downgrade();
window.open_dialog(cx, move |this, _window, cx| {
window.open_alert_dialog(cx, move |alert, _window, cx| {
let ent = ent.clone();
this.confirm()
.show_close(true)
alert
.confirm()
.title("Reset Encryption Key")
.child(
.description(
v_flex()
.gap_1()
.text_style(TextStyle::Callout)
.text_xs()
.child(SharedString::from(ENC_MSG))
.child(
div()
@@ -466,10 +475,9 @@ impl Workspace {
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dialog_layer = Root::render_dialog_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Dialog, notification and tooltip layers are owned by the framework
// Root that gpui_kit::open_window installed for this window.
div()
.id("workspace")
.on_action(cx.listener(Self::on_command))
@@ -477,9 +485,5 @@ impl Render for Workspace {
.relative()
.size_full()
.child(self.dock.clone())
// Notifications
.children(notification_layer)
// Dialogs
.children(dialog_layer)
}
}
+142
View File
@@ -0,0 +1,142 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
};
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex};
/// A single navigation row, such as an entry in a sidebar list.
#[derive(IntoElement)]
pub struct Nav {
id: ElementId,
interactivity: Interactivity,
style: StyleRefinement,
prefix: Option<AnyElement>,
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
center_prefix: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl Nav {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
let mut interactivity = Interactivity::default();
interactivity.element_id = Some(id.clone());
Self {
id,
interactivity,
style: StyleRefinement::default(),
prefix: None,
label: SharedString::default(),
suffix: None,
selected: false,
clickable: false,
center_prefix: false,
on_click: None,
}
}
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
/// Whether to center the prefix in the avatar-width column.
pub fn center_prefix(mut self, center: bool) -> Self {
self.center_prefix = center;
self
}
/// 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<SharedString>) -> 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 InteractiveElement for Nav {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}
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() || self.clickable;
h_flex()
.id(self.id)
.h_8()
.w_full()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.when_some(self.prefix, |this, prefix| {
this.child(
h_flex()
.flex_shrink_0()
.when(self.center_prefix, |this| this.w_6().justify_center())
.child(prefix),
)
})
.child(div().flex_1().min_w_0().truncate().child(self.label))
.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().list_hover))
.when(self.selected, |this| this.bg(cx.theme().list_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
.refine_style(&self.style)
}
}
+25 -22
View File
@@ -1,16 +1,18 @@
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, px,
App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Sizable, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*;
use state::USER_KEYRING;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputState};
use ui::{IconName, Sizable, StyledExt, divider, v_flex};
const MSG: &str = "Store your account keys in a safe location. \
You can restore your account or move to another client anytime you want.";
@@ -106,13 +108,15 @@ impl BackupPanel {
}
}
impl Panel for BackupPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
impl gpui_kit::component::dock::BasePanel for BackupPanel {
fn panel_name(&self) -> &'static str {
"Backup"
}
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
impl Panel for BackupPanel {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.name.clone()
}
}
@@ -132,26 +136,26 @@ impl Render for BackupPanel {
.w_full()
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(MSG)),
)
.child(divider(cx))
.child(Separator::horizontal())
.child(
v_flex()
.gap(px(theme::SPACE))
.gap_2()
.flex_1()
.w_full()
.text_style(TextStyle::Callout)
.text_xs()
.child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_style(TextStyle::Caption)
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("Public Key:")),
)
.child(Input::new(&self.npub_input).small().disabled(true)),
@@ -162,9 +166,9 @@ impl Render for BackupPanel {
.w_full()
.child(
div()
.text_style(TextStyle::Caption)
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("Secret Key:")),
)
.child(Input::new(&self.nsec_input).small().disabled(true)),
@@ -181,7 +185,6 @@ impl Render for BackupPanel {
})
.primary()
.small()
.font_semibold()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.copy_secret(cx);
})),
+43 -43
View File
@@ -9,18 +9,19 @@ use gpui::{
size, transparent_black,
};
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership};
use nostr_sdk::prelude::{RelayUrl, Timestamp};
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::Avatar;
const TITLE: &str = "Browse";
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
@@ -228,7 +229,7 @@ impl BrowsePanel {
let registry = GroupsRegistry::global(cx);
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
ui::dock::add_panel_to(
crate::dock::add_panel_to(
dock,
PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center,
@@ -268,7 +269,7 @@ impl BrowsePanel {
.min_w_0()
.child(
div()
.text_style(TextStyle::Callout)
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("Public Groups"),
@@ -276,8 +277,8 @@ impl BrowsePanel {
.child(
div()
.truncate()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.map(|this| match &self.relay {
Some(relay) => {
this.child(SharedString::from(relay.as_str().to_owned()))
@@ -294,14 +295,9 @@ impl BrowsePanel {
.w_full()
.max_w_64()
.px_1()
.rounded(px(button_radius()))
.bg(cx.theme().surface)
.child(
Input::new(&self.input)
.appearance(false)
.small()
.flex_1(),
)
.rounded(cx.theme().radius)
.bg(cx.theme().sidebar)
.child(Input::new(&self.input).appearance(false).flex_1())
.child(
Button::new("relay-browse")
.icon(IconName::ArrowRight)
@@ -313,6 +309,7 @@ impl BrowsePanel {
})),
),
)
.flex_shrink_0()
.into_any_element()
}
@@ -341,8 +338,9 @@ impl BrowsePanel {
.children(pair.first().map(|card| self.render_card(card, cx)))
.child(match pair.get(1) {
Some(card) => self.render_card(card, cx),
None => div().flex_1().into_any_element(),
None => div().flex_1().flex_shrink_0().into_any_element(),
})
.flex_shrink_0()
.into_any_element()
}
})
@@ -374,28 +372,28 @@ impl BrowsePanel {
.min_w_0()
.h_full()
.overflow_hidden()
.rounded(px(panel_radius()))
.rounded(cx.theme().radius_lg)
.cursor_pointer()
.border_1()
.border_color(transparent_black())
.bg(cx.theme().surface)
.bg(cx.theme().sidebar)
.hover(|this| {
this.bg(cx.theme().surface_raised)
.border_color(cx.theme().border_strong)
this.bg(cx.theme().secondary)
.border_color(cx.theme().border)
})
.child(
v_flex()
.flex_1()
.min_h_0()
.p_2()
.gap(px(theme::SPACE))
.gap_2()
.child(
h_flex()
.w_full()
.items_center()
.gap(px(theme::SPACE))
.gap_2()
.when(!hide_avatar, |this| {
this.child(Avatar::from_source(picture).seed(seed).flex_shrink_0())
this.child(Avatar::new(picture).seed(seed).flex_shrink_0())
})
.child(
v_flex()
@@ -403,7 +401,7 @@ impl BrowsePanel {
.min_w_0()
.child(
div()
.text_style(TextStyle::Callout)
.text_xs()
.truncate()
.font_semibold()
.line_height(relative(1.2))
@@ -412,8 +410,8 @@ impl BrowsePanel {
.child(
div()
.truncate()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(relay),
),
),
@@ -421,8 +419,8 @@ impl BrowsePanel {
.child(
div()
.min_w_0()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.text_ellipsis()
.line_clamp(2)
.map(|this| {
@@ -438,11 +436,11 @@ impl BrowsePanel {
h_flex()
.w_full()
.gap_1p5()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(Icon::new(IconName::Book))
.child(
div()
.text_style(TextStyle::Callout)
.text_xs()
.child(SharedString::from(format!("{members} members"))),
),
),
@@ -454,19 +452,21 @@ impl BrowsePanel {
}
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
TITLE.into()
impl gpui_kit::component::dock::BasePanel for BrowsePanel {
fn panel_name(&self) -> &'static str {
"Browse"
}
}
fn title(&self, cx: &App) -> AnyElement {
impl Panel for BrowsePanel {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1()
.text_style(TextStyle::Caption)
.text_xs()
.child(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().text_faint),
.text_color(cx.theme().muted_foreground),
)
.child(TITLE)
.into_any_element()
@@ -542,8 +542,8 @@ fn notice(text: &'static str, cx: &App) -> AnyElement {
h_flex()
.size_full()
.justify_center()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(text)
.into_any_element()
}
+31 -29
View File
@@ -3,22 +3,23 @@ use std::collections::HashSet;
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, px, rems, retain_all,
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign,
Window, div, rems, retain_all,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::Directory;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::scroll::ScrollableElement;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::Avatar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
cx.new(|cx| ContactListPanel::new(window, cx))
@@ -241,12 +242,12 @@ impl ContactListPanel {
.h_8()
.px_2()
.justify_between()
.rounded(px(button_radius()))
.hover(|this| this.bg(cx.theme().element_hover))
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().primary))
.child(
h_flex()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.gap_2()
.text_xs()
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
@@ -256,7 +257,7 @@ impl ContactListPanel {
)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.icon(IconName::X)
.xsmall()
.ghost()
.invisible()
@@ -281,20 +282,22 @@ impl ContactListPanel {
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(px(panel_radius()))
.text_style(TextStyle::Callout)
.rounded(cx.theme().radius_lg)
.text_xs()
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
}
impl Panel for ContactListPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
impl gpui_kit::component::dock::BasePanel for ContactListPanel {
fn panel_name(&self) -> &'static str {
"Contacts"
}
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
impl Panel for ContactListPanel {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.name.clone()
}
}
@@ -316,15 +319,15 @@ impl Render for ContactListPanel {
.overflow_y_scrollbar()
.child(
v_flex()
.gap(px(theme::SPACE))
.gap_2()
.flex_1()
.w_full()
.text_style(TextStyle::Callout)
.text_xs()
.child(
div()
.text_style(TextStyle::Caption)
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child("New contact:"),
)
.child(
@@ -350,7 +353,7 @@ impl Render for ContactListPanel {
this.child(
div()
.italic()
.text_style(TextStyle::Caption)
.text_xs()
.text_color(cx.theme().danger)
.child(error.clone()),
)
@@ -371,10 +374,9 @@ impl Render for ContactListPanel {
})
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.icon(IconName::CircleCheck)
.label("Update")
.primary()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
+82 -75
View File
@@ -7,22 +7,23 @@ use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, svg,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle, add_panel_to};
use ui::group_box::{GroupBox, GroupBoxVariants};
use ui::nav::Nav;
use ui::scroll::ScrollableElement;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use ui::Avatar;
use super::browse;
use crate::dock::add_panel_to;
use crate::nav::Nav;
use crate::sidebar::open_room;
/// How many requests and follow ups to show per section.
@@ -44,7 +45,6 @@ pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> En
}
pub struct GreeterPanel {
name: SharedString,
focus_handle: FocusHandle,
dock: WeakEntity<DockArea>,
@@ -98,7 +98,6 @@ impl GreeterPanel {
});
Self {
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
dock,
requests: vec![],
@@ -205,9 +204,9 @@ impl GreeterPanel {
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(px(button_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.rounded(cx.theme().radius)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("No chat requests")
.into_any_element(),
]
@@ -219,7 +218,6 @@ impl GreeterPanel {
let room = room.clone();
let dock = dock.clone();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let preview = preview.clone();
@@ -231,9 +229,9 @@ impl GreeterPanel {
.h_9()
.px_1p5()
.gap_2()
.rounded(px(button_radius()))
.rounded(cx.theme().radius)
.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.hover(|this| this.bg(cx.theme().list_hover))
.on_click({
let room = room.clone();
let dock = dock.clone();
@@ -242,25 +240,18 @@ impl GreeterPanel {
}
})
.child(
Avatar::from_source(picture)
Avatar::new(room.read(cx).display_image(cx))
.seed(seed)
.small()
.flex_shrink_0(),
)
.child(
div()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(name),
.small(),
)
.child(div().min_w_0().truncate().text_sm().child(name))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_sm()
.text_color(cx.theme().muted_foreground)
.italic()
.child(preview),
)
@@ -283,8 +274,8 @@ impl GreeterPanel {
.child(
div()
.text_align(gpui::TextAlign::Right)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(created_at),
),
)
@@ -310,11 +301,24 @@ impl GreeterPanel {
.collect()
};
GroupBox::new()
.id("requests")
.title("Requests")
.fill()
.child(v_flex().gap_0().w_full().children(rows))
v_flex()
.gap_2()
.child(
div()
.text_color(cx.theme().muted_foreground)
.text_xs()
.font_semibold()
.child("Requests"),
)
.child(
v_flex()
.px_2()
.py_1()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().group_box)
.text_color(cx.theme().group_box_foreground)
.children(rows),
)
.into_any_element()
}
@@ -333,7 +337,6 @@ impl GreeterPanel {
let room = room.clone();
let dock = dock.clone();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let preview = preview.clone();
@@ -345,9 +348,9 @@ impl GreeterPanel {
.h_9()
.px_1p5()
.gap_2()
.rounded(px(button_radius()))
.rounded(cx.theme().radius)
.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.hover(|this| this.bg(cx.theme().list_hover))
.on_click({
let room = room.clone();
let dock = dock.clone();
@@ -356,25 +359,18 @@ impl GreeterPanel {
}
})
.child(
Avatar::from_source(picture)
Avatar::new(room.read(cx).display_image(cx))
.seed(seed)
.small()
.flex_shrink_0(),
)
.child(
div()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(name),
.small(),
)
.child(div().min_w_0().truncate().text_sm().child(name))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_sm()
.text_color(cx.theme().muted_foreground)
.italic()
.child(preview),
)
@@ -397,8 +393,8 @@ impl GreeterPanel {
.child(
div()
.text_align(gpui::TextAlign::Right)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(created_at),
),
)
@@ -424,11 +420,24 @@ impl GreeterPanel {
.collect();
Some(
GroupBox::new()
.id("follow-ups")
.title("Follow ups")
.fill()
.child(v_flex().gap_0().w_full().children(rows))
v_flex()
.gap_2()
.child(
div()
.text_color(cx.theme().muted_foreground)
.text_xs()
.font_semibold()
.child("Follow ups"),
)
.child(
v_flex()
.px_2()
.py_1()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().group_box)
.text_color(cx.theme().group_box_foreground)
.children(rows),
)
.into_any_element(),
)
}
@@ -439,8 +448,8 @@ impl GreeterPanel {
.w_full()
.child(
div()
.text_color(cx.theme().text_muted)
.text_style(TextStyle::Subheadline)
.text_color(cx.theme().muted_foreground)
.text_xs()
.font_semibold()
.child("Find new friends"),
)
@@ -449,13 +458,9 @@ impl GreeterPanel {
.w_full()
.child(
Nav::new("browse-group")
.prefix(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().text_faint),
)
.prefix(Icon::new(IconName::Compass))
.label("Browse Public Groups")
.text_style(TextStyle::Callout)
.text_sm()
.on_click(cx.listener(|this, _, window, cx| {
let panel = browse::init(this.dock.clone(), window, cx);
add_panel_to(
@@ -469,9 +474,9 @@ impl GreeterPanel {
)
.child(
Nav::new("browse-posts")
.prefix(Icon::new(IconName::Link).small())
.prefix(Icon::new(IconName::Link))
.label("Browse Nostr via Jumble")
.text_style(TextStyle::Callout)
.text_sm()
.on_click(|_, _, cx| {
cx.open_url(JUMBLE_URL);
}),
@@ -480,20 +485,22 @@ impl GreeterPanel {
}
}
impl Panel for GreeterPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
impl gpui_kit::component::dock::BasePanel for GreeterPanel {
fn panel_name(&self) -> &'static str {
"Welcome"
}
}
fn title(&self, cx: &App) -> AnyElement {
impl Panel for GreeterPanel {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1()
.text_style(TextStyle::Caption)
.text_xs()
.child(
svg()
.path("brand/coop.svg")
.size_4()
.text_color(cx.theme().text_muted),
.text_color(cx.theme().muted_foreground),
)
.child("Welcome")
.into_any_element()
@@ -520,7 +527,7 @@ impl Render for GreeterPanel {
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().text_faint),
.text_color(cx.theme().muted_foreground),
)
})
.when(logged_in, |this| {
+14 -12
View File
@@ -1,10 +1,10 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Window,
};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
use gpui_kit::assets::IconName;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<InboxPanel> {
cx.new(|cx| InboxPanel::new(window, cx))
@@ -24,18 +24,20 @@ impl InboxPanel {
}
}
impl Panel for InboxPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
impl gpui_kit::component::dock::BasePanel for InboxPanel {
fn panel_name(&self) -> &'static str {
"Inbox"
}
}
fn title(&self, cx: &App) -> AnyElement {
impl Panel for InboxPanel {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Inbox)
.small()
.text_color(cx.theme().text_faint),
.text_color(cx.theme().muted_foreground),
)
.child(self.name.clone())
.into_any_element()
@@ -55,8 +57,8 @@ impl Render for InboxPanel {
h_flex()
.size_full()
.justify_center()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(self.name.clone())
}
}
+47 -51
View File
@@ -3,22 +3,21 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, ClipboardItem, Context, Entity, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, div, px, relative, uniform_list,
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nip29::Group;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::avatar::Avatar;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::nav::Nav;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use ui::{Avatar, IconName};
use super::Sidebar;
use crate::dialogs::profile;
use crate::nav::Nav;
/// A collapsible section of the group view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -143,34 +142,34 @@ impl Sidebar {
.flex_1()
.min_h_0()
.w_full()
.gap(px(theme::SPACE))
.gap_2()
.child(
h_flex()
.px_2()
.px_4()
.flex_shrink_0()
.justify_between()
.child(
h_flex()
.pl_0p5()
.gap_1()
.flex_shrink_1()
.child(Avatar::from_source(picture).seed(seed).small())
.child(Avatar::new(picture).seed(seed.clone()).small())
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
div()
.text_style(TextStyle::Caption)
.text_xs()
.truncate()
.line_height(relative(1.2))
.line_height(relative(1.125))
.font_semibold()
.child(name),
)
.child(
div()
.text_style(TextStyle::Caption2)
.text_color(cx.theme().text_muted)
.text_size(px(8.0))
.font_medium()
.text_color(cx.theme().muted_foreground)
.child(total_members),
),
),
@@ -182,28 +181,17 @@ impl Sidebar {
),
)
.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(),
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)
}),
)
.child(Scrollbar::vertical(&self.group_scroll)),
.h_full()
.px_2(),
),
)
.into_any_element()
}
@@ -242,10 +230,14 @@ impl Sidebar {
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().text_faint))
.text_style(TextStyle::Caption)
.suffix(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
.text_xs()
.font_semibold()
.text_color(cx.theme().text_faint)
.text_color(cx.theme().muted_foreground)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
GroupSection::Admins => this.admins_open = !this.admins_open,
@@ -277,29 +269,32 @@ impl Sidebar {
.current_user()
.is_some_and(|me| me == public_key);
Nav::new(public_key.to_hex())
let nav = Nav::new(public_key.to_hex())
.label(person.name())
.text_style(TextStyle::Callout)
.font_medium()
.text_sm()
.clickable(true)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
.small(),
)
})
.when(!roles.is_empty(), |this| {
this.suffix(
div()
.font_normal()
.truncate()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(roles.join(", "))),
)
})
.dropdown_menu(move |menu, _window, _cx| {
});
let row = div()
.id(public_key.to_hex())
.w_full()
.child(nav)
.context_menu(move |menu, _window, _cx| {
let menu = menu
.item(
PopupMenuItem::new("View profile").on_click(move |_, window, cx| {
@@ -338,7 +333,8 @@ impl Sidebar {
nip29_ui::remove_member(group.clone(), public_key, window, cx);
}
}))
})
.into_any_element()
});
row.into_any_element()
}
}
+108 -75
View File
@@ -2,17 +2,22 @@ use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
Stateful, Styled, div, px,
Stateful, Styled, Window, div, px,
};
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex,
};
use nostr_sdk::prelude::*;
use theme::TABBAR_HEIGHT;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::menu::DropdownMenu;
use ui::{Disableable, IconName, Selectable, Sizable, TRAFFIC_LIGHT_PADDING, h_flex};
use state::NostrRegistry;
use ui::{Avatar, IconName};
use super::Sidebar;
use crate::Command;
use crate::dock::TAB_BAR_HEIGHT;
use crate::nav::Nav;
impl Sidebar {
pub(super) fn render_user(
@@ -23,30 +28,22 @@ impl Sidebar {
let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed();
h_flex()
.id("sidebar-user")
.w_full()
.h(TABBAR_HEIGHT)
.h(TAB_BAR_HEIGHT)
.flex_shrink_0()
.items_center()
.gap(px(theme::SPACE))
.justify_between()
.px_2()
.when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
.child(
Button::new("current-user")
.child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
.small(),
)
.small()
.caret()
.compact()
.transparent()
.text()
.dropdown_caret(true)
.min_w_0()
.flex_shrink_1()
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.dropdown_menu(move |this, _window, _cx| {
this.menu_with_icon(
"Profile",
@@ -55,66 +52,80 @@ impl Sidebar {
)
.menu_with_icon("Relays", IconName::Relay, Box::new(Command::ShowRelayList))
.separator()
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
.item(
PopupMenuItem::element(|_, cx| {
div().text_color(cx.theme().danger).child("Log out")
})
.on_click(|_, window, cx| {
open_logout_dialog(window, cx);
}),
)
}),
)
.child(div().flex_1())
.when_some(updater, |this, updater| {
this.child(self.render_updater(updater, cx))
})
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
.tooltip("Search")
.selected(self.search_open)
.ghost()
.small()
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_search(window, cx);
})),
h_flex()
.gap_1()
.when_some(updater, |this, updater| {
this.child(self.render_updater(updater, cx))
})
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
.tooltip("Search")
.selected(self.search_open)
.ghost()
.small()
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_search(window, cx);
})),
)
.child(
Button::new("sidebar-new")
.icon(IconName::PlusChat)
.tooltip("New")
.ghost()
.small()
.dropdown_menu(|menu, _window, _cx| {
menu.label("Public Group")
.menu("New Group", Box::new(Command::NewGroup))
.menu("Join Group", Box::new(Command::JoinGroup))
.separator()
.menu("New Chat", Box::new(Command::NewChat))
}),
)
.when(self.group_open, |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
.tooltip("Back")
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.hide_group(cx);
})),
)
}),
)
}
/// The bottom navigation, pinned below the list like signed's sidebar.
pub(super) fn render_footer(&self, _cx: &mut Context<Self>) -> Div {
v_flex()
.p_2()
.flex_shrink_0()
.gap_1()
.items_start()
.justify_start()
.child(
Button::new("sidebar-new")
.icon(IconName::PlusChat)
.tooltip("New")
.ghost()
.small()
.dropdown_menu(|menu, _window, _cx| {
menu.label("Public Group")
.menu_with_icon(
"New Group",
IconName::Group,
Box::new(Command::NewGroup),
)
.menu_with_icon(
"Join Group",
IconName::Door,
Box::new(Command::JoinGroup),
)
.separator()
.menu_with_icon(
"New Chat",
IconName::Message,
Box::new(Command::NewChat),
)
Nav::new("sidebar-settings")
.prefix(Icon::new(IconName::Settings).small())
.center_prefix(true)
.label("Settings")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowSettings), cx)
}),
)
.when(self.group_open, |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
.tooltip("Back")
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.hide_group(cx);
})),
)
})
}
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
@@ -123,7 +134,7 @@ impl Sidebar {
let staged = updater.read(cx).staged();
h_flex()
.gap(px(theme::SPACE))
.gap_2()
.when(!up_to_date, |this| {
this.child(
Button::new("update")
@@ -151,3 +162,25 @@ impl Sidebar {
.into_any_element()
}
}
/// Confirm signing out, then drop the stored credential like signed's exit dialog.
fn open_logout_dialog(window: &mut Window, cx: &mut App) {
const DESCRIPTION: &str = "Are you sure you want to log out? You will need to re-enter your credentials to sign back in.";
window.open_alert_dialog(cx, |alert, _, _| {
alert
.title("Log Out?")
.description(DESCRIPTION)
.button_props(
DialogButtonProps::default()
.ok_text("Log out")
.ok_variant(ButtonVariant::Danger)
.show_cancel(true),
)
.on_ok(|_, _, cx| {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |nostr, cx| nostr.sign_out(cx));
true
})
});
}
+13 -12
View File
@@ -4,13 +4,13 @@ use gpui::{
AnyElement, App, AppContext, Context, IntoElement, ParentElement, SharedString, Styled, Task,
Window, div,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockPlacement, PanelHandle};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, PanelHandle};
use ui::notification::Notification;
use ui::{StyledExt, WindowExtension, h_flex, v_flex};
use super::Sidebar;
use super::rows::SidebarRow;
@@ -127,7 +127,7 @@ impl Sidebar {
})?;
cx.update(|window, cx| {
ui::dock::add_panel_to(
crate::dock::add_panel_to(
&dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
@@ -193,9 +193,8 @@ impl Sidebar {
.h_20()
.w_full()
.px_4()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().bg)
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
Button::new("create-chat")
.label(if self.selected_contacts.len() > 1 {
@@ -211,9 +210,11 @@ impl Sidebar {
.child(
h_flex()
.gap_1()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child("Selected")
.text_xs()
.text_color(cx.theme().muted_foreground)
.text_center()
.justify_center()
.child("Selected:")
.child(
div()
.font_semibold()
+52 -43
View File
@@ -3,19 +3,18 @@ use std::rc::Rc;
use chat::{ChatRegistry, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, px,
uniform_list,
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nip29::GroupsRegistry;
use settings::AppSettings;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
use ui::nav_item::NavItem;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use ui::IconName;
use super::Sidebar;
use super::rows::SidebarRow;
use crate::Command;
use crate::nav::Nav;
/// A collapsible section of the sidebar's main list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -44,12 +43,12 @@ impl Sidebar {
// Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty();
let (list_id, scroll_handle, list_title) = if contacts_open {
("sidebar-contacts", &self.contacts_scroll, Some("Contacts"))
let (list_id, title) = if contacts_open {
("sidebar-contacts", Some("Contacts"))
} else if requests_open {
("sidebar-requests", &self.requests_scroll, Some("Requests"))
("sidebar-requests", Some("Requests"))
} else {
("sidebar-list", &self.list_scroll, None)
("sidebar-list", None)
};
let request_count = ChatRegistry::global(cx)
@@ -61,86 +60,96 @@ impl Sidebar {
.flex_1()
.min_h_0()
.relative()
.gap(px(theme::SPACE))
.child(
v_flex()
.px_2()
.gap_1()
.items_start()
.justify_start()
.child(
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
Nav::new("browse")
.prefix(Icon::new(IconName::Compass).small())
.label("Browse")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
)
.child(
NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
Nav::new("reqs")
.prefix(Icon::new(IconName::Invite).small())
.label("Requests")
.text_sm()
.when(request_count > 0, |this| {
this.suffix(
h_flex()
.gap_1()
.items_center()
.child(
h_flex()
.py_0p5()
.px_1()
.min_w_6()
.justify_center()
.text_style(TextStyle::Caption2)
.text_color(cx.theme().text_muted)
.text_xs()
.font_medium()
.text_color(cx.theme().muted_foreground)
.text_center()
.rounded(px(control_radius()))
.bg(cx.theme().surface_raised)
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.child(SharedString::from(
request_count.to_string(),
)),
)
.when(self.new_requests && !requests_open, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().accent),
div()
.size_1()
.rounded_full()
.bg(cx.theme().primary),
)
}),
)
})
.when(requests_open, |this| this.bg(cx.theme().element_active))
.when(requests_open, |this| this.bg(cx.theme().list_active))
.on_click(cx.listener(|this, _event, _window, cx| {
this.toggle_requests(cx);
})),
)
.child(
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
.when(contacts_open, |this| this.bg(cx.theme().element_active))
Nav::new("contacts")
.prefix(Icon::new(IconName::Book).small())
.label("Contacts")
.text_sm()
.when(contacts_open, |this| this.bg(cx.theme().list_active))
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_contacts(window, cx);
})),
),
)
.when_some(list_title, |this, list_title| {
.when_some(title, |this, title| {
this.child(
div()
h_flex()
.px_4()
.text_style(TextStyle::Caption)
.h_10()
.w_full()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(list_title),
.child(title),
)
})
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
list_id,
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(scroll_handle)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(scroll_handle)),
uniform_list(
list_id,
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.min_h_0()
.flex_1()
.px_2(),
)
.when(create_button, |this| {
this.child(self.render_create_overlay(cx))
+376 -376
View File
@@ -1,376 +1,376 @@
use std::collections::HashSet;
use anyhow::Error;
use chat::{ChatEvent, ChatRegistry};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px,
retain_all,
};
use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry};
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
use ui::dock::{DockArea, Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState};
use ui::notification::Notification;
use ui::{Sizable, StyledExt, WindowExtension, h_flex, title_bar_drag_handlers, v_flex};
mod group_view;
mod header;
mod inbox;
mod list;
mod onboarding;
mod rows;
mod search;
pub(crate) use rows::open_room;
use search::SearchResult;
pub struct Sidebar {
focus_handle: FocusHandle,
list_scroll: UniformListScrollHandle,
dock: WeakEntity<DockArea>,
/// Random background shown behind the signed-out screen.
banner: SharedString,
/// 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 list.
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 main list's pinned section is expanded.
pins_open: bool,
/// Whether the user expanded or collapsed the main list's groups section.
groups_open: Option<bool>,
/// Same as `groups_open` for the chats section.
chats_open: Option<bool>,
/// Whether the main 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>>,
/// Person directory for contacts, search hits, and group members
directory: Entity<Directory>,
/// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list.
contacts_scroll: UniformListScrollHandle,
/// Whether the main list is showing chat requests instead of chats and groups.
requests_open: bool,
new_requests: bool,
requests_scroll: UniformListScrollHandle,
/// Whether the search bar is shown below the header.
search_open: bool,
/// Whether searches also cover global (relay) profiles.
global_open: bool,
/// Debounced delay for the global search while typing.
find_debouncer: DebouncedDelay<Self>,
/// Search input state.
find_input: Entity<InputState>,
/// Search results: rooms and groups by name, then messages by content.
results: Vec<SearchResult>,
/// Whether a global (relay) search is in progress.
global_finding: bool,
/// Profiles found by the last global search, if one was triggered.
global_results: Option<Vec<PublicKey>>,
/// The async global profile search.
global_task: Option<Task<()>>,
/// Scroll state of the search results list.
search_scroll: UniformListScrollHandle,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl Sidebar {
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Type to search")
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
this.new_requests = true;
cx.notify();
};
}),
);
subscriptions.push(
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
cx.notify();
}),
);
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);
}
GroupsEvent::Created {
warning: Some(warning),
..
} => {
window.push_notification(Notification::warning(warning.clone()), cx);
}
_ => {}
};
}),
);
subscriptions.push(
cx.subscribe_in(
&nostr,
window,
|this, state, event, window, cx| match event {
StateEvent::NoSigner => {
this.banner = onboarding::pick_banner();
if this.search_open {
this.toggle_search(window, cx);
}
cx.notify();
}
StateEvent::SignerChanged => {
if let Some(current_user) = state.read(cx).current_user() {
this.directory.update(cx, |directory, cx| {
directory.track([current_user], cx);
});
}
}
_ => {}
},
),
);
subscriptions.push(cx.subscribe_in(
&find_input,
window,
|this, state, event, window, cx| match event {
InputEvent::PressEnter { .. } => {
this.search(cx);
if this.global_open {
this.global_search(cx);
}
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
this.clear_results(cx);
} else {
this.search(cx);
if this.global_open {
this.find_debouncer.fire_new(
Duration::from_millis(FIND_DELAY),
window,
cx,
|this, window, cx| this.debounced_global(window, cx),
);
}
}
}
_ => {}
},
));
Self {
focus_handle: cx.focus_handle(),
list_scroll: UniformListScrollHandle::new(),
dock,
banner: onboarding::pick_banner(),
group: None,
group_open: false,
group_scroll: UniformListScrollHandle::new(),
admins_open: true,
members_open: true,
pins_open: true,
groups_open: None,
chats_open: None,
contacts_open: false,
contacts: None,
directory: Directory::new(cx),
selected_contacts: HashSet::new(),
contacts_scroll: UniformListScrollHandle::new(),
requests_open: false,
requests_scroll: UniformListScrollHandle::new(),
new_requests: false,
search_open: false,
global_open: false,
find_debouncer: DebouncedDelay::new(),
find_input,
results: Vec::new(),
global_finding: false,
global_results: None,
global_task: None,
search_scroll: UniformListScrollHandle::new(),
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// 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);
}
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
"Sidebar".into()
}
fn title(&self, _cx: &App) -> AnyElement {
SharedString::from("Sidebar").into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
false
}
fn zoomable(&self, _cx: &App) -> bool {
false
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
let logged_in = current_user.is_some();
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
let group = self.group.clone().and_then(|group| group.upgrade());
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
.relative()
.gap(px(theme::SPACE))
.bg(cx.theme().surface)
.when_some(current_user.as_ref(), |this, current_user| {
this.child(title_bar_drag_handlers(
self.render_user(current_user, cx),
window,
cx,
))
})
.when(self.search_open, |this| this.child(self.render_find(cx)))
.when(!logged_in, |this| {
this.relative()
.child(title_bar_drag_handlers(
div()
.id("onboarding-drag")
.absolute()
.top_0()
.left_0()
.h(TABBAR_HEIGHT)
.w_full(),
window,
cx,
))
.child(
div().absolute().inset_0().child(
img(self.banner.clone())
.size_full()
.object_fit(ObjectFit::Cover),
),
)
.child(onboarding::render_signed_out(window, cx))
})
.map(|this| match group {
Some(group) if self.group_open && !searching => {
this.child(self.render_group(group, cx))
}
_ if searching => this.child(self.render_results(cx)),
_ => this.child(self.render_list(cx)),
})
.when(loading && logged_in, |this| {
this.child(
div()
.absolute()
.bottom_4()
.left_0()
.h_9()
.w_full()
.px_8()
.child(
h_flex()
.gap(px(theme::SPACE))
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().bg.opacity(0.85))
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded_full()
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().accent))
.child("Getting messages..."),
),
)
})
.into_any_element()
}
}
use std::collections::HashSet;
use anyhow::Error;
use chat::{ChatEvent, ChatRegistry};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
Task, WeakEntity, Window, div, img, retain_all,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
use gpui_kit::component::input::{InputEvent, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::spinner::Spinner;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry};
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers;
use crate::dock::TAB_BAR_HEIGHT;
mod group_view;
mod header;
mod inbox;
mod list;
mod onboarding;
mod rows;
mod search;
pub(crate) use rows::open_room;
use search::SearchResult;
pub struct Sidebar {
focus_handle: FocusHandle,
dock: WeakEntity<DockArea>,
/// Random background shown behind the signed-out screen.
banner: SharedString,
/// 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 list.
group_open: bool,
/// 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 main list's pinned section is expanded.
pins_open: bool,
/// Whether the user expanded or collapsed the main list's groups section.
groups_open: Option<bool>,
/// Same as `groups_open` for the chats section.
chats_open: Option<bool>,
/// Whether the main 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>>,
/// Person directory for contacts, search hits, and group members
directory: Entity<Directory>,
/// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>,
/// Whether the main list is showing chat requests instead of chats and groups.
requests_open: bool,
new_requests: bool,
/// Whether the search bar is shown below the header.
search_open: bool,
/// Whether searches also cover global (relay) profiles.
global_open: bool,
/// Debounced delay for the global search while typing.
find_debouncer: DebouncedDelay<Self>,
/// Search input state.
find_input: Entity<InputState>,
/// Search results: rooms and groups by name, then messages by content.
results: Vec<SearchResult>,
/// Whether a global (relay) search is in progress.
global_finding: bool,
/// Profiles found by the last global search, if one was triggered.
global_results: Option<Vec<PublicKey>>,
/// The async global profile search.
global_task: Option<Task<()>>,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl Sidebar {
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Type to search")
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
this.new_requests = true;
cx.notify();
};
}),
);
subscriptions.push(
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
cx.notify();
}),
);
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);
}
GroupsEvent::Created {
warning: Some(warning),
..
} => {
window.push_notification(Notification::warning(warning.clone()), cx);
}
_ => {}
};
}),
);
subscriptions.push(
cx.subscribe_in(
&nostr,
window,
|this, state, event, window, cx| match event {
StateEvent::NoSigner => {
this.banner = onboarding::pick_banner();
if this.search_open {
this.toggle_search(window, cx);
}
cx.notify();
}
StateEvent::SignerChanged => {
if let Some(current_user) = state.read(cx).current_user() {
this.directory.update(cx, |directory, cx| {
directory.track([current_user], cx);
});
}
}
_ => {}
},
),
);
subscriptions.push(cx.subscribe_in(
&find_input,
window,
|this, state, event, window, cx| match event {
InputEvent::PressEnter { .. } => {
this.search(cx);
if this.global_open {
this.global_search(cx);
}
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
this.clear_results(cx);
} else {
this.search(cx);
if this.global_open {
this.find_debouncer.fire_new(
Duration::from_millis(FIND_DELAY),
window,
cx,
|this, window, cx| this.debounced_global(window, cx),
);
}
}
}
_ => {}
},
));
Self {
focus_handle: cx.focus_handle(),
dock,
banner: onboarding::pick_banner(),
group: None,
group_open: false,
admins_open: true,
members_open: true,
pins_open: true,
groups_open: None,
chats_open: None,
contacts_open: false,
contacts: None,
directory: Directory::new(cx),
selected_contacts: HashSet::new(),
requests_open: false,
new_requests: false,
search_open: false,
global_open: false,
find_debouncer: DebouncedDelay::new(),
find_input,
results: Vec::new(),
global_finding: false,
global_results: None,
global_task: None,
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// 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);
}
}
}
impl gpui_kit::component::dock::BasePanel for Sidebar {
fn panel_name(&self) -> &'static str {
"Sidebar"
}
fn closable(&self, _cx: &App) -> bool {
false
}
fn zoomable(&self, _cx: &App) -> bool {
false
}
}
impl Panel for Sidebar {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
SharedString::from("Sidebar")
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
let logged_in = current_user.is_some();
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
let group = self.group.clone().and_then(|group| group.upgrade());
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
let picking_contacts = self.contacts_open && !self.requests_open;
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
.relative()
.justify_between()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
v_flex()
.flex_1()
.min_h_0()
.when_some(current_user.as_ref(), |this, current_user| {
this.child(title_bar_drag_handlers(
self.render_user(current_user, cx),
window,
cx,
))
})
.when(self.search_open, |this| this.child(self.render_find(cx)))
.when(!logged_in, |this| {
this.child(title_bar_drag_handlers(
div()
.id("onboarding-drag")
.absolute()
.top_0()
.left_0()
.h(TAB_BAR_HEIGHT)
.w_full(),
window,
cx,
))
.child(
div().absolute().inset_0().child(
img(self.banner.clone())
.size_full()
.object_fit(ObjectFit::Cover),
),
)
.child(onboarding::render_signed_out(window, cx))
})
.map(|this| match group {
Some(group) if self.group_open && !searching => {
this.child(self.render_group(group, cx))
}
_ if searching => this.child(self.render_results(cx)),
_ => this.child(self.render_list(cx)),
}),
)
.when(logged_in && !picking_contacts, |this| {
this.child(self.render_footer(cx))
})
.when(loading && logged_in, |this| {
this.child(
div()
.absolute()
.bottom_4()
.left_0()
.h_9()
.w_full()
.px_8()
.child(
h_flex()
.gap_2()
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small().color(cx.theme().primary))
.child("Getting messages..."),
),
)
})
.into_any_element()
}
}
+5 -12
View File
@@ -1,8 +1,8 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
use ui::{Sizable, StyledExt, v_flex};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::v_flex;
use crate::dialogs::import;
@@ -16,11 +16,12 @@ pub(super) fn pick_banner() -> SharedString {
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or_default();
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
SIGNED_OUT_BANNERS[index].into()
}
/// The headline and identity import shown while signed out.
pub(super) fn render_signed_out(window: &mut Window, cx: &mut App) -> AnyElement {
pub(super) fn render_signed_out(_window: &mut Window, _cx: &mut App) -> AnyElement {
v_flex()
.size_full()
.justify_end()
@@ -30,15 +31,7 @@ pub(super) fn render_signed_out(window: &mut Window, cx: &mut App) -> AnyElement
.child(
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()
.primary()
.on_click(|_, window, cx| {
import::open(window, cx);
}),
+332 -343
View File
@@ -4,24 +4,24 @@ use chat::{Room, RoomKind};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity,
Window, div, px,
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement,
SharedString, Styled, WeakEntity, Window, div, px,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
use nip29::Group;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
use ui::dialog::DialogButtonProps;
use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
use ui::menu::{ContextMenu, PopupMenuItem};
use ui::nav::Nav;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Avatar, IconName};
use super::Sidebar;
use super::list::ListSection;
use super::search::MessageHit;
use crate::dialogs::{profile, screening};
use crate::nav::Nav;
/// Open a room in the dock, screening new requests when enabled.
pub(crate) fn open_room(
@@ -30,7 +30,7 @@ pub(crate) fn open_room(
window: &mut Window,
cx: &mut App,
) {
ui::dock::add_panel_to(
crate::dock::add_panel_to(
dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
@@ -42,8 +42,9 @@ pub(crate) fn open_room(
let peer = room.read(cx).display_member(cx).public_key();
let view = screening::init(peer, window, cx);
window.open_dialog(cx, move |this, _window, _cx| {
this.confirm()
window.open_alert_dialog(cx, move |alert, _window, _cx| {
alert
.confirm()
.child(view.clone())
.button_props(
DialogButtonProps::default()
@@ -58,337 +59,6 @@ pub(crate) fn open_room(
}
}
impl Sidebar {
pub(super) fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Sidebar>,
) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section(section) => {
let section = *section;
let label = section.label();
let open = match section {
ListSection::Pins => self.pins_open,
ListSection::Groups => self.is_groups_open(cx),
ListSection::Chats => self.is_chats_open(cx),
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().text_muted))
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
ListSection::Pins => this.pins_open = !this.pins_open,
ListSection::Groups => {
this.groups_open = Some(!this.is_groups_open(cx))
}
ListSection::Chats => {
this.chats_open = Some(!this.is_chats_open(cx))
}
}
cx.notify();
}))
.into_any_element()
}
SidebarRow::Heading(label) => {
let label = *label;
Nav::new(label)
.label(label)
.text_style(TextStyle::Caption)
.font_semibold()
.text_color(cx.theme().text_muted)
.into_any_element()
}
SidebarRow::Empty(text) => v_flex()
.h_10()
.w_full()
.px_1p5()
.items_center()
.child(
h_flex()
.h_9()
.w_full()
.justify_center()
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(px(button_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(*text),
)
.into_any_element(),
SidebarRow::Room { room } => {
let id = room.read(cx).id;
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let member = room.read(cx).members().first().copied();
let spam =
room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request;
let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
let dock = self.dock.clone();
let room = room.clone();
let nav = Nav::new(index)
.label(name)
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
})
.suffix(
h_flex()
.gap_1()
.when(spam, |this| {
this.child(
div()
.px_1()
.rounded(px(button_radius()))
.bg(cx.theme().danger.opacity(0.15))
.text_size(px(8.0))
.text_color(cx.theme().danger)
.child("SPAM"),
)
})
.when(!spam, |this| {
this.child(
div()
.font_normal()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(created_at),
)
}),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
});
ContextMenu::new(
format!("room-menu-{index}"),
nav,
move |menu, _window, _cx| {
let menu = menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
.on_click(move |_, _window, cx| {
AppSettings::global(cx).update(cx, |settings, cx| {
settings.toggle_pinned_room(id, cx);
});
}),
);
if let Some(member) = member {
menu.item(PopupMenuItem::new("View profile").on_click(
move |_, window, cx| {
profile::open(member, window, cx);
},
))
} else {
menu
}
},
)
.into_any_element()
}
SidebarRow::Contact { public_key } => {
let person = self.directory.read(cx).person(public_key, cx);
let selected = self.selected_contacts.contains(public_key);
let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned();
Nav::new(index)
.label(person.name())
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.selected(selected)
.on_click(move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.toggle_contact(&public_key, cx);
}) {
log::error!("Failed to select contact: {error}");
}
})
.into_any_element()
}
SidebarRow::Profile { public_key } => {
let person = self.directory.read(cx).person(public_key, cx);
let public_key = public_key.to_owned();
Nav::new(index)
.label(person.name())
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(move |_event, window, cx| {
profile::open(public_key, window, cx);
})
.into_any_element()
}
SidebarRow::Group { group } => {
let dock = self.dock.clone();
let sidebar = cx.entity().downgrade();
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 tag = seed.clone();
let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx);
let weak_group = group.downgrade();
let suffix = group
.read(cx)
.membership()
.status()
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from);
let nav = Nav::new(index)
.label(name)
.text_style(TextStyle::Callout)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
})
.when_some(suffix, |this, suffix| {
this.suffix(
div()
.font_normal()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(suffix),
)
})
.on_click(move |_event, window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
DockPlacement::Center,
window,
cx,
);
if let Err(error) = sidebar.update(cx, |this, cx| {
this.show_group(&group, cx);
}) {
log::error!("Failed to show the group in the sidebar: {error}");
}
});
ContextMenu::new(
format!("group-menu-{index}"),
nav,
move |menu, _window, _cx| {
menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
.on_click({
let tag = tag.clone();
move |_, _window, cx| {
AppSettings::global(cx).update(
cx,
|settings, cx| {
settings.toggle_pinned_group(&tag, cx);
},
);
}
}),
)
.item(
PopupMenuItem::new("Leave").on_click({
let group = weak_group.clone();
move |_, window, cx| {
nip29_ui::confirm_leave(group.clone(), window, cx);
}
}),
)
},
)
.into_any_element()
}
SidebarRow::Message { hit } => {
let person = self.directory.read(cx).person(&hit.author, cx);
let name = hit.room.read(cx).display_name(cx);
let detail = format!("{} · {}", name, hit.created_at.to_ago());
let dock = self.dock.clone();
let room = hit.room.clone();
let content = hit.content.clone();
Nav::new(index)
.label(content)
.text_style(TextStyle::Callout)
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.suffix(
div()
.font_normal()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(detail),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
})
.into_any_element()
}
}
})
.collect()
}
}
/// A row shown in the sidebar's lists.
pub(super) enum SidebarRow {
Section(ListSection),
@@ -412,3 +82,322 @@ pub(super) enum SidebarRow {
Heading(&'static str),
Empty(&'static str),
}
impl Sidebar {
pub(super) fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Sidebar>,
) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
let element = match row {
SidebarRow::Section(section) => {
let section = *section;
let label = section.label();
let open = match section {
ListSection::Pins => self.pins_open,
ListSection::Groups => self.is_groups_open(cx),
ListSection::Chats => self.is_chats_open(cx),
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
Nav::new(label)
.label(label)
.suffix(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
ListSection::Pins => this.pins_open = !this.pins_open,
ListSection::Groups => {
this.groups_open = Some(!this.is_groups_open(cx))
}
ListSection::Chats => {
this.chats_open = Some(!this.is_chats_open(cx))
}
}
cx.notify();
}))
.into_any_element()
}
SidebarRow::Heading(label) => {
let label = *label;
Nav::new(label)
.label(label)
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.into_any_element()
}
SidebarRow::Empty(text) => h_flex()
.h_8()
.w_full()
.px_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(*text)
.into_any_element(),
SidebarRow::Room { room } => {
let id = room.read(cx).id;
let name = room.read(cx).display_name(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let member = room.read(cx).members().first().copied();
let spam =
room.read(cx).is_spam(cx) && room.read(cx).kind == RoomKind::Request;
let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx);
let dock = self.dock.clone();
let room = room.clone();
let nav = Nav::new(index)
.label(name)
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(room.read(cx).display_image(cx))
.seed(seed)
.small(),
)
})
.suffix(
h_flex()
.gap_1()
.when(spam, |this| {
this.child(
div()
.px_1()
.rounded(cx.theme().radius)
.bg(cx.theme().danger.opacity(0.15))
.text_size(px(8.0))
.text_color(cx.theme().danger)
.child("SPAM"),
)
})
.when(!spam, |this| {
this.child(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(created_at),
)
}),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
});
div()
.id(index)
.w_full()
.child(nav)
.context_menu(move |menu, _window, _cx| {
let menu = menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
.on_click(move |_, _window, cx| {
AppSettings::global(cx).update(cx, |settings, cx| {
settings.toggle_pinned_room(id, cx);
});
}),
);
if let Some(member) = member {
menu.item(PopupMenuItem::new("View profile").on_click(
move |_, window, cx| {
profile::open(member, window, cx);
},
))
} else {
menu
}
})
.into_any_element()
}
SidebarRow::Contact { public_key } => {
let person = self.directory.read(cx).person(public_key, cx);
let selected = self.selected_contacts.contains(public_key);
let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned();
Nav::new(index)
.label(person.name())
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.selected(selected)
.on_click(move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.toggle_contact(&public_key, cx);
}) {
log::error!("Failed to select contact: {error}");
}
})
.into_any_element()
}
SidebarRow::Profile { public_key } => {
let person = self.directory.read(cx).person(public_key, cx);
let public_key = public_key.to_owned();
Nav::new(index)
.label(person.name())
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.on_click(move |_event, window, cx| {
profile::open(public_key, window, cx);
})
.into_any_element()
}
SidebarRow::Group { group } => {
let dock = self.dock.clone();
let sidebar = cx.entity().downgrade();
let group = group.clone();
let name = group.read(cx).display_name();
let seed = group.read(cx).key().cache_tag();
let tag = seed.clone();
let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx);
let weak_group = group.downgrade();
let suffix = group
.read(cx)
.membership()
.status()
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from);
let nav = Nav::new(index)
.label(name)
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(group.read(cx).display_image())
.seed(seed)
.small(),
)
})
.when_some(suffix, |this, suffix| {
this.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(suffix),
)
})
.on_click(move |_event, window, cx| {
crate::dock::add_panel_to(
&dock,
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
DockPlacement::Center,
window,
cx,
);
if let Err(error) = sidebar.update(cx, |this, cx| {
this.show_group(&group, cx);
}) {
log::error!("Failed to show the group in the sidebar: {error}");
}
});
div()
.id(index)
.w_full()
.child(nav)
.context_menu(move |menu, _window, _cx| {
menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
.on_click({
let tag = tag.clone();
move |_, _window, cx| {
AppSettings::global(cx).update(
cx,
|settings, cx| {
settings.toggle_pinned_group(&tag, cx);
},
);
}
}),
)
.item(
PopupMenuItem::new("Leave").on_click({
let group = weak_group.clone();
move |_, window, cx| {
nip29_ui::confirm_leave(group.clone(), window, cx);
}
}),
)
})
.into_any_element()
}
SidebarRow::Message { hit } => {
let person = self.directory.read(cx).person(&hit.author, cx);
let name = hit.room.read(cx).display_name(cx);
let detail = format!("{} · {}", name, hit.created_at.to_ago());
let dock = self.dock.clone();
let room = hit.room.clone();
let content = hit.content.clone();
Nav::new(index)
.label(content)
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small(),
)
})
.suffix(
div()
.font_normal()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(detail),
)
.on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx);
})
.into_any_element()
}
};
// Keep a small gap between rows so selected rows read as pills.
div().pb_0p5().child(element).into_any_element()
})
.collect()
}
}
+20 -25
View File
@@ -4,16 +4,15 @@ use std::rc::Rc;
use chat::{ChatRegistry, Room};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
Window, div, px, uniform_list,
Window, div, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::Input;
use gpui_kit::component::{ActiveTheme, Selectable, Sizable, h_flex, v_flex};
use nip29::{Group, GroupsRegistry};
use nostr_sdk::prelude::*;
use state::{FIND_LIMIT, NostrRegistry};
use theme::{ActiveTheme, button_radius};
use ui::button::{Button, ButtonVariants};
use ui::input::Input;
use ui::scroll::Scrollbar;
use ui::{IconName, Selectable, Sizable, h_flex, v_flex};
use super::Sidebar;
use super::rows::SidebarRow;
@@ -206,6 +205,7 @@ impl Sidebar {
pub(super) fn render_find(&self, cx: &mut Context<Self>) -> AnyElement {
h_flex()
.mb_2()
.px_2()
.flex_shrink_0()
.w_full()
@@ -214,9 +214,9 @@ impl Sidebar {
.px_1p5()
.gap_1()
.w_full()
.rounded(px(button_radius()))
.bg(cx.theme().surface_raised)
.child(Input::new(&self.find_input).appearance(false).small())
.rounded(cx.theme().radius)
.bg(cx.theme().input_background())
.child(Input::new(&self.find_input).appearance(false))
.child(
Button::new("sidebar-global")
.icon(IconName::Compass)
@@ -225,7 +225,7 @@ impl Sidebar {
} else {
"Enable global search"
})
.small()
.xsmall()
.ghost()
.selected(self.global_open)
.loading(self.global_finding)
@@ -245,22 +245,17 @@ impl Sidebar {
.min_h_0()
.relative()
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
"sidebar-search",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(&self.search_scroll)
.h_full()
.px_2(),
div().min_h_0().flex_1().child(
uniform_list(
"sidebar-search",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
}),
)
.child(Scrollbar::vertical(&self.search_scroll)),
.h_full()
.px_2(),
),
)
.into_any_element()
}