feat: Basic Application Settings (#58)

* .

* .

* .

* update modal
This commit is contained in:
reya
2025-06-13 07:56:59 +07:00
committed by GitHub
parent e687204361
commit cc36adeafe
24 changed files with 1066 additions and 303 deletions

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
global = { path = "../global" }
settings = { path = "../settings" }
gpui.workspace = true
nostr.workspace = true

View File

@@ -9,6 +9,7 @@ use global::shared_state;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use crate::constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE};
use crate::message::Message;
@@ -249,10 +250,12 @@ impl Room {
/// - For a direct message: the other person's avatar
/// - For a group chat: None
pub fn display_image(&self, cx: &App) -> SharedString {
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
if let Some(picture) = self.picture.as_ref() {
picture.clone()
} else if !self.is_group() {
self.first_member(cx).render_avatar()
self.first_member(cx).render_avatar(proxy)
} else {
"brand/group.png".into()
}
@@ -630,6 +633,7 @@ impl Room {
let subject = self.subject.clone();
let picture = self.picture.clone();
let public_keys = Arc::clone(&self.members);
let backup = AppSettings::get_global(cx).settings().backup_messages;
cx.background_spawn(async move {
let signer = shared_state().client.signer().await?;
@@ -697,7 +701,7 @@ impl Room {
}
// Only send a backup message to current user if there are no issues when sending to others
if reports.is_empty() {
if backup && reports.is_empty() {
if let Err(e) = shared_state()
.client
.send_private_msg(*current_user, &content, tags.clone())

View File

@@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use global::constants::NIP96_SERVER;
use gpui::{Image, ImageFormat};
use itertools::Itertools;
use nostr_sdk::prelude::*;
@@ -11,11 +10,14 @@ use qrcode_generator::QrCodeEcc;
pub mod debounced_delay;
pub mod profile;
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
pub async fn nip96_upload(
client: &Client,
upload_to: Url,
file: Vec<u8>,
) -> anyhow::Result<Url, anyhow::Error> {
let signer = client.signer().await?;
let server_url = Url::parse(NIP96_SERVER)?;
let config: ServerConfig = nip96::get_server_config(server_url, None).await?;
let config: ServerConfig = nip96::get_server_config(upload_to.to_owned(), None).await?;
let url = nip96::upload_data(&signer, &config, file, None, None).await?;
Ok(url)

View File

@@ -2,23 +2,29 @@ use global::constants::IMAGE_RESIZE_SERVICE;
use gpui::SharedString;
use nostr_sdk::prelude::*;
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
pub trait RenderProfile {
fn render_avatar(&self) -> SharedString;
fn render_avatar(&self, proxy: bool) -> SharedString;
fn render_name(&self) -> SharedString;
}
impl RenderProfile for Profile {
fn render_avatar(&self) -> SharedString {
fn render_avatar(&self, proxy: bool) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&default=https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png&n=-1",
IMAGE_RESIZE_SERVICE, picture
)
.into()
if proxy {
format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&default={}&n=-1",
IMAGE_RESIZE_SERVICE, picture, FALLBACK_IMG
)
.into()
} else {
picture.into()
}
})
.unwrap_or_else(|| "brand/avatar.png".into())
}

View File

@@ -14,6 +14,7 @@ theme = { path = "../theme" }
common = { path = "../common" }
global = { path = "../global" }
chats = { path = "../chats" }
settings = { path = "../settings" }
auto_update = { path = "../auto_update" }
gpui.workspace = true

View File

@@ -6,8 +6,8 @@ use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, Styled, Subscription, Task, Window,
div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, IntoElement,
ParentElement, Render, Styled, Subscription, Task, Window,
};
use nostr_connect::prelude::*;
use serde::Deserialize;
@@ -20,9 +20,7 @@ use ui::dock_area::{DockArea, DockItem};
use ui::{ContextModal, IconName, Root, Sizable, TitleBar};
use crate::views::chat::{self, Chat};
use crate::views::{
compose, login, new_account, onboarding, profile, relays, sidebar, startup, welcome,
};
use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome};
impl_internal_actions!(dock, [ToggleModal]);
@@ -181,6 +179,17 @@ impl ChatSpace {
});
}
pub fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let settings = preferences::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.title("Preferences")
.width(px(DEFAULT_MODAL_WIDTH))
.child(settings.clone())
});
}
fn titlebar(&mut self, status: bool, cx: &mut Context<Self>) {
self.titlebar = status;
cx.notify();
@@ -206,53 +215,19 @@ impl ChatSpace {
})
}
fn on_modal_action(
&mut self,
action: &ToggleModal,
window: &mut Window,
cx: &mut Context<Self>,
) {
match action.modal {
ModalKind::Profile => {
let profile = profile::init(window, cx);
fn toggle_appearance(&self, window: &mut Window, cx: &mut App) {
if cx.theme().mode.is_dark() {
Theme::change(ThemeMode::Light, Some(window), cx);
} else {
Theme::change(ThemeMode::Dark, Some(window), cx);
}
}
window.open_modal(cx, move |modal, _, _| {
modal
.title("Profile")
.width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone())
})
}
ModalKind::Compose => {
let compose = compose::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.title("Direct Messages")
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
})
}
ModalKind::Relay => {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Edit your Messaging Relays")
.child(relays.clone())
});
}
ModalKind::SetupRelay => {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Your Messaging Relays are not configured")
.child(relays.clone())
});
}
_ => {}
};
fn logout(&self, _window: &mut Window, cx: &mut App) {
cx.background_spawn(async move {
shared_state().unset_signer().await;
})
.detach();
}
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
@@ -310,40 +285,28 @@ impl Render for ChatSpace {
this.icon(IconName::Moon)
}
})
.on_click(cx.listener(|_, _, window, cx| {
if cx.theme().mode.is_dark() {
Theme::change(
ThemeMode::Light,
Some(window),
cx,
);
} else {
Theme::change(
ThemeMode::Dark,
Some(window),
cx,
);
}
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_appearance(window, cx);
})),
)
.child(
Button::new("settings")
.tooltip("Open settings")
Button::new("preferences")
.tooltip("Open Preferences")
.small()
.ghost()
.icon(IconName::Settings),
.icon(IconName::Settings)
.on_click(cx.listener(|this, _, window, cx| {
this.open_settings(window, cx);
})),
)
.child(
Button::new("logout")
.tooltip("Log out")
.tooltip("Log Out")
.small()
.ghost()
.icon(IconName::Logout)
.on_click(cx.listener(move |_, _, _window, cx| {
cx.background_spawn(async move {
shared_state().unset_signer().await;
})
.detach();
.on_click(cx.listener(|this, _, window, cx| {
this.logout(window, cx);
})),
),
),
@@ -356,7 +319,5 @@ impl Render for ChatSpace {
.child(div().absolute().top_8().children(notification_layer))
// Modals
.children(modal_layer)
// Actions
.on_action(cx.listener(Self::on_modal_action))
}
}

View File

@@ -92,6 +92,8 @@ fn main() {
cx.activate(true);
// Initialize components
ui::init(cx);
// Initialize settings
settings::init(cx);
// Initialize auto update
auto_update::init(cx);
// Initialize chat state

View File

@@ -20,6 +20,7 @@ use gpui::{
use itertools::Itertools;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::fs;
use theme::ActiveTheme;
@@ -371,6 +372,7 @@ impl Chat {
self.uploading(true, cx);
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
@@ -389,7 +391,9 @@ impl Chat {
// Spawn task via async utility instead of GPUI context
spawn(async move {
let url = match nip96_upload(&shared_state().client, file_data).await {
let url = match nip96_upload(&shared_state().client, nip96, file_data)
.await
{
Ok(url) => Some(url),
Err(e) => {
log::error!("Upload error: {e}");
@@ -542,6 +546,9 @@ impl Chat {
return div().id(ix);
};
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
let message = message.borrow();
// Message without ID, Author probably the placeholder
@@ -590,7 +597,9 @@ impl Chat {
div()
.flex()
.gap_3()
.child(Avatar::new(author.render_avatar()).size(rems(2.)))
.when(!hide_avatar, |this| {
this.child(Avatar::new(author.render_avatar(proxy)).size(rems(2.)))
})
.child(
div()
.flex_1()

View File

@@ -14,6 +14,7 @@ use gpui::{
};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::Timer;
use theme::ActiveTheme;
@@ -305,6 +306,8 @@ impl Render for Compose {
const DESCRIPTION: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let label: SharedString = if self.selected.read(cx).len() > 1 {
"Create Group DM".into()
} else {
@@ -413,7 +416,7 @@ impl Render for Compose {
.gap_3()
.text_sm()
.child(
img(item.render_avatar())
img(item.render_avatar(proxy))
.size_7()
.flex_shrink_0(),
)

View File

@@ -3,6 +3,7 @@ pub mod compose;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod profile;
pub mod relays;
pub mod sidebar;

View File

@@ -9,6 +9,7 @@ use gpui::{
Styled, Window,
};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
@@ -94,6 +95,7 @@ impl NewAccount {
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
@@ -122,7 +124,9 @@ impl NewAccount {
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
if let Ok(url) = nip96_upload(&shared_state().client, file_data).await {
if let Ok(url) =
nip96_upload(&shared_state().client, nip96, file_data).await
{
_ = tx.send(url);
}
});

View File

@@ -0,0 +1,308 @@
use common::profile::RenderProfile;
use global::{
constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER},
shared_state,
};
use gpui::{
div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context,
Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render,
StatefulInteractiveElement, Styled, Window,
};
use settings::AppSettings;
use theme::ActiveTheme;
use ui::{
avatar::Avatar,
button::{Button, ButtonVariants},
input::{InputState, TextInput},
switch::Switch,
ContextModal, IconName, Sizable, Size, StyledExt,
};
use crate::views::{profile, relays};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
}
pub struct Preferences {
media_input: Entity<InputState>,
focus_handle: FocusHandle,
}
impl Preferences {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let media_server = AppSettings::get_global(cx)
.settings()
.media_server
.to_string();
let media_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(media_server)
.placeholder(NIP96_SERVER)
});
Self {
media_input,
focus_handle: cx.focus_handle(),
}
})
}
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let profile = profile::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.title("Profile")
.width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone())
});
}
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Edit your Messaging Relays")
.child(relays.clone())
});
}
}
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MEDIA_DESCRIPTION: &str = "Coop only supports NIP-96 media servers for now. If you're not sure about it, please keep the default value.";
const BACKUP_DESCRIPTION: &str =
"When a user sends a message, Coop won't back it up to the user's messaging relays";
const TRUSTED_DESCRIPTION: &str = "Show trusted requests by default";
const HIDE_AVATAR_DESCRIPTION: &str =
"Unload all avatar pictures to improve performance and reduce memory usage";
const PROXY_DESCRIPTION: &str =
"Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)";
let input_state = self.media_input.downgrade();
let settings = AppSettings::get_global(cx).settings();
div()
.track_focus(&self.focus_handle)
.size_full()
.px_3()
.pb_3()
.flex()
.flex_col()
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Account"),
)
.when_some(shared_state().identity(), |this, profile| {
this.child(
div()
.w_full()
.flex()
.justify_between()
.items_center()
.child(
div()
.id("current-user")
.flex()
.items_center()
.gap_2()
.child(
Avatar::new(
profile.render_avatar(settings.proxy_user_avatars),
)
.size(rems(2.4)),
)
.child(
div()
.flex_1()
.text_sm()
.child(
div()
.line_height(relative(1.3))
.font_semibold()
.child(profile.render_name()),
)
.child(
div()
.line_height(relative(1.3))
.text_xs()
.text_color(cx.theme().text_muted)
.child("See your profile"),
),
)
.on_click(cx.listener(|this, _, window, cx| {
this.open_profile(window, cx);
})),
)
.child(
Button::new("relays")
.label("DM Relays")
.ghost()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.open_relays(window, cx);
})),
),
)
}),
)
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Media Server"),
)
.child(
div()
.flex()
.items_start()
.gap_1()
.child(TextInput::new(&self.media_input).xsmall())
.child(
Button::new("update")
.icon(IconName::CheckCircleFill)
.ghost()
.with_size(Size::Size(px(26.)))
.on_click(move |_, window, cx| {
if let Some(input) = input_state.upgrade() {
let value = input.read(cx).value();
let Ok(url) = Url::parse(value) else {
window.push_notification("URL is not valid", cx);
return;
};
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.media_server = url;
cx.notify();
});
}
}),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(MEDIA_DESCRIPTION),
),
)
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Messages"),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
Switch::new("backup_messages")
.label("Backup messages")
.description(BACKUP_DESCRIPTION)
.checked(settings.backup_messages)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.backup_messages =
!this.settings.backup_messages;
cx.notify();
})
}),
)
.child(
Switch::new("only_show_trusted")
.label("Only trusted")
.description(TRUSTED_DESCRIPTION)
.checked(settings.only_show_trusted)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.only_show_trusted =
!this.settings.only_show_trusted;
cx.notify();
})
}),
),
),
)
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Display"),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
Switch::new("hide_user_avatars")
.label("Hide user avatars")
.description(HIDE_AVATAR_DESCRIPTION)
.checked(settings.hide_user_avatars)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.hide_user_avatars =
!this.settings.hide_user_avatars;
cx.notify();
})
}),
)
.child(
Switch::new("proxy_user_avatars")
.label("Proxy user avatars")
.description(PROXY_DESCRIPTION)
.checked(settings.proxy_user_avatars)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.proxy_user_avatars =
!this.settings.proxy_user_avatars;
cx.notify();
})
}),
),
),
)
}
}

View File

@@ -10,6 +10,7 @@ use gpui::{
PathPromptOptions, Render, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
@@ -104,6 +105,7 @@ impl Profile {
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
@@ -123,7 +125,9 @@ impl Profile {
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
if let Ok(url) = nip96_upload(&shared_state().client, file_data).await {
if let Ok(url) =
nip96_upload(&shared_state().client, nip96, file_data).await
{
_ = tx.send(url);
}
});

View File

@@ -5,6 +5,7 @@ use gpui::{
div, img, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
};
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::StyledExt;
@@ -59,6 +60,7 @@ impl DisplayRoom {
impl RenderOnce for DisplayRoom {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
self.base
.id(self.ix)
@@ -67,25 +69,27 @@ impl RenderOnce for DisplayRoom {
.gap_2()
.text_sm()
.rounded(cx.theme().radius)
.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.map(|this| {
if let Some(path) = self.img {
this.child(Avatar::new(path).size(rems(1.5)))
} else {
this.child(
img("brand/avatar.png")
.rounded_full()
.size_6()
.into_any_element(),
)
}
}),
)
.when(!hide_avatar, |this| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.map(|this| {
if let Some(path) = self.img {
this.child(Avatar::new(path).size(rems(1.5)))
} else {
this.child(
img("brand/avatar.png")
.rounded_full()
.size_6()
.into_any_element(),
)
}
}),
)
})
.child(
div()
.flex_1()

View File

@@ -8,27 +8,29 @@ use chats::{ChatRegistry, RoomEmitter};
use common::debounced_delay::DebouncedDelay;
use common::profile::RenderProfile;
use element::DisplayRoom;
use global::constants::SEARCH_RELAYS;
use global::constants::{DEFAULT_MODAL_WIDTH, SEARCH_RELAYS};
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
Styled, Subscription, Task, Window,
div, px, rems, uniform_list, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::popup_menu::{PopupMenu, PopupMenuExt};
use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton;
use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt};
use crate::chatspace::{ModalKind, ToggleModal};
use crate::views::compose;
mod element;
@@ -68,6 +70,7 @@ impl Sidebar {
let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None);
let trusted_only = AppSettings::get_global(cx).settings().only_show_trusted;
let find_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
@@ -118,7 +121,7 @@ impl Sidebar {
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
trusted_only: false,
trusted_only,
indicator,
active_filter,
find_input,
@@ -334,7 +337,20 @@ impl Sidebar {
});
}
fn open_compose(&self, window: &mut Window, cx: &mut Context<Self>) {
let compose = compose::init(window, cx);
window.open_modal(cx, move |modal, _window, _cx| {
modal
.title("Direct Messages")
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
});
}
fn render_account(&self, profile: &Profile, cx: &Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
div()
.px_3()
.h_8()
@@ -344,56 +360,38 @@ impl Sidebar {
.items_center()
.child(
div()
.id("current-user")
.flex()
.items_center()
.gap_2()
.text_sm()
.font_semibold()
.child(Avatar::new(profile.render_avatar()).size(rems(1.75)))
.child(profile.render_name()),
.child(Avatar::new(profile.render_avatar(proxy)).size(rems(1.75)))
.child(profile.render_name())
.on_click(cx.listener({
let Ok(public_key) = profile.public_key().to_bech32();
let item = ClipboardItem::new_string(public_key);
move |_, _, window, cx| {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
cx.write_to_primary(item.clone());
#[cfg(any(target_os = "windows", target_os = "macos"))]
cx.write_to_clipboard(item.clone());
window.push_notification("User's NPUB is copied", cx);
}
})),
)
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
Button::new("user")
.icon(IconName::Ellipsis)
.small()
.ghost()
.rounded(ButtonRounded::Full)
.popup_menu(|this, _window, _cx| {
this.menu(
"Profile",
Box::new(ToggleModal {
modal: ModalKind::Profile,
}),
)
.menu(
"Relays",
Box::new(ToggleModal {
modal: ModalKind::Relay,
}),
)
}),
)
.child(
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip("Create DM or Group DM")
.small()
.primary()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Compose,
}),
cx,
);
})),
),
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip("Create DM or Group DM")
.small()
.primary()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(|this, _, window, cx| {
this.open_compose(window, cx);
})),
)
}

View File

@@ -33,7 +33,7 @@ pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.;
/// Image Resize Service
pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
/// NIP96 Media Server.
/// Default NIP96 Media Server.
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048;

View File

@@ -461,7 +461,7 @@ impl Globals {
/// Stores an unwrapped event in local database with reference to original
async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
// Must be use the random generated keys to sign this event
let event = EventBuilder::new(Kind::Custom(30078), event.as_json())
let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
.tags(vec![Tag::identifier(root), Tag::event(root)])
.sign(keys)
.await?;

View File

@@ -0,0 +1,16 @@
[package]
name = "settings"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
serde.workspace = true
serde_json.workspace = true

146
crates/settings/src/lib.rs Normal file
View File

@@ -0,0 +1,146 @@
use anyhow::anyhow;
use global::shared_state;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
let state = cx.new(AppSettings::new);
// Observe for state changes and save settings to database
state.update(cx, |this, cx| {
this.subscriptions
.push(cx.observe(&state, |this, _state, cx| {
this.set_settings(cx);
}));
});
AppSettings::set_global(state, cx);
}
#[derive(Serialize, Deserialize)]
pub struct Settings {
pub media_server: Url,
pub proxy_user_avatars: bool,
pub hide_user_avatars: bool,
pub only_show_trusted: bool,
pub backup_messages: bool,
}
impl AsRef<Settings> for Settings {
fn as_ref(&self) -> &Settings {
self
}
}
struct GlobalAppSettings(Entity<AppSettings>);
impl Global for GlobalAppSettings {}
pub struct AppSettings {
pub settings: Settings,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl AppSettings {
/// Retrieve the Global Settings instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAppSettings>().0.clone()
}
/// Retrieve the Settings instance
pub fn get_global(cx: &App) -> &Self {
cx.global::<GlobalAppSettings>().0.read(cx)
}
/// Set the global Settings instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let settings = Settings {
media_server: Url::parse("https://nostrmedia.com").expect("it's fine"),
proxy_user_avatars: true,
hide_user_avatars: false,
only_show_trusted: false,
backup_messages: true,
};
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, _window, cx| {
this.get_settings_from_db(cx);
}));
Self {
settings,
subscriptions,
}
}
pub fn settings(&self) -> &Settings {
self.settings.as_ref()
}
fn get_settings_from_db(&self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier("coop-settings")
.limit(1);
if let Some(event) = shared_state()
.client
.database()
.query(filter)
.await?
.first_owned()
{
log::info!("Successfully loaded settings from database");
Ok(serde_json::from_str(&event.content)?)
} else {
Err(anyhow!("Not found"))
}
});
cx.spawn(async move |this, cx| {
if let Ok(settings) = task.await {
this.update(cx, |this, cx| {
this.settings = settings;
cx.notify();
})
.ok();
}
})
.detach();
}
fn set_settings(&self, cx: &mut Context<Self>) {
if let Ok(content) = serde_json::to_string(&self.settings) {
cx.background_spawn(async move {
let Some(identity) = shared_state().identity() else {
return;
};
let keys = Keys::generate();
let ident = Tag::identifier("coop-settings");
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(vec![ident])
.build(identity.public_key())
.sign(&keys)
.await
{
if let Err(e) = shared_state().client.database().save_event(&event).await {
log::error!("Failed to save user settings: {e}");
} else {
log::info!("New settings have been saved successfully");
}
}
})
.detach();
}
}
}

View File

@@ -373,6 +373,7 @@ impl Theme {
Self::change(appearance, window, cx);
}
/// Change the app's appearance
pub fn change(mode: impl Into<ThemeMode>, window: Option<&mut Window>, cx: &mut App) {
let mode = mode.into();
let colors = match mode {

View File

@@ -1,54 +1,117 @@
use std::rc::Rc;
use std::time::Duration;
use std::{rc::Rc, time::Duration};
use gpui::prelude::FluentBuilder;
use gpui::{
actions, anchored, div, point, px, relative, Animation, AnimationExt as _, AnyElement, App,
Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, MouseButton,
ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Window,
anchored, div, point, prelude::FluentBuilder, px, relative, Animation, AnimationExt as _,
AnyElement, App, Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement,
KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled,
Window,
};
use theme::ActiveTheme;
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariants as _};
use crate::{v_flex, ContextModal, IconName, StyledExt};
actions!(modal, [Escape]);
use crate::{
actions::{Cancel, Confirm},
animation::cubic_bezier,
button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _},
h_flex, v_flex, ContextModal, IconName, Root, StyledExt,
};
const CONTEXT: &str = "Modal";
pub fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
]);
}
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
type FooterFn =
Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
/// Modal button props.
pub struct ModalButtonProps {
ok_text: Option<SharedString>,
ok_variant: ButtonVariant,
cancel_text: Option<SharedString>,
cancel_variant: ButtonVariant,
}
impl Default for ModalButtonProps {
fn default() -> Self {
Self {
ok_text: None,
ok_variant: ButtonVariant::Primary,
cancel_text: None,
cancel_variant: ButtonVariant::default(),
}
}
}
impl ModalButtonProps {
/// Sets the text of the OK button. Default is `OK`.
pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
self.ok_text = Some(ok_text.into());
self
}
/// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
self.ok_variant = ok_variant;
self
}
/// Sets the text of the Cancel button. Default is `Cancel`.
pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
self.cancel_text = Some(cancel_text.into());
self
}
/// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`.
pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
self.cancel_variant = cancel_variant;
self
}
}
#[derive(IntoElement)]
pub struct Modal {
base: Div,
title: Option<AnyElement>,
footer: Option<AnyElement>,
footer: Option<FooterFn>,
content: Div,
width: Pixels,
max_width: Option<Pixels>,
margin_top: Option<Pixels>,
on_close: OnClose,
closable: bool,
on_ok: OnOk,
on_cancel: OnCancel,
button_props: ModalButtonProps,
show_close: bool,
overlay: bool,
overlay_closable: bool,
keyboard: bool,
/// This will be change when open the modal, the focus handle is create when open the modal.
pub(crate) focus_handle: FocusHandle,
pub(crate) layer_ix: usize,
pub(crate) overlay: bool,
pub(crate) overlay_visible: bool,
}
impl Modal {
pub fn new(_window: &mut Window, cx: &mut App) -> Self {
let radius = (cx.theme().radius * 2.).min(px(20.));
let base = v_flex()
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().border)
.rounded_xl()
.shadow_md();
.rounded(radius)
.shadow_xl()
.min_h_24();
Self {
base,
@@ -61,9 +124,14 @@ impl Modal {
max_width: None,
overlay: true,
keyboard: true,
closable: true,
layer_ix: 0,
overlay_visible: false,
on_close: Rc::new(|_, _, _| {}),
on_ok: None,
on_cancel: Rc::new(|_, _, _| true),
button_props: ModalButtonProps::default(),
show_close: true,
overlay_closable: true,
}
}
@@ -74,12 +142,54 @@ impl Modal {
}
/// Set the footer of the modal.
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
///
/// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
///
/// - First `RenderButtonFn` is the render function for the OK button.
/// - Second `RenderButtonFn` is the render function for the CANCEL button.
///
/// When you set the footer, the footer will be placed default footer buttons.
pub fn footer<E, F>(mut self, footer: F) -> Self
where
E: IntoElement,
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
{
self.footer = Some(Box::new(move |ok, cancel, window, cx| {
footer(ok, cancel, window, cx)
.into_iter()
.map(|e| e.into_any_element())
.collect()
}));
self
}
/// Set to use confirm modal, with OK and Cancel buttons.
///
/// See also [`Self::alert`]
pub fn confirm(self) -> Self {
self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set to as a alter modal, with OK button.
///
/// See also [`Self::confirm`]
pub fn alert(self) -> Self {
self.footer(|ok, _, window, cx| vec![ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set the button props of the modal.
pub fn button_props(mut self, button_props: ModalButtonProps) -> Self {
self.button_props = button_props;
self
}
/// Sets the callback for when the modal is closed.
///
/// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -88,9 +198,31 @@ impl Modal {
self
}
/// Sets the false to make modal unclosable, default: true
pub fn closable(mut self, closable: bool) -> Self {
self.closable = closable;
/// Sets the callback for when the modal is has been confirmed.
///
/// The callback should return `true` to close the modal, if return `false` the modal will not be closed.
pub fn on_ok(
mut self,
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_ok = Some(Rc::new(on_ok));
self
}
/// Sets the callback for when the modal is has been canceled.
///
/// The callback should return `true` to close the modal, if return `false` the modal will not be closed.
pub fn on_cancel(
mut self,
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_cancel = Rc::new(on_cancel);
self
}
/// Sets the false to hide close icon, default: true
pub fn show_close(mut self, show_close: bool) -> Self {
self.show_close = show_close;
self
}
@@ -118,6 +250,14 @@ impl Modal {
self
}
/// Set the overlay closable of the modal, defaults to `true`.
///
/// When the overlay is clicked, the modal will be closed.
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
self.overlay_closable = overlay_closable;
self
}
/// Set whether to support keyboard esc to close the modal, defaults to `true`.
pub fn keyboard(mut self, keyboard: bool) -> Self {
self.keyboard = keyboard;
@@ -145,6 +285,64 @@ impl RenderOnce for Modal {
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
let layer_ix = self.layer_ix;
let on_close = self.on_close.clone();
let on_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "Ok".into());
let ok_variant = self.button_props.ok_variant;
move |_, _| {
Button::new("ok")
.label(ok_text)
.with_variant(ok_variant)
.on_click({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if let Some(on_ok) = &on_ok {
if !on_ok(&ClickEvent::default(), window, cx) {
return;
}
}
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.into_any_element()
}
});
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
let cancel_text = self
.button_props
.cancel_text
.unwrap_or_else(|| "Cancel".into());
let cancel_variant = self.button_props.cancel_variant;
move |_, _| {
Button::new("cancel")
.label(cancel_text)
.with_variant(cancel_variant)
.on_click({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if !on_cancel(&ClickEvent::default(), window, cx) {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.into_any_element()
}
});
let window_paddings = crate::window_border::window_paddings(window, cx);
let view_size = window.viewport_size()
- gpui::size(
@@ -155,8 +353,8 @@ impl RenderOnce for Modal {
origin: Point::default(),
size: view_size,
};
let offset_top = px(layer_ix as f32 * 2.);
let y = self.margin_top.unwrap_or(view_size.height / 16.) + offset_top;
let offset_top = px(layer_ix as f32 * 16.);
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
anchored()
@@ -164,14 +362,22 @@ impl RenderOnce for Modal {
.snap_to_window()
.child(
div()
.occlude()
.w(view_size.width)
.h(view_size.height)
.when(self.overlay, |this| this.bg(cx.theme().overlay))
.when(self.keyboard, |this| {
.when(self.overlay_visible, |this| {
this.occlude().bg(cx.theme().overlay)
})
.when(self.overlay_closable, |this| {
// Only the last modal owns the `mouse down - close modal` event.
if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() {
return this;
}
this.on_mouse_down(MouseButton::Left, {
let on_close = self.on_close.clone();
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
@@ -182,8 +388,39 @@ impl RenderOnce for Modal {
.id(SharedString::from(format!("modal-{layer_ix}")))
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.when(self.keyboard, |this| {
this.on_action({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_: &Cancel, window, cx| {
// FIXME:
//
// Here some Modal have no focus_handle, so it will not work will Escape key.
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.on_action({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let has_footer = self.footer.is_some();
move |_: &Confirm, window, cx| {
if let Some(on_ok) = &on_ok {
if on_ok(&ClickEvent::default(), window, cx) {
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
} else if has_footer {
window.close_modal(cx);
}
}
})
})
.absolute()
.occlude()
.relative()
.left(x)
.top(y)
.w(self.width)
@@ -203,7 +440,7 @@ impl RenderOnce for Modal {
.child(title),
)
})
.when(self.closable, |this| {
.when(self.show_close, |this| {
this.child(
Button::new(SharedString::from(format!(
"modal-close-{layer_ix}"
@@ -221,26 +458,23 @@ impl RenderOnce for Modal {
)
.on_click(
move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
},
),
)
})
.child(self.content)
.children(self.footer)
.when(self.keyboard, |this| {
this.on_action({
let on_close = self.on_close.clone();
move |_: &Escape, window, cx| {
// FIXME:
//
// Here some Modal have no focus_handle, so it will not work will Escape key.
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.child(div().w_full().flex_1().child(self.content))
.when(self.footer.is_some(), |this| {
let footer = self.footer.unwrap();
this.child(h_flex().gap_2().justify_end().children(footer(
render_ok,
render_cancel,
window,
cx,
)))
})
.with_animation(
"slide-down",

View File

@@ -128,7 +128,7 @@ impl ContextModal for Window {
type Builder = Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>;
#[derive(Clone)]
pub struct ActiveModal {
pub(crate) struct ActiveModal {
focus_handle: FocusHandle,
builder: Builder,
}
@@ -137,7 +137,7 @@ pub struct ActiveModal {
///
/// It is used to manage the Modal, and Notification.
pub struct Root {
pub active_modals: Vec<ActiveModal>,
pub(crate) active_modals: Vec<ActiveModal>,
pub notification: Entity<NotificationList>,
pub focused_input: Option<Entity<InputState>>,
/// Used to store the focus handle of the previous view.
@@ -194,36 +194,46 @@ impl Root {
/// Render the Modal layer.
pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
let root = window.root::<Root>()??;
let active_modals = root.read(cx).active_modals.clone();
let mut has_overlay = false;
if active_modals.is_empty() {
return None;
}
Some(
div().children(active_modals.iter().enumerate().map(|(i, active_modal)| {
let mut show_overlay_ix = None;
let mut modals = active_modals
.iter()
.enumerate()
.map(|(i, active_modal)| {
let mut modal = Modal::new(window, cx);
modal = (active_modal.builder)(modal, window, cx);
modal.layer_ix = i;
// Give the modal the focus handle, because `modal` is a temporary value, is not possible to
// keep the focus handle in the modal.
//
// So we keep the focus handle in the `active_modal`, this is owned by the `Root`.
modal.focus_handle = active_modal.focus_handle.clone();
// Keep only have one overlay, we only render the first modal with overlay.
if has_overlay {
modal.overlay = false;
}
modal.layer_ix = i;
// Find the modal which one needs to show overlay.
if modal.has_overlay() {
has_overlay = true;
show_overlay_ix = Some(i);
}
modal
})),
)
})
.collect::<Vec<_>>();
if let Some(ix) = show_overlay_ix {
if let Some(modal) = modals.get_mut(ix) {
modal.overlay_visible = true;
}
}
Some(div().children(modals))
}
/// Return the root view of the Root.

View File

@@ -4,13 +4,13 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId,
InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _,
Window,
div, px, white, Animation, AnimationExt as _, AnyElement, App, Element, ElementId,
GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString,
Styled as _, Window,
};
use theme::ActiveTheme;
use crate::{h_flex, Disableable, Side, Sizable, Size};
use crate::{Disableable, Side, Sizable, Size};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
@@ -19,6 +19,7 @@ pub struct Switch {
checked: bool,
disabled: bool,
label: Option<SharedString>,
description: Option<SharedString>,
label_side: Side,
on_click: OnClick,
size: Size,
@@ -27,13 +28,15 @@ pub struct Switch {
impl Switch {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
checked: false,
disabled: false,
label: None,
description: None,
on_click: None,
label_side: Side::Right,
label_side: Side::Left,
size: Size::Medium,
}
}
@@ -48,6 +51,11 @@ impl Switch {
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
@@ -116,8 +124,8 @@ impl Element for Switch {
let on_click = self.on_click.clone();
let (bg, toggle_bg) = match self.checked {
true => (theme.icon_accent, theme.background),
false => (theme.element_background, theme.background),
true => (theme.element_background, white()),
false => (theme.elevated_surface_background, white()),
};
let (bg, toggle_bg) = match self.disabled {
@@ -138,74 +146,98 @@ impl Element for Switch {
let inset = px(2.);
let mut element = div()
.flex()
.child(
h_flex()
div()
.id(self.id.clone())
.items_center()
.gap_2()
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.child(
// Switch Bar
div()
.id(self.id.clone())
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.w_full()
.flex()
.justify_between()
.items_center()
.border(inset)
.border_color(theme.border_transparent)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.gap_4()
.when_some(self.label.clone(), |this, label| {
// Label
this.child(
div().text_sm().text_color(cx.theme().text).child(label),
)
})
.child(
// Switch Toggle
div().rounded_full().bg(toggle_bg).size(bar_width).map(
|this| {
let prev_checked = state.prev_checked.clone();
if !self.disabled
&& prev_checked
.borrow()
.is_some_and(|prev| prev != checked)
{
let dur = Duration::from_secs_f64(0.15);
cx.spawn(async move |cx| {
cx.background_executor().timer(dur).await;
*prev_checked.borrow_mut() = Some(checked);
})
.detach();
this.with_animation(
ElementId::NamedInteger(
"move".into(),
checked as u64,
),
Animation::new(dur),
move |this, delta| {
// Switch Bar
div()
.id(self.id.clone())
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex()
.items_center()
.border(inset)
.border_color(theme.border_transparent)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.child(
// Switch Toggle
div()
.rounded_full()
.shadow_sm()
.bg(toggle_bg)
.size(bar_width)
.map(|this| {
let prev_checked = state.prev_checked.clone();
if !self.disabled
&& prev_checked
.borrow()
.is_some_and(|prev| prev != checked)
{
let dur = Duration::from_secs_f64(0.15);
cx.spawn(async move |cx| {
cx.background_executor()
.timer(dur)
.await;
*prev_checked.borrow_mut() =
Some(checked);
})
.detach();
this.with_animation(
ElementId::NamedInteger(
"move".into(),
checked as u64,
),
Animation::new(dur),
move |this, delta| {
let max_x = bg_width
- bar_width
- inset * 2;
let x = if checked {
max_x * delta
} else {
max_x - max_x * delta
};
this.left(x)
},
)
.into_any_element()
} else {
let max_x =
bg_width - bar_width - inset * 2;
let x = if checked {
max_x * delta
} else {
max_x - max_x * delta
};
this.left(x)
},
)
.into_any_element()
} else {
let max_x = bg_width - bar_width - inset * 2;
let x = if checked { max_x } else { px(0.) };
this.left(x).into_any_element()
}
},
),
let x =
if checked { max_x } else { px(0.) };
this.left(x).into_any_element()
}
}),
),
),
)
.when_some(self.label.clone(), |this, label| {
this.child(div().child(label).map(|this| match self.size {
Size::XSmall | Size::Small => this.text_sm(),
_ => this.text_base(),
}))
.when_some(self.description.clone(), |this, description| {
this.child(
div()
.w_3_4()
.text_xs()
.text_color(cx.theme().text_muted)
.child(description),
)
})
.when_some(
on_click