diff --git a/common/locales/en-US/main.ftl b/common/locales/en-US/main.ftl index 373d0c44825..24431acb07b 100644 --- a/common/locales/en-US/main.ftl +++ b/common/locales/en-US/main.ftl @@ -48,6 +48,8 @@ uplink = Uplink .go-back = Go Back .upload-queue = Upload queue .download-queue = Download Queue + .copy-seed = Copy to Clipboard + .copied-seed = Copied to Clipboard community = Community .invited = You're Invited! @@ -408,7 +410,7 @@ copy-seed-words = Recovery Seed .finished = I Saved It enter-seed-words = Recovery Seed - .instructions = Type your recovery seed here. You may either enter one word at a time or all at once separated by spaces. + .instructions = Type your recovery seed here. Each phrase should go into their respective box. Alternatively you can simply copy past your recovery seed in here. .submit = Recover Account .placeholder = Enter Recovery Seed... .invalid-seed = Hmm, that seed didn't work. diff --git a/kit/src/elements/input/mod.rs b/kit/src/elements/input/mod.rs index 324ae4ef0da..28a892f6f84 100644 --- a/kit/src/elements/input/mod.rs +++ b/kit/src/elements/input/mod.rs @@ -9,7 +9,6 @@ use uuid::Uuid; pub type ValidationError = String; use crate::elements::label::Label; use crate::elements::loader::Loader; - use common::icons::outline::Shape as Icon; use common::icons::Icon as IconElement; @@ -151,6 +150,7 @@ pub struct Props<'a> { select_on_focus: Option, onchange: Option>, onreturn: Option>, + onfocus: Option>, reset: Option>, #[props(default = false)] disable_onblur: bool, @@ -416,6 +416,11 @@ pub fn Input<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { maxlength: "{max_length}", "type": "{typ}", placeholder: "{cx.props.placeholder}", + onfocus: move |_| { + if let Some(e) = &cx.props.onfocus { + e.call(()) + } + }, onblur: move |_| { if onblur_active { emit_return(&cx, val.read().to_string(), *valid.current(), Code::Enter); diff --git a/ui/src/components/settings/sub_pages/profile/mod.rs b/ui/src/components/settings/sub_pages/profile/mod.rs index d6bb6412d5a..a64041c0f63 100644 --- a/ui/src/components/settings/sub_pages/profile/mod.rs +++ b/ui/src/components/settings/sub_pages/profile/mod.rs @@ -627,7 +627,34 @@ pub fn ProfileSettings(cx: Scope) -> Element { } if let Some(phrase) = seed_phrase.as_ref() { let words = phrase.split_whitespace().collect::>(); + let words2 = words.clone(); render!( + Button { + text: get_local_text("uplink.copy-seed"), + aria_label: "copy-seed-button".into(), + icon: Icon::BookmarkSquare, + onpress: move |_| { + match Clipboard::new() { + Ok(mut c) => { + match c.set_text(words2.clone().join("\n").to_string()) { + Ok(_) => state.write().mutate(Action::AddToastNotification( + ToastNotification::init( + "".into(), + get_local_text("uplink.copied-seed"), + None, + 2, + ), + )), + Err(e) => log::warn!("Unable to set text to clipboard: {e}"), + } + }, + Err(e) => { + log::warn!("Unable to create clipboard reference: {e}"); + } + }; + }, + appearance: Appearance::Secondary + }, SettingSectionSimple { aria_label: "seed-words-section".into(), div { @@ -639,7 +666,7 @@ pub fn ProfileSettings(cx: Scope) -> Element { class: "col", span { aria_label: "seed-word-number-{((idx * 2) + 1).to_string()}", - class: "num", ((idx * 2) + 1).to_string() + class: "num disable-select", ((idx * 2) + 1).to_string() }, span { aria_label: "seed-word-value-{((idx * 2) + 1).to_string()}", @@ -650,7 +677,7 @@ pub fn ProfileSettings(cx: Scope) -> Element { class: "col", span { aria_label: "seed-word-number-{((idx * 2) + 2).to_string()}", - class: "num", ((idx * 2) + 2).to_string() + class: "num disable-select", ((idx * 2) + 2).to_string() }, span { aria_label: "seed-word-value-{((idx * 2) + 2).to_string()}", diff --git a/ui/src/layouts/log_in/copy_seed_words.rs b/ui/src/layouts/log_in/copy_seed_words.rs index 066a71598bd..c8ce8609a0a 100644 --- a/ui/src/layouts/log_in/copy_seed_words.rs +++ b/ui/src/layouts/log_in/copy_seed_words.rs @@ -1,7 +1,11 @@ +use std::time::Duration; + +use arboard::Clipboard; use common::{icons, language::get_local_text, state::State}; use dioxus::prelude::*; use dioxus_desktop::{use_window, LogicalSize}; use kit::elements::{button::Button, label::Label, Appearance}; +use tokio::time::sleep; use crate::get_app_style; @@ -16,7 +20,7 @@ pub fn Layout(cx: Scope, page: UseState, seed_words: UseRef) if !matches!(&*page.current(), AuthPages::Success(_)) { window.set_inner_size(LogicalSize { width: 500.0, - height: 460.0, + height: 480.0, }); } @@ -58,6 +62,13 @@ pub fn Layout(cx: Scope, page: UseState, seed_words: UseRef) #[component] fn SeedWords(cx: Scope, page: UseState, words: Vec) -> Element { + let copied = use_ref(cx, || false); + use_future(cx, copied, |current| async move { + if *current.read() { + sleep(Duration::from_secs(3)).await; + *current.write() = false; + } + }); render! { div { class: "seed-words", @@ -66,10 +77,9 @@ fn SeedWords(cx: Scope, page: UseState, words: Vec) -> Elemen class: "row", div { class: "col", - span { aria_label: "seed-word-number-{((idx * 2) + 1).to_string()}", - class: "num", ((idx * 2) + 1).to_string() + class: "num disable-select", ((idx * 2) + 1).to_string() }, span { aria_label: "seed-word-value-{((idx * 2) + 1).to_string()}", @@ -80,7 +90,7 @@ fn SeedWords(cx: Scope, page: UseState, words: Vec) -> Elemen class: "col", span { aria_label: "seed-word-number-{((idx * 2) + 2).to_string()}", - class: "num", ((idx * 2) + 2).to_string() + class: "num disable-select", ((idx * 2) + 2).to_string() }, span { aria_label: "seed-word-value-{((idx * 2) + 2).to_string()}", @@ -90,6 +100,28 @@ fn SeedWords(cx: Scope, page: UseState, words: Vec) -> Elemen } }) }, + div { + class: "controls", + Button { + text: get_local_text("uplink.copy-seed"), + aria_label: "copy-seed-button".into(), + icon: icons::outline::Shape::BookmarkSquare, + onpress: move |_| { + match Clipboard::new() { + Ok(mut c) => { + match c.set_text(words.join("\n").to_string()) { + Ok(_) => *copied.write() = true, + Err(e) => log::warn!("Unable to set text to clipboard: {e}"), + } + }, + Err(e) => { + log::warn!("Unable to create clipboard reference: {e}"); + } + }; + }, + appearance: Appearance::Secondary + } + } div { class: "controls", Button { @@ -107,5 +139,11 @@ fn SeedWords(cx: Scope, page: UseState, words: Vec) -> Elemen } } } + copied.read().then(||{ + rsx!(div{ + class: "copied-toast", + get_local_text("uplink.copied-seed") + }) + }) } } diff --git a/ui/src/layouts/log_in/enter_seed_handler.js b/ui/src/layouts/log_in/enter_seed_handler.js new file mode 100644 index 00000000000..240d2160e9e --- /dev/null +++ b/ui/src/layouts/log_in/enter_seed_handler.js @@ -0,0 +1,9 @@ +let page = document.getElementById("enter-seed-words-layout"); +let inputs = page.getElementsByTagName("input"); +for (let input of inputs) { + input.addEventListener("paste", event => { + event.preventDefault(); + let paste = (event.clipboardData || window.clipboardData).getData("text"); + dioxus.send(paste) + }) +} \ No newline at end of file diff --git a/ui/src/layouts/log_in/enter_seed_words.rs b/ui/src/layouts/log_in/enter_seed_words.rs index 52557478bce..df5eb1037ca 100644 --- a/ui/src/layouts/log_in/enter_seed_words.rs +++ b/ui/src/layouts/log_in/enter_seed_words.rs @@ -8,7 +8,12 @@ use common::{ use dioxus::prelude::*; use dioxus_desktop::{use_window, LogicalSize}; use futures::{channel::oneshot, StreamExt}; -use kit::elements::{button::Button, input, label::Label, Appearance}; +use kit::elements::{ + button::Button, + input::{self, Options}, + label::Label, + Appearance, +}; use crate::get_app_style; @@ -38,17 +43,45 @@ struct Cmd { pub fn Layout(cx: Scope, pin: UseRef, page: UseState) -> Element { let state = use_ref(cx, State::load); let loading = use_state(cx, || false); - let input = use_ref(cx, String::new); + let input: &UseRef> = use_ref(cx, || (0..12).map(|_| String::new()).collect()); let seed_error = use_state(cx, || None); + let focus = use_ref(cx, || 0); let window = use_window(cx); if !matches!(&*page.current(), AuthPages::Success(_)) { window.set_inner_size(LogicalSize { width: 500.0, - height: 280.0, + height: 480.0, }); } + + let eval = use_eval(cx); + use_effect(cx, (), move |_| { + to_owned![eval, input]; + async move { + if let Ok(eval) = eval(include_str!("./enter_seed_handler.js")) { + loop { + if let Ok(val) = eval.recv().await { + let paste = val + .to_string() + .replace("\\\\", "\\") + .replace("\\r", "\r") + .replace("\\n", "\n"); + let paste = &paste[1..(paste.len() - 1)]; // Trim the apostrophes from the input + if !paste.is_empty() { + let phrases = paste.lines().collect::>(); + for i in 0..12 { + if i < phrases.len() { + input.with_mut(|v: &mut Vec| v[i] = phrases[i].into()); + } + } + } + } + } + } + } + }); // todo: show toasts to inform user of errors. let ch = use_coroutine(cx, |mut rx: UnboundedReceiver| { to_owned![loading, page, seed_error]; @@ -109,28 +142,97 @@ pub fn Layout(cx: Scope, pin: UseRef, page: UseState) -> Elem aria_label: "instructions", get_local_text("enter-seed-words.instructions") }, - input::Input { - aria_label: "recovery-seed-input".into(), - focus: true, - placeholder: get_local_text("enter-seed-words.placeholder"), - onchange: move |(x, is_valid): (String, bool)| { - if x.is_empty() || seed_error.get().is_some() { - seed_error.set(None); - } - if is_valid { - *input.write_silent() = x; - } else{ - seed_error.set(Some(SeedError::ValidationError)); - } - }, - onreturn: move |_|{ - loading.set(true); - ch.send(Cmd { - seed_words: input.read().clone(), - passphrase: pin.read().clone() - }); - } - }, + div { + class: "seed-words", + (0..6).map(|idx|{ + let idx = idx * 2; + let other = idx + 1; + rsx!(div { + class: "row", + div { + class: "col", + span { + aria_label: "seed-word-number-{(idx + 1).to_string()}", + class: "num disable-select", (idx + 1).to_string() + }, + input::Input { + aria_label: "recovery-seed-input-".to_string() + &(idx + 1).to_string(), + value: input.read()[idx].clone(), + select_on_focus: *focus.read() == idx, + focus: *focus.read() == idx, // select class gets removed on focus. this forces an update + placeholder: "".into(), + disable_onblur: true, + options: Options { + clear_on_submit: false, + ..Default::default() + }, + onfocus: move |_|{ + *focus.write() = idx; + }, + onchange: move |(x, is_valid): (String, bool)| { + if x.is_empty() || seed_error.get().is_some() { + seed_error.set(None); + } + if is_valid { + input.with_mut(|v|v[idx] = x); + } else{ + seed_error.set(Some(SeedError::ValidationError)); + } + }, + onreturn: move |_| { + let f = *focus.read(); + *focus.write() = (f + 1) % 12; + } + }, + }, + div { + class: "col", + span { + aria_label: "seed-word-number-{(other + 1).to_string()}", + class: "num disable-select", (other + 1).to_string() + }, + input::Input { + aria_label: "recovery-seed-input-".to_string() + &(other + 1).to_string(), + value: input.read()[other].clone(), + focus: *focus.read() == other, + select_on_focus: *focus.read() == other, // select class gets removed on focus. this forces an update + placeholder: "".into(), + disable_onblur: true, + options: Options { + clear_on_submit: false, + ..Default::default() + }, + onfocus: move |_|{ + *focus.write() = other; + }, + onchange: move |(x, is_valid): (String, bool)| { + if x.is_empty() || seed_error.get().is_some() { + seed_error.set(None); + } + if is_valid { + input.with_mut(|v|v[other] = x); + } else{ + seed_error.set(Some(SeedError::ValidationError)); + } + }, + onreturn: move |_| { + if other == 11 { + loading.set(true); + log::debug!("seed {}", input.read().join(" ")); + ch.send(Cmd { + seed_words: input.read().join(" ").clone(), + passphrase: pin.read().clone() + }); + } else { + let f = *focus.read(); + *focus.write() = (f + 1) % 12; + } + } + }, + } + }) + }) + } seed_error.as_ref().map(|e| rsx!( span { aria_label: "input-error", @@ -155,7 +257,7 @@ pub fn Layout(cx: Scope, pin: UseRef, page: UseState) -> Elem onpress: move |_| { loading.set(true); ch.send(Cmd { - seed_words: input.read().clone(), + seed_words: input.read().join(" ").clone(), passphrase: pin.read().clone() }); } diff --git a/ui/src/layouts/log_in/style.scss b/ui/src/layouts/log_in/style.scss index 0babceb9f01..0e637874433 100644 --- a/ui/src/layouts/log_in/style.scss +++ b/ui/src/layouts/log_in/style.scss @@ -1,13 +1,21 @@ - .overlay-load-shadow { - display: block; - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: rgba(0, 0, 0, 0.2); - z-index: 999; - text-align: center; - pointer-events: all; - } \ No newline at end of file + display: block; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.2); + z-index: 999; + text-align: center; + pointer-events: all; +} + +.copied-toast { + position: fixed; + top: var(--height-titlebar); + right: 0; + padding: var(--padding-less); + background: var(--secondary-dark); + border-radius: 0 0 var(--border-radius-more) var(--border-radius-more); +} \ No newline at end of file diff --git a/ui/src/layouts/style.scss b/ui/src/layouts/style.scss index 62bb29216bc..673883910e5 100644 --- a/ui/src/layouts/style.scss +++ b/ui/src/layouts/style.scss @@ -263,6 +263,10 @@ flex: 1; display: inline-flex; flex-direction: row; + .input { + background-color: transparent; + min-height: 0; + } } .row {