feat: add onboarding dialog
This commit is contained in:
@@ -28,6 +28,14 @@ pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
|||||||
/// Built-in image shown behind chat and group panels
|
/// Built-in image shown behind chat and group panels
|
||||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||||
|
|
||||||
|
fn default_onboarded() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shadow() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// The user's appearance preference, persisted with the rest of the settings.
|
/// The user's appearance preference, persisted with the rest of the settings.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub enum AppearanceMode {
|
pub enum AppearanceMode {
|
||||||
@@ -78,6 +86,9 @@ pub struct ThemeSettings {
|
|||||||
pub radius: Option<f32>,
|
pub radius: Option<f32>,
|
||||||
/// Corner radius for large elements like dialogs; follows the OS theme when unset
|
/// Corner radius for large elements like dialogs; follows the OS theme when unset
|
||||||
pub radius_lg: Option<f32>,
|
pub radius_lg: Option<f32>,
|
||||||
|
/// Draw soft shadows on inputs, buttons and dialogs
|
||||||
|
#[serde(default = "default_shadow")]
|
||||||
|
pub shadow: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ThemeSettings {
|
impl Default for ThemeSettings {
|
||||||
@@ -87,6 +98,7 @@ impl Default for ThemeSettings {
|
|||||||
focus_ring: false,
|
focus_ring: false,
|
||||||
radius: None,
|
radius: None,
|
||||||
radius_lg: None,
|
radius_lg: None,
|
||||||
|
shadow: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +157,12 @@ pub struct Settings {
|
|||||||
/// Hide the notice that messages in public groups are not encrypted
|
/// Hide the notice that messages in public groups are not encrypted
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
|
/// Enable the NIP-60 wallet
|
||||||
|
#[serde(default)]
|
||||||
|
pub wallet: bool,
|
||||||
|
/// Whether the first-run onboarding has been completed
|
||||||
|
#[serde(default = "default_onboarded")]
|
||||||
|
pub onboarded: bool,
|
||||||
/// Tuning applied on top of the native OS theme
|
/// Tuning applied on top of the native OS theme
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub theme: ThemeSettings,
|
pub theme: ThemeSettings,
|
||||||
@@ -178,10 +196,12 @@ impl Default for Settings {
|
|||||||
appearance_mode: AppearanceMode::default(),
|
appearance_mode: AppearanceMode::default(),
|
||||||
hide_avatar: false,
|
hide_avatar: false,
|
||||||
screening: true,
|
screening: true,
|
||||||
wot_auto_trust: true,
|
wot_auto_trust: false,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
nip4e_rotation_days: None,
|
nip4e_rotation_days: None,
|
||||||
hide_group_notice: false,
|
hide_group_notice: false,
|
||||||
|
wallet: false,
|
||||||
|
onboarded: false,
|
||||||
theme: ThemeSettings::default(),
|
theme: ThemeSettings::default(),
|
||||||
theme_light: None,
|
theme_light: None,
|
||||||
theme_dark: None,
|
theme_dark: None,
|
||||||
@@ -222,6 +242,9 @@ macro_rules! setting_accessors {
|
|||||||
inner.$field = value;
|
inner.$field = value;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
// Notify the handle too, so observers of AppSettings
|
||||||
|
// react to the change
|
||||||
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +280,8 @@ setting_accessors! {
|
|||||||
pub panel_background_path: Option<PathBuf>,
|
pub panel_background_path: Option<PathBuf>,
|
||||||
pub theme_light: Option<String>,
|
pub theme_light: Option<String>,
|
||||||
pub theme_dark: Option<String>,
|
pub theme_dark: Option<String>,
|
||||||
|
pub wallet: bool,
|
||||||
|
pub onboarded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
setting_getters! {
|
setting_getters! {
|
||||||
@@ -314,6 +339,8 @@ impl AppSettings {
|
|||||||
edit(&mut inner.theme);
|
edit(&mut inner.theme);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
// Notify the handle too, so observers of AppSettings react
|
||||||
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +353,7 @@ impl AppSettings {
|
|||||||
let styled = Theme::global_mut(cx);
|
let styled = Theme::global_mut(cx);
|
||||||
styled.font_size = px(tuning.font_size);
|
styled.font_size = px(tuning.font_size);
|
||||||
styled.focus_ring = tuning.focus_ring;
|
styled.focus_ring = tuning.focus_ring;
|
||||||
|
styled.shadow = tuning.shadow;
|
||||||
if let Some(radius) = tuning.radius {
|
if let Some(radius) = tuning.radius {
|
||||||
styled.radius = px(radius);
|
styled.radius = px(radius);
|
||||||
}
|
}
|
||||||
@@ -582,6 +610,11 @@ fn persisted_content() -> Option<String> {
|
|||||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the persisted onboarding flag synchronously.
|
||||||
|
pub fn persisted_onboarded() -> bool {
|
||||||
|
persisted_settings().is_some_and(|settings| settings.onboarded)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||||
persisted_settings()
|
persisted_settings()
|
||||||
.map(|settings| settings.appearance_mode)
|
.map(|settings| settings.appearance_mode)
|
||||||
@@ -672,6 +705,40 @@ mod tests {
|
|||||||
assert_eq!(settings.theme, ThemeSettings::default());
|
assert_eq!(settings.theme, ThemeSettings::default());
|
||||||
assert_eq!(settings.theme_light, None);
|
assert_eq!(settings.theme_light, None);
|
||||||
assert_eq!(settings.theme_dark, None);
|
assert_eq!(settings.theme_dark, None);
|
||||||
|
|
||||||
|
// Files written before onboarding and the wallet flag existed count
|
||||||
|
// as onboarded with the wallet off
|
||||||
|
assert!(settings.onboarded);
|
||||||
|
assert!(!settings.wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh installs start un-onboarded with optional features off, and the
|
||||||
|
/// flags round-trip through serde
|
||||||
|
#[test]
|
||||||
|
fn onboarding_and_wallet_persist() {
|
||||||
|
let settings = Settings::default();
|
||||||
|
assert!(!settings.onboarded);
|
||||||
|
assert!(!settings.wallet);
|
||||||
|
assert!(!settings.wot_auto_trust);
|
||||||
|
assert!(settings.theme.shadow);
|
||||||
|
|
||||||
|
let settings: Settings = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"appearance_mode": "Dark",
|
||||||
|
"hide_avatar": false,
|
||||||
|
"screening": true,
|
||||||
|
"wot_auto_trust": true,
|
||||||
|
"nip4e": false,
|
||||||
|
"trusted_relays": [],
|
||||||
|
"file_server": "https://nostr.download/",
|
||||||
|
"onboarded": true,
|
||||||
|
"wallet": true
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(settings.onboarded);
|
||||||
|
assert!(settings.wallet);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The theme selection round-trips through serde.
|
/// The theme selection round-trips through serde.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod import;
|
|||||||
pub mod join_group;
|
pub mod join_group;
|
||||||
pub mod new_chat;
|
pub mod new_chat;
|
||||||
pub mod new_group;
|
pub mod new_group;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod relays;
|
pub mod relays;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext, Context, Div, Entity, Hsla, InteractiveElement, IntoElement,
|
||||||
|
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||||
|
div, px,
|
||||||
|
};
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::dialog::{Confirm, DialogButtonProps, DialogFooter};
|
||||||
|
use gpui_kit::component::input::{InputEvent, InputState, NumberInput};
|
||||||
|
use gpui_kit::component::separator::Separator;
|
||||||
|
use gpui_kit::component::switch::Switch;
|
||||||
|
use gpui_kit::component::theme::{ThemeColor, try_parse_color};
|
||||||
|
use gpui_kit::component::{
|
||||||
|
ActiveTheme, Theme, ThemeConfig, ThemeRegistry, WindowExt, h_flex, v_flex,
|
||||||
|
};
|
||||||
|
use gpui_kit::prelude::FluentBuilder;
|
||||||
|
use rand::seq::IndexedRandom;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use ui::{settings_block, settings_row};
|
||||||
|
|
||||||
|
use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE};
|
||||||
|
|
||||||
|
/// Open the first-run onboarding dialog
|
||||||
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
|
let view = cx.new(|cx| Onboarding::new(window, cx));
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(640.))
|
||||||
|
.close_button(false)
|
||||||
|
.overlay_closable(false)
|
||||||
|
.keyboard(false)
|
||||||
|
.title("Welcome to Coop")
|
||||||
|
.child(view.clone())
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.ok_text("Get started")
|
||||||
|
.show_cancel(false)
|
||||||
|
.on_ok(|_, _, cx| {
|
||||||
|
AppSettings::update_onboarded(true, cx);
|
||||||
|
// Returning true closes the dialog
|
||||||
|
true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.footer(
|
||||||
|
DialogFooter::new().child(
|
||||||
|
Button::new("get-started")
|
||||||
|
.label("Get started")
|
||||||
|
.primary()
|
||||||
|
.on_click(|_, window, cx| {
|
||||||
|
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Onboarding {
|
||||||
|
font_size_input: Entity<InputState>,
|
||||||
|
radius_input: Entity<InputState>,
|
||||||
|
light_themes: Vec<Rc<ThemeConfig>>,
|
||||||
|
dark_themes: Vec<Rc<ThemeConfig>>,
|
||||||
|
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Onboarding {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let theme = AppSettings::get_theme(cx);
|
||||||
|
let (light_themes, dark_themes) = Self::sample_themes(cx);
|
||||||
|
|
||||||
|
let font_size_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.default_value(theme.font_size.to_string())
|
||||||
|
.step(1.)
|
||||||
|
.min(FONT_SIZE_RANGE.0 as f64)
|
||||||
|
.max(FONT_SIZE_RANGE.1 as f64)
|
||||||
|
});
|
||||||
|
|
||||||
|
let radius_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.default_value(
|
||||||
|
theme
|
||||||
|
.radius
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.placeholder("System")
|
||||||
|
.step(1.)
|
||||||
|
.min(RADIUS_RANGE.0 as f64)
|
||||||
|
.max(RADIUS_RANGE.1 as f64)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push({
|
||||||
|
let state = font_size_input.clone();
|
||||||
|
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
|
||||||
|
if !matches!(event, InputEvent::Change) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(font_size) = Self::number_at(&state, FONT_SIZE_RANGE, cx) {
|
||||||
|
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// One radius control tunes both radii so the whole UI stays coherent
|
||||||
|
subscriptions.push({
|
||||||
|
let state = radius_input.clone();
|
||||||
|
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
|
||||||
|
if !matches!(event, InputEvent::Change) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = Self::number_at(&state, RADIUS_RANGE, cx);
|
||||||
|
AppSettings::update_theme(
|
||||||
|
move |theme| {
|
||||||
|
theme.radius = radius;
|
||||||
|
theme.radius_lg = radius;
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
font_size_input,
|
||||||
|
radius_input,
|
||||||
|
light_themes,
|
||||||
|
dark_themes,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number in a theme input, clamped to the range, empty is None
|
||||||
|
fn number_at(state: &Entity<InputState>, range: (f32, f32), cx: &App) -> Option<f32> {
|
||||||
|
let (min, max) = range;
|
||||||
|
state
|
||||||
|
.read(cx)
|
||||||
|
.value()
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.clamp(min, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three light and three dark bundled themes, sampled at random.
|
||||||
|
fn sample_themes(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
|
||||||
|
let active = Theme::global(cx).theme_name().clone();
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
|
||||||
|
let (lights, darks): (Vec<_>, Vec<_>) = ThemeRegistry::global(cx)
|
||||||
|
.sorted_themes()
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.partition(|theme| !theme.mode.is_dark());
|
||||||
|
|
||||||
|
let mut sample = |group: &[Rc<ThemeConfig>]| {
|
||||||
|
let mut picks: Vec<_> = group.sample(&mut rng, 3).cloned().collect();
|
||||||
|
let active_config = group.iter().find(|theme| theme.name == active);
|
||||||
|
let already_sampled = picks.iter().any(|theme| theme.name == active);
|
||||||
|
|
||||||
|
if let (Some(config), Some(slot)) = (active_config, picks.first_mut())
|
||||||
|
&& !already_sampled
|
||||||
|
{
|
||||||
|
*slot = config.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
picks
|
||||||
|
};
|
||||||
|
|
||||||
|
(sample(&lights), sample(&darks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The theme picker: a demo block per sampled bundled theme
|
||||||
|
fn themes_view(&self, cx: &App) -> AnyElement {
|
||||||
|
let active = Theme::global(cx).theme_name().clone();
|
||||||
|
|
||||||
|
settings_block(
|
||||||
|
"Theme",
|
||||||
|
"Customize UI colors. More in Settings.",
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(Self::theme_group("Light", &self.light_themes, &active, cx))
|
||||||
|
.child(Self::theme_group("Dark", &self.dark_themes, &active, cx)),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_group(
|
||||||
|
label: &'static str,
|
||||||
|
themes: &[Rc<ThemeConfig>],
|
||||||
|
active: &SharedString,
|
||||||
|
cx: &App,
|
||||||
|
) -> Div {
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().flex().flex_wrap().gap_2().children(
|
||||||
|
themes
|
||||||
|
.iter()
|
||||||
|
.map(|config| Self::theme_card(config, active, cx)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card painting a mini app mockup in the theme's own colors
|
||||||
|
fn theme_card(config: &Rc<ThemeConfig>, active: &SharedString, cx: &App) -> impl IntoElement {
|
||||||
|
let selected = &config.name == active;
|
||||||
|
let name = config.name.clone();
|
||||||
|
let preview = Self::theme_preview(config);
|
||||||
|
|
||||||
|
let border = if selected {
|
||||||
|
cx.theme().list_active_border
|
||||||
|
} else {
|
||||||
|
preview.border
|
||||||
|
};
|
||||||
|
|
||||||
|
// Square block; the bigger inset keeps the mock window small inside it
|
||||||
|
let block = div()
|
||||||
|
.flex()
|
||||||
|
.w_full()
|
||||||
|
.aspect_square()
|
||||||
|
.p_4()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(preview.background)
|
||||||
|
.border_1()
|
||||||
|
.border_color(border)
|
||||||
|
.hover(|this| this.border_color(cx.theme().border));
|
||||||
|
|
||||||
|
let block = if selected { block.border_2() } else { block };
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.id(SharedString::from(format!("theme-{name}")))
|
||||||
|
.flex_1()
|
||||||
|
.cursor_pointer()
|
||||||
|
.child(
|
||||||
|
block.child(
|
||||||
|
h_flex()
|
||||||
|
.size_full()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
|
.overflow_hidden()
|
||||||
|
.border_1()
|
||||||
|
.border_color(preview.border)
|
||||||
|
.bg(preview.window)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w_1_3()
|
||||||
|
.h_full()
|
||||||
|
.p_1()
|
||||||
|
.justify_between()
|
||||||
|
.bg(preview.sidebar)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(h_flex().gap_0p5().children((0..3).map(|_| {
|
||||||
|
div().size_1().rounded_full().bg(preview.accent)
|
||||||
|
})))
|
||||||
|
.child(Self::demo_bar(preview.bar))
|
||||||
|
.child(Self::demo_bar(preview.bar)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size_2()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(preview.bar.opacity(0.55)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.h_1()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(preview.bar.opacity(0.55)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.h_full()
|
||||||
|
.p_1()
|
||||||
|
.gap_1()
|
||||||
|
.child(Self::demo_bar(preview.bar))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(div().flex_1().h_4().rounded_sm().bg(preview.block))
|
||||||
|
.child(div().flex_1().h_4().rounded_sm().bg(preview.block)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_1()
|
||||||
|
.text_xs()
|
||||||
|
.truncate()
|
||||||
|
.text_color(if selected {
|
||||||
|
cx.theme().foreground
|
||||||
|
} else {
|
||||||
|
cx.theme().muted_foreground
|
||||||
|
})
|
||||||
|
.child(name),
|
||||||
|
)
|
||||||
|
.on_click({
|
||||||
|
let name = config.name.clone();
|
||||||
|
move |_, _window, cx| AppSettings::select_theme(&name, cx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A skeleton text bar for the demo block
|
||||||
|
fn demo_bar(color: Hsla) -> Div {
|
||||||
|
div().w_full().h_1().rounded_full().bg(color.opacity(0.55))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The demo block colors resolved from a theme config.
|
||||||
|
fn theme_preview(config: &ThemeConfig) -> ThemePreview {
|
||||||
|
let defaults = if config.mode.is_dark() {
|
||||||
|
ThemeColor::dark()
|
||||||
|
} else {
|
||||||
|
ThemeColor::light()
|
||||||
|
};
|
||||||
|
let colors = &config.colors;
|
||||||
|
|
||||||
|
let resolve = |value: Option<&SharedString>, fallback: Hsla| {
|
||||||
|
value
|
||||||
|
.and_then(|value| try_parse_color(value).ok())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
};
|
||||||
|
|
||||||
|
ThemePreview {
|
||||||
|
background: resolve(colors.background.as_ref(), defaults.background),
|
||||||
|
border: resolve(colors.border.as_ref(), defaults.border),
|
||||||
|
window: resolve(colors.popover.as_ref(), defaults.popover),
|
||||||
|
sidebar: resolve(colors.secondary.as_ref(), defaults.secondary),
|
||||||
|
bar: resolve(colors.muted_foreground.as_ref(), defaults.muted_foreground),
|
||||||
|
block: resolve(colors.secondary.as_ref(), defaults.secondary),
|
||||||
|
accent: resolve(colors.primary.as_ref(), defaults.primary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colors of a theme's demo block
|
||||||
|
struct ThemePreview {
|
||||||
|
background: Hsla,
|
||||||
|
border: Hsla,
|
||||||
|
window: Hsla,
|
||||||
|
sidebar: Hsla,
|
||||||
|
bar: Hsla,
|
||||||
|
block: Hsla,
|
||||||
|
accent: Hsla,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Onboarding {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const INTRO: &str =
|
||||||
|
"Pick how Coop works and looks. You can change all of this later in Preferences.";
|
||||||
|
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||||
|
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
const WALLET: &str = "Show the wallet and allow ecash payments.";
|
||||||
|
const FONT_SIZE: &str = "The base font size for the interface.";
|
||||||
|
const RADIUS: &str =
|
||||||
|
"Corner radius for UI elements, including dialogs. Empty follows the theme.";
|
||||||
|
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||||
|
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
|
||||||
|
|
||||||
|
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||||
|
let nip4e = AppSettings::get_nip4e(cx);
|
||||||
|
let theme = AppSettings::get_theme(cx);
|
||||||
|
|
||||||
|
let features = v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(settings_row(
|
||||||
|
"Web of Trust",
|
||||||
|
WOT,
|
||||||
|
Switch::new("onboarding-wot")
|
||||||
|
.checked(wot_auto_trust)
|
||||||
|
.on_click(move |checked, _window, cx| {
|
||||||
|
AppSettings::update_wot_auto_trust(*checked, cx);
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Decoupling Encryption Key",
|
||||||
|
NIP4E,
|
||||||
|
Switch::new("onboarding-nip4e").checked(nip4e).on_click(
|
||||||
|
move |checked, _window, cx| {
|
||||||
|
AppSettings::update_nip4e(*checked, cx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let features = features.child(settings_row(
|
||||||
|
"Wallet",
|
||||||
|
WALLET,
|
||||||
|
Switch::new("onboarding-wallet")
|
||||||
|
.checked(AppSettings::get_wallet(cx))
|
||||||
|
.on_click(move |checked, _window, cx| {
|
||||||
|
AppSettings::update_wallet(*checked, cx);
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(INTRO),
|
||||||
|
)
|
||||||
|
.child(features)
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(self.themes_view(cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(settings_row(
|
||||||
|
"Font Size",
|
||||||
|
FONT_SIZE,
|
||||||
|
NumberInput::new(&self.font_size_input).w_24(),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Corner Radius",
|
||||||
|
RADIUS,
|
||||||
|
NumberInput::new(&self.radius_input).w_24(),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Focus Ring",
|
||||||
|
FOCUS_RING,
|
||||||
|
Switch::new("onboarding-focus-ring")
|
||||||
|
.checked(theme.focus_ring)
|
||||||
|
.on_click(move |checked, _window, cx| {
|
||||||
|
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Shadows",
|
||||||
|
SHADOW,
|
||||||
|
Switch::new("onboarding-shadow")
|
||||||
|
.checked(theme.shadow)
|
||||||
|
.on_click(move |checked, _window, cx| {
|
||||||
|
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ use gpui::PathPromptOptions;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui::Task;
|
use gpui::Task;
|
||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
|
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
|
||||||
Subscription, Window, div,
|
Subscription, Window, div,
|
||||||
@@ -28,8 +30,8 @@ use speech::model::{self as speech_model};
|
|||||||
use ui::{settings_block, settings_row};
|
use ui::{settings_block, settings_row};
|
||||||
|
|
||||||
/// Bounds for the theme number inputs
|
/// Bounds for the theme number inputs
|
||||||
const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
|
pub(super) const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
|
||||||
const RADIUS_RANGE: (f32, f32) = (0., 24.);
|
pub(super) const RADIUS_RANGE: (f32, f32) = (0., 24.);
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||||
cx.new(|cx| Preferences::new(window, cx))
|
cx.new(|cx| Preferences::new(window, cx))
|
||||||
@@ -95,7 +97,7 @@ impl Preferences {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
|
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
|
||||||
|
|
||||||
let subscriptions = vec![
|
let mut subscriptions = vec![
|
||||||
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
||||||
if let Some(font_size) = value {
|
if let Some(font_size) = value {
|
||||||
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
|
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
|
||||||
@@ -112,6 +114,12 @@ impl Preferences {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Keep the switches in sync with the settings they edit
|
||||||
|
subscriptions.push({
|
||||||
|
let settings = AppSettings::global(cx);
|
||||||
|
cx.observe(&settings, |_, _, cx| cx.notify())
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
|
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
|
||||||
let timer = Duration::from_millis(500);
|
let timer = Duration::from_millis(500);
|
||||||
@@ -219,26 +227,42 @@ impl Preferences {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
|
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
|
||||||
|
let enabled = AppSettings::get_wallet(cx);
|
||||||
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
|
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
|
||||||
|
|
||||||
settings_block(
|
v_flex()
|
||||||
"Wallet",
|
.w_full()
|
||||||
WALLET,
|
.gap_3()
|
||||||
h_flex()
|
.child(settings_row(
|
||||||
.gap_1()
|
"Wallet",
|
||||||
.child(div().flex_1().child(Input::new(&self.mint_input)))
|
"Show the wallet and allow ecash payments.",
|
||||||
.child(
|
Switch::new("wallet")
|
||||||
Button::new("setup-mint")
|
.checked(enabled)
|
||||||
.label(if has_wallet { "Add" } else { "Set up" })
|
.on_click(move |checked, _window, cx| {
|
||||||
.ghost()
|
AppSettings::update_wallet(*checked, cx);
|
||||||
.size_8()
|
}),
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
cx,
|
||||||
this.setup_mint(window, cx);
|
))
|
||||||
})),
|
.when(enabled, |this| {
|
||||||
),
|
this.child(settings_block(
|
||||||
cx,
|
"Mint",
|
||||||
)
|
WALLET,
|
||||||
.into_any_element()
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(div().flex_1().child(Input::new(&self.mint_input)))
|
||||||
|
.child(
|
||||||
|
Button::new("setup-mint")
|
||||||
|
.label(if has_wallet { "Add" } else { "Set up" })
|
||||||
|
.ghost()
|
||||||
|
.size_8()
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.setup_mint(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +482,7 @@ impl Render for Preferences {
|
|||||||
const RADIUS_LG: &str =
|
const RADIUS_LG: &str =
|
||||||
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
||||||
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||||
|
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
|
||||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||||
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
|
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
|
||||||
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||||
@@ -566,14 +591,22 @@ impl Render for Preferences {
|
|||||||
FOCUS_RING,
|
FOCUS_RING,
|
||||||
Switch::new("focus-ring")
|
Switch::new("focus-ring")
|
||||||
.checked(theme.focus_ring)
|
.checked(theme.focus_ring)
|
||||||
.on_click(move |_, _window, cx| {
|
.on_click(move |checked, _window, cx| {
|
||||||
AppSettings::update_theme(
|
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
|
||||||
|theme| theme.focus_ring = !theme.focus_ring,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
AppSettings::apply_theme_style(cx);
|
AppSettings::apply_theme_style(cx);
|
||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Shadows",
|
||||||
|
SHADOW,
|
||||||
|
Switch::new("shadow").checked(theme.shadow).on_click(
|
||||||
|
move |checked, _window, cx| {
|
||||||
|
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
cx,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::cell::Cell;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ::settings::{AppSettings, AppearanceMode};
|
use ::settings::{AppSettings, AppearanceMode, persisted_onboarded};
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
@@ -30,7 +30,9 @@ use smallvec::{SmallVec, smallvec};
|
|||||||
use state::{NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
use crate::dialogs::restore::RestoreEncryption;
|
||||||
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
|
use crate::dialogs::{
|
||||||
|
import, join_group, new_chat, new_group, onboarding, profile, relays, settings,
|
||||||
|
};
|
||||||
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ impl Workspace {
|
|||||||
let skin = dock::CoopDockSkin::new(cx);
|
let skin = dock::CoopDockSkin::new(cx);
|
||||||
gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin)
|
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 sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
let left_sidebar = sidebar.clone();
|
let left_sidebar = sidebar.clone();
|
||||||
|
|
||||||
@@ -107,10 +110,18 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let login_nostr = nostr.clone();
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the nostr events
|
||||||
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
|
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
|
||||||
StateEvent::SignerChanged => window.close_all_dialogs(cx),
|
StateEvent::SignerChanged => {
|
||||||
|
window.close_all_dialogs(cx);
|
||||||
|
|
||||||
|
// Show the first-run onboarding once the user is signed in
|
||||||
|
if !persisted_onboarded() && login_nostr.read(cx).current_user().is_some() {
|
||||||
|
onboarding::open(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
|
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
@@ -265,6 +276,10 @@ impl Workspace {
|
|||||||
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
||||||
this.set_center(center, window, cx);
|
this.set_center(center, window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if !persisted_onboarded() && nostr.read(cx).current_user().is_some() {
|
||||||
|
onboarding::open(window, cx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -343,9 +358,10 @@ impl Workspace {
|
|||||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||||
}
|
}
|
||||||
Command::ShowWallet => {
|
Command::ShowWallet =>
|
||||||
|
{
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
if AppSettings::get_wallet(cx) {
|
||||||
let panel = nip60_ui::wallet::init(window, cx);
|
let panel = nip60_ui::wallet::init(window, cx);
|
||||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,17 +67,23 @@ impl Sidebar {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.items_start()
|
.items_start()
|
||||||
.justify_start()
|
.justify_start()
|
||||||
.when(cfg!(not(target_arch = "wasm32")), |this| {
|
.when(
|
||||||
this.child(
|
cfg!(not(target_arch = "wasm32")) && AppSettings::get_wallet(cx),
|
||||||
Nav::new("wallet")
|
|this| {
|
||||||
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
|
this.child(
|
||||||
.label("Wallet")
|
Nav::new("wallet")
|
||||||
.text_sm()
|
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
|
||||||
.on_click(|_, window, cx| {
|
.label("Wallet")
|
||||||
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
|
.text_sm()
|
||||||
}),
|
.on_click(|_, window, cx| {
|
||||||
)
|
window.dispatch_action(
|
||||||
})
|
Box::new(crate::Command::ShowWallet),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Nav::new("browse")
|
Nav::new("browse")
|
||||||
.prefix(Icon::new(IconName::Compass).small())
|
.prefix(Icon::new(IconName::Compass).small())
|
||||||
|
|||||||
Reference in New Issue
Block a user