@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
308
crates/coop/src/views/preferences.rs
Normal file
308
crates/coop/src/views/preferences.rs
Normal 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();
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
16
crates/settings/Cargo.toml
Normal file
16
crates/settings/Cargo.toml
Normal 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
146
crates/settings/src/lib.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user