From ca735c016951b21e0662f249d0b3aef2877982de Mon Sep 17 00:00:00 2001 From: Brayan Vargas <86427419+b-avb@users.noreply.github.com> Date: Sat, 17 Aug 2024 09:53:13 -0500 Subject: [PATCH] Vote UI (#81) * change: update vote ui * feat: add threshold for linear curve, calculate decision * feat: vote feedback * fix: persist vote off-chain * change: move tooltip bottom-right * refactor; create bar component * chore: rename components * fix: add calculate_threshold --- Cargo.toml | 1 + public/styles/main.scss | 226 ++++-- src/components/atoms/action_request.rs | 33 + src/components/atoms/bar.rs | 66 ++ src/components/atoms/mod.rs | 4 + src/hooks/use_notification.rs | 12 + src/hooks/use_vote.rs | 55 ++ src/lib.rs | 2 + src/locales/en-US.json | 20 +- src/locales/es-ES.json | 20 +- src/main.rs | 3 +- src/pages/vote.rs | 778 ++++++++++++++------- src/services/bot/client.rs | 2 +- src/services/kreivo/community_referenda.rs | 22 +- src/services/kreivo/community_track.rs | 56 +- src/services/kreivo/system.rs | 21 + 16 files changed, 1022 insertions(+), 299 deletions(-) create mode 100644 src/components/atoms/action_request.rs create mode 100644 src/components/atoms/bar.rs create mode 100644 src/hooks/use_vote.rs create mode 100644 src/services/kreivo/system.rs diff --git a/Cargo.toml b/Cargo.toml index c4c1b88..c0c7398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ reqwest = { version = "0.12.4", features = ["multipart", "json"] } pulldown-cmark = "0.11.0" blake2 = "0.10.6" chrono = "0.4.38" +wasm-logger = "0.2.0" [patch.crates-io] cookie = { git = "https://github.com/S0c5/cookie-rs.git" } diff --git a/public/styles/main.scss b/public/styles/main.scss index 7d58640..112a492 100644 --- a/public/styles/main.scss +++ b/public/styles/main.scss @@ -195,6 +195,13 @@ $fw-bold: 700; line-height: $lh-18; } +%text-xxs-font-regular { + font-family: $font-family; + font-size: $fs-10; + font-weight: $fw-regular; + line-height: $lh-18; +} + %text-base-font-regular { font-family: $font-family; font-size: $fs-16; @@ -206,11 +213,6 @@ $fw-bold: 700; display: none !important; } -.wip { - @extend %text-base-font-semibold; - color: var(--text-secondary); -} - .bg--transparent { background: transparent !important; } @@ -311,14 +313,19 @@ $fw-bold: 700; height: 100vh; } +.page--vote, .page--initiative { - background: #F4F4F4; display: flex; flex-direction: column; width: calc(100vw - 90px); height: calc(100vh - 90px); } + +.page--initiative { + background: #F4F4F4; +} + .page--onboarding>.row { gap: 0; } @@ -674,8 +681,8 @@ $fw-bold: 700; box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, .25); min-height: 60px; border-radius: 8px; - top: 84px; - left: 16px; + bottom: 20px; + right: 16px; background: var(--fill-50); } @@ -820,7 +827,6 @@ $fw-bold: 700; padding: 8px 16px; border-radius: 32px; display: inline-block; - } .badge--green-dark { @@ -864,7 +870,20 @@ $fw-bold: 700; } .details__proposal { - flex-grow: 2; + padding: 20px 28px; + width: 100%; + border-radius: 28px; + border-right: 1px solid var(--fill-00); + border-bottom: 1px solid var(--fill-00); + border-left: 1px solid var(--fill-00); + display: flex; + flex-direction: column; + background: var(--white); +} + +.details__subtitle { + @extend %text-base-font-medium; + color: var(--text-secondary); } .details__tags { @@ -884,6 +903,16 @@ $fw-bold: 700; gap: 24px; } +.details__head { + display: flex; + justify-content: space-between; + align-items: center; +} + +.details__head>button { + width: fit-content; +} + .details__cta { width: 50%; margin: 40px auto 0; @@ -894,27 +923,57 @@ $fw-bold: 700; color: var(--text-secondary); } -.statistics__bar { - height: 12px; +.bar { width: 100%; border-radius: 16px; display: flex; + overflow: hidden; + position: relative; } -.statistics__bar__content { - height: 100%; +.bar--vote { + background: #f44336bd; +} + +.bar--vote > .bar__content--left { + background: #56C95F; +} + + +.bar--remaign { + background: #c7eedb; +} + +.bar--remaign > .bar__content--left { + background: #89c3e9; +} + +.bar--participation { + background: var(--white); +} + +.bar__content__threshold { + width: 1px; + height: 18px; + background: black; + position: absolute; +} + +.bar__percent { + width: 100%; border-radius: 16px; - display: inline-block; + display: flex; + justify-content: space-between; } -.statistics__bar--aye, -.statistics__bar__content--aye { - background: var(--state-primary-active); +.bar__content { + display: inline-block; } -.statistics__bar--nay, -.statistics__bar__content--nay { - background: var(--state-destructive-active); +.bar__content--right { + &>p { + text-align: right; + } } .statistics__votes { @@ -932,34 +991,20 @@ $fw-bold: 700; .votes-counter { display: flex; - flex-direction: column; + align-items: center; position: relative; -} - -.votes-counter::before { - content: ""; - width: 4px; - height: 100%; - position: absolute; - left: -16px; - border-radius: 4px; -} - -.votes-counter--for::before { - background: var(--state-primary-active); - -} - -.votes-counter--against::before { - background: var(--state-destructive-active); + gap: 8px; + padding: 11px 0 12px 0; + border-bottom: 1px solid rgba(34, 122, 107, 0.20); } .votes-counter__title { - @extend %text-xs-font-regular; + @extend %text-xxs-font-regular; + padding: 0px 8px; } .votes-counter__percent { - @extend %text-base-font-semibold; + @extend %text-base-font-regular; text-align: left; } @@ -967,6 +1012,12 @@ $fw-bold: 700; @extend %text-base-font-medium; } +.vote-cta { + background: var(--white); + border-bottom: 1px rgba(34, 122, 107, 0.20); + border-radius: 12px; +} + .voting { margin-top: 24px; } @@ -995,17 +1046,60 @@ $fw-bold: 700; } .vote-card { - padding: 32px 24px; + padding: 20px 28px; width: 100%; + background: #DAFBDB; + border-radius: 28px; + box-shadow: 0px 1px 0px 0px rgba(26, 26, 26, 0.08), 0px 2px 4px -1px rgba(26, 26, 26, 0.08); + display: flex; + flex-direction: column; + gap: 24px; +} + +.vote-card__title { + @extend %text-base-font-regular; + color: var(--text-secondary); +} + +.vote-card__info { + @extend %text-xs-font-regular; + color: var(--text-tertiary); +} + +.requests { + display: flex; + flex-direction: column; + gap: 8px; +} + +.action-request { background: var(--white); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 8px; +} + +.action-request--big { border-radius: 12px; - border-right: 1px solid var(--fill-00); - border-bottom: 1px solid var(--fill-00); - border-left: 1px solid var(--fill-00); } -.vote-card__info { +.action-request--medium { + border-radius: 12px; +} + +.action-request--small { + border-radius: 100px; +} + +.action-request__title { + @extend %text-base-font-regular; + color: var(--text-tertiary); +} + +.action-request__details { @extend %text-xs-font-regular; + line-height: normal; color: var(--text-tertiary); } @@ -1069,6 +1163,28 @@ $fw-bold: 700; @extend %text-xs-font-medium; } +.note { + display: flex; + padding: 12px; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 12px; + border-radius: 12px; + background: rgba(150, 150, 150, 0.30); + @extend %text-custom-font-medium; + color: var(--text-primary); +} + +.note .key-value__key, +.note .key-value__value { + font-family: $font-family; + font-size: $fs-12; + font-weight: $fw-medium; + line-height: normal; + color: var(--text-primary); +} + .markdown-preview { width: 100%; min-width: 220px; @@ -1317,6 +1433,7 @@ $fw-bold: 700; .dropdown__container--big, .button--big, +.action-request--big, .tab--big, .input-wrapper__container--big, .icon-button--big { @@ -1330,6 +1447,7 @@ $fw-bold: 700; .dropdown__container--medium, .button--medium, +.action-request--medium, .tab--medium, .input-wrapper__container--medium, .icon-button--medium { @@ -1341,6 +1459,7 @@ $fw-bold: 700; .dropdown__container--small, .button--small, .tab--small, +.action-request--small, .input-wrapper__container--small, .icon-button--small { @extend %text-sm-font-semibold; @@ -1730,6 +1849,7 @@ textarea::placeholder { .radio-button__header, .step-card__header { display: flex; + justify-content: space-between; width: 100%; gap: 8px; padding: 12px; @@ -1884,7 +2004,10 @@ textarea::placeholder { width: 40%; } - +.combo-input .dropdown__container { + border-radius: 12px 0 0 12px; + background: var(--fill-200); +} .combo-input .dropdown__list { min-width: 100%; @@ -1905,7 +2028,12 @@ textarea::placeholder { display: inline-flex; } - +.combo-input .input-wrapper { + background: var(--fill-00); + border-radius: 0 12px 12px 0; + margin: 0; + box-shadow: none; +} .subtitle { color: var(--text-primary); diff --git a/src/components/atoms/action_request.rs b/src/components/atoms/action_request.rs new file mode 100644 index 0000000..7a472cc --- /dev/null +++ b/src/components/atoms/action_request.rs @@ -0,0 +1,33 @@ +use dioxus::prelude::*; + +use super::dropdown::ElementSize; + +#[derive(PartialEq, Props, Clone)] +pub struct RequestProps { + name: String, + details: Option, + #[props(default = ElementSize::Medium)] + size: ElementSize, +} + +pub fn ActionRequest(props: RequestProps) -> Element { + let size = match props.size { + ElementSize::Big => "action-request--big", + ElementSize::Medium => "action-request--medium", + ElementSize::Small => "action-request--small", + }; + + rsx!( + div { + class: "action-request {size}", + span { class: "action-request__title", + {props.name} + } + if let Some(details) = props.details { + span { class: "action-request__details", + {details} + } + } + } + ) +} diff --git a/src/components/atoms/bar.rs b/src/components/atoms/bar.rs new file mode 100644 index 0000000..2b4b61a --- /dev/null +++ b/src/components/atoms/bar.rs @@ -0,0 +1,66 @@ +use dioxus::prelude::*; + +#[derive(PartialEq, Clone)] +pub enum Variant { + Remaign, + Vote, + Participation +} + +#[derive(PartialEq, Props, Clone)] +pub struct BarProps { + left_value: f64, + center_value: Option, + right_value: f64, + left_helper: Option, + right_helper: Option, + left_title: Option, + right_title: Option, + #[props(default = Variant::Remaign)] + variant: Variant, +} + +pub fn Bar(props: BarProps) -> Element { + let variant = match props.variant { + Variant::Remaign => "bar--remaign", + Variant::Vote => "bar--vote", + Variant::Participation => "bar--participation", + }; + + rsx!( + section { + div { + class: "bar {variant}", + span { + class: "bar__content bar__content--left", + style: format!("width: {}%", props.left_value), + p { class: "votes-counter__title", + {props.left_helper} + } + } + if let Some(value) = props.center_value { + span { + class: "bar__content__threshold", + style: format!("left: {}%", value), + } + } + span { + class: "bar__content bar__content--right", + style: format!("width: {}%", props.right_value), + p { class: "votes-counter__title", + {props.right_helper} + } + } + } + div { + class: "bar__percent", + p { class: "votes-counter__percent", + {props.left_title} + } + p { class: "votes-counter__percent", + {props.right_title} + } + } + } + ) +} diff --git a/src/components/atoms/mod.rs b/src/components/atoms/mod.rs index 819afa4..6a75cf8 100644 --- a/src/components/atoms/mod.rs +++ b/src/components/atoms/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod attach; pub mod avatar; pub mod badge; +pub mod bar; pub mod button; pub mod card; pub mod checkbox_card; @@ -15,6 +16,7 @@ pub mod key_value; pub mod markdown; pub mod notification; pub mod radio_button; +pub mod action_request; pub mod search_input; pub mod step; pub mod step_card; @@ -28,6 +30,7 @@ pub use account::AccountButton; pub use attach::Attach; pub use avatar::Avatar; pub use badge::Badge; +pub use bar::Bar; pub use button::Button; pub use card::Card; pub use checkbox_card::CheckboxCard; @@ -41,6 +44,7 @@ pub use key_value::KeyValue; pub use markdown::Markdown; pub use notification::Notification; pub use radio_button::RadioButton; +pub use action_request::ActionRequest; pub use search_input::SearchInput; pub use step::Step; pub use step_card::StepCard; diff --git a/src/hooks/use_notification.rs b/src/hooks/use_notification.rs index 95df692..c6a5131 100644 --- a/src/hooks/use_notification.rs +++ b/src/hooks/use_notification.rs @@ -56,6 +56,18 @@ impl UseNotificationState { gloo::timers::callback::Timeout::new(3000, move || this.clear()).forget(); } + pub fn handle_success(&mut self, body: &str) { + self.handle_notification(NotificationItem { + title: translate!(use_i18(), "success.title"), + body: String::from(body), + variant: NotificationVariant::Success, + show: true, + handle: NotificationHandle { + value: NotificationHandler::None, + }, + }); + } + pub fn handle_error(&mut self, body: &str) { self.handle_notification(NotificationItem { title: String::from("Error"), diff --git a/src/hooks/use_vote.rs b/src/hooks/use_vote.rs new file mode 100644 index 0000000..dafbd3e --- /dev/null +++ b/src/hooks/use_vote.rs @@ -0,0 +1,55 @@ +use dioxus::prelude::*; + +#[derive(Clone, Debug)] +pub enum ProposalStatus { + APPROVED, + REJECTED, + VOTING, + QUEUE, +} + +#[derive(Clone, Debug)] +pub enum BadgeColor { + YELLOW, + RED, + GREEN, +} + +#[derive(Clone, Debug, Default)] +pub struct VoteDigest { + pub aye: u64, + pub nay: u64, +} + +impl VoteDigest { + pub fn total(&self) -> u64 { + self.aye + self.nay + } + + pub fn percent_aye(&self) -> f64 { + if self.total() > 0 { + let percent_unit = 100.0 / self.total() as f64; + percent_unit * self.aye as f64 + } else { + 50.0 + } + } + + pub fn percent_nay(&self) -> f64 { + if self.total() > 0 { + let percent_unit = 100.0 / self.total() as f64; + percent_unit * self.nay as f64 + } else { + 50.0 + } + } +} + +pub fn use_vote() -> UseVoteState { + use_hook(move || UseVoteState {}) +} + +#[derive(Clone, Copy)] +pub struct UseVoteState {} + +impl UseVoteState {} diff --git a/src/lib.rs b/src/lib.rs index 6ffaf4a..b032631 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub mod hooks { pub mod use_theme; pub mod use_timestamp; pub mod use_tooltip; + pub mod use_vote; } pub mod components { @@ -57,6 +58,7 @@ pub mod services { pub mod community_track; pub mod identity; pub mod preimage; + pub mod system; pub mod timestamp; } diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 3892597..95e7d55 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -181,7 +181,8 @@ "voting": "In voting", "excecuted": "Executed", "approved": "Approved", - "rejected": "Failed" + "rejected": "Failed", + "queue": "In queue" } }, "cta": "View advanced data" @@ -209,6 +210,16 @@ } } }, + "tips": { + "voting": { + "title": "Voting", + "description": "This may take a moment" + }, + "voted": { + "title": "Everything went well", + "description": "The vote has been cast" + } + }, "success": { "title": "Everything was perfect! You have created a referendum! 馃コ", "form": { @@ -317,6 +328,9 @@ "description": "We are seeking funds to create a decentralized governance system on our blockchain platform. We want to enable our community to actively participate in decision-making.\n\n**Key Points**\n\n- Development of governance tools\n- Education and outreach on decentralized governance\n- Ongoing research and development of the governance system\n\n**Use of Funds**\n\n- Development of governance tools: 40%.\n- Education and outreach: 30%.\n- Research and development: 30%." } }, + "success": { + "title": "Everything went well" + }, "errors": { "session": { "persist": "Failed to save session" @@ -332,6 +346,10 @@ "initiatives": { "query_failed": "Failed to fetch initiatives" }, + "vote": { + "persist_failed": "The vote could not be saved", + "chain": "The vote could not be cast" + }, "timestamp": { "query_failed": "Failed to fetch timestamp" }, diff --git a/src/locales/es-ES.json b/src/locales/es-ES.json index 8f8ce45..2a159e6 100644 --- a/src/locales/es-ES.json +++ b/src/locales/es-ES.json @@ -176,7 +176,8 @@ "voting": "En votaci贸n", "excecuted": "Ejecutada", "approved": "Aprobada", - "rejected": "Fallida" + "rejected": "Fallida", + "queue": "En cola" } }, "update": "Modificar", @@ -205,6 +206,16 @@ } } }, + "tips": { + "voting": { + "title": "Votando", + "description": "Esto puede tomar un momento" + }, + "voted": { + "title": "Todo ha salido bien", + "description": "El voto se ha realizado" + } + }, "success": { "title": "Todo ha estado perfecto隆Haz creado un referendo! 馃コ", "form": { @@ -306,6 +317,9 @@ "description": "Buscamos fondos para crear un sistema de gobernanza descentralizada en nuestra plataforma blockchain. Queremos permitir que nuestra comunidad participe activamente en la toma de decisiones.\n\n**Puntos importantes**\n\n- Desarrollo de herramientas de gobernanza\n- Educaci贸n y divulgaci贸n sobre gobernanza descentralizada\n- Investigaci贸n y desarrollo continuo del sistema de gobernanza\n\n**Uso de fondos**\n\n- Desarrollo de herramientas de gobernanza: 40%.\n- Educaci贸n y divulgaci贸n: 30%.\n- Investigaci贸n y desarrollo: 30%." } }, + "success": { + "title": "Todo ha salido bien" + }, "warnings": { "title": "Atenci贸n", "middleware": { @@ -328,6 +342,10 @@ "initiatives": { "query_failed": "No se ha podido traer iniciativas" }, + "vote": { + "persist_failed": "No se ha podido guardar el voto", + "chain": "No se ha podido votar" + }, "timestamp": { "query_failed": "No se ha podido traer el estado actual" }, diff --git a/src/main.rs b/src/main.rs index d9669fa..e5e5f6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ use dioxus::prelude::*; use dioxus_std::{i18n::use_i18, translate}; use gloo::storage::{errors::StorageError, LocalStorage}; -use log::LevelFilter; use virto_communities::{ components::atoms::{Notification, Tooltip}, hooks::{ @@ -20,7 +19,7 @@ use virto_communities::{ }; fn main() { - dioxus_logger::init(LevelFilter::Debug).expect("failed to init logger"); + wasm_logger::init(wasm_logger::Config::default()); console_error_panic_hook::set_once(); launch(App); diff --git a/src/pages/vote.rs b/src/pages/vote.rs index 45824ea..2bb9b19 100644 --- a/src/pages/vote.rs +++ b/src/pages/vote.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, str::FromStr}; +use std::str::FromStr; use dioxus::prelude::*; use dioxus_std::{i18n::use_i18, translate}; @@ -6,99 +6,31 @@ use futures_util::{StreamExt, TryFutureExt}; use crate::{ components::atoms::{ - button::Variant, dropdown::ElementSize, key_value::Variant as KeyValueVariant, Badge, - Button, KeyValue, + button::Variant, dropdown::ElementSize, key_value::Variant as KeyValueVariant, Badge, Bar, + Button, CircleCheck, Icon, KeyValue, ActionRequest, StopSign, }, hooks::{ - use_accounts::use_accounts, use_initiative::{ - use_initiative, InitiativeHistory, InitiativeInfoContent, InitiativeVoteContent, - InitiativeVoteData, Vote, VoteOf, + ActionItem, ConvictionVote, InitiativeInfoContent, InitiativeVoteData, Vote, VoteOf, + VoteType, }, use_notification::use_notification, use_our_navigator::use_our_navigator, use_session::use_session, use_spaces_client::use_spaces_client, - use_tooltip::use_tooltip, + use_tooltip::{use_tooltip, TooltipItem}, use_vote::{ProposalStatus, VoteDigest}, }, - pages::{initiatives::InitiativeWrapper, onboarding::convert_to_jsvalue}, + pages::initiatives::InitiativeWrapper, services::kreivo::{ - community_memberships::{get_communities_by_member, get_membership_id}, - community_referenda::{metadata_of, referendum_info_for}, + community_memberships::{get_communities_by_member, get_membership_id, item}, + community_referenda::{metadata_of, referendum_info_for, Deciding}, + community_track::{tracks, TrackInfo}, preimage::{preimage_for, request_status_for}, + system::number, }, }; use wasm_bindgen::prelude::*; -#[derive(Clone, Debug)] -pub enum InitiativeStep { - Info, - Actions, - Settings, - Confirmation, - None, -} - -#[derive(Clone, Debug)] -pub enum ProposalStatus { - APPROVED, - REJECTED, - VOTING, -} - -#[derive(Clone, Debug)] -pub enum BadgeColor { - YELLOW, - RED, - GREEN, -} - -#[derive(Clone, Debug, Default)] -pub struct VoteDigest { - pub aye: u64, - pub nay: u64, -} - -impl VoteDigest { - fn total(&self) -> u64 { - self.aye + self.nay - } - - fn percent_aye(&self) -> f64 { - if self.total() > 0 { - let percent_unit = 100.0 / self.total() as f64; - percent_unit * self.aye as f64 - } else { - 50.0 - } - } - - fn percent_nay(&self) -> f64 { - if self.total() > 0 { - let percent_unit = 100.0 / self.total() as f64; - percent_unit * self.nay as f64 - } else { - 50.0 - } - } - - fn add_aye(&mut self) { - self.aye = self.aye + 1 - } - - fn add_nay(&mut self) { - self.nay = self.nay + 1 - } - - fn set_aye(&mut self, aye: u64) { - self.aye = aye - } - - fn set_nay(&mut self, nay: u64) { - self.nay = nay - } -} - #[wasm_bindgen] extern "C" { #[wasm_bindgen(catch, js_namespace = window, js_name = topupThenInitiativeVote)] @@ -109,33 +41,31 @@ extern "C" { ) -> Result; } -fn filter_latest_votes(votes: Vec) -> Vec { - let mut latest_votes: HashMap = HashMap::new(); - - for vote in votes.iter().rev() { - latest_votes.insert(vote.user.clone(), vote.clone()); - } - - latest_votes.into_values().collect() -} - #[component] pub fn Vote(id: u16, initiativeid: u16) -> Element { let i18 = use_i18(); - let mut initiative = use_initiative(); - let mut session = use_session(); + let session = use_session(); let spaces_client = use_spaces_client(); - let mut nav = use_our_navigator(); + let nav = use_our_navigator(); let mut notification = use_notification(); let mut tooltip = use_tooltip(); - let accounts = use_accounts(); let mut votes_statistics = use_signal(|| VoteDigest::default()); let mut content = use_signal(|| String::new()); let mut can_vote = use_signal(|| false); + let mut show_requests = use_signal(|| false); + let mut show_vote = use_signal(|| true); + let mut initiative_wrapper = consume_context::>>(); + let mut current_block = use_signal(|| 0); + let mut track_info = use_signal(|| None); + let mut members = use_signal(|| 0); + let mut room_id = use_signal(|| None); + + let mut approval_threshold = use_signal(|| 100.0); + let mut participation_threshold = use_signal(|| 100.0); let cont = &*content.read(); let parser = pulldown_cmark::Parser::new(cont); @@ -166,9 +96,30 @@ pub fn Vote(id: u16, initiativeid: u16) -> Element { return; }; - if community_tracks.iter().any(|community| community.id == id) { - can_vote.set(true); - } + // get community members + let response_item = item(id, None).await; + let item_details = match response_item { + Ok(items) => items, + Err(_) => 0u16, + }; + + members.set(item_details); + + // get current block + let Ok(block) = number().await else { + log::warn!("Failed to get last block kusama"); + continue; + }; + + current_block.set(block); + + // get track + let Ok(track) = tracks(id).await else { + log::warn!("Failed to get track"); + continue; + }; + + track_info.set(Some(track)); if initiative_wrapper().is_none() { let Ok(response) = referendum_info_for(initiativeid).await else { @@ -185,10 +136,29 @@ pub fn Vote(id: u16, initiativeid: u16) -> Element { actions: vec![], }, ongoing: response.ongoing, - })) + })); }; - if let Some(mut wrapper) = initiative_wrapper() { + let threshold = get_approval_threshold( + &*track_info.read(), + &initiative_wrapper.unwrap().ongoing.deciding, + current_block(), + ); + + approval_threshold.set(threshold); + + let threshold = get_participation_threshold( + &*track_info.read(), + &initiative_wrapper.unwrap().ongoing.deciding, + current_block(), + ); + participation_threshold.set(threshold); + + if community_tracks.iter().any(|community| community.id == id) { + can_vote.set(true); + } + + if let Some(wrapper) = initiative_wrapper() { votes_statistics.set(VoteDigest::default()); votes_statistics.with_mut(|votes| votes.aye = wrapper.ongoing.tally.ayes); votes_statistics.with_mut(|votes| votes.nay = wrapper.ongoing.tally.nays); @@ -204,9 +174,10 @@ pub fn Vote(id: u16, initiativeid: u16) -> Element { let Ok(preimage_len) = request_status_for(&initiative_metadata).await else { continue; - }; + }; - let Ok(room_id_metadata) = preimage_for(&initiative_metadata, preimage_len).await else { + let Ok(room_id_metadata) = preimage_for(&initiative_metadata, preimage_len).await + else { continue; }; @@ -221,9 +192,10 @@ pub fn Vote(id: u16, initiativeid: u16) -> Element { continue; }; + room_id.set(Some(room_id_metadata)); + content.set(response.info.description.clone()); - log::info!("{:?}", response); wrapper.info = response.info.clone(); initiative_wrapper.set(Some(wrapper.clone())); @@ -231,9 +203,15 @@ pub fn Vote(id: u16, initiativeid: u16) -> Element { } }); - let mut handle_vote = move |is_vote_aye: bool| { + let handle_vote = move |is_vote_aye: bool| { spawn( async move { + tooltip.handle_tooltip(TooltipItem { + title: translate!(i18, "governance.tips.voting.title"), + body: translate!(i18, "governance.tips.voting.description"), + show: true, + }); + let account_address = session .get() .ok_or(translate!(i18, "errors.wallet.account_address"))? @@ -251,202 +229,526 @@ pub fn Vote(id: u16, initiativeid: u16) -> Element { .await .map_err(|_| translate!(i18, "errors.wallet.account_address"))?; - let response = spaces_client - .get() - .vote_initiative(InitiativeVoteData { - user: account_address, - room: String::from("!aOgBsDPlVOIDTisUsJ:matrix.org"), - vote: Vote::Standard(if is_vote_aye { VoteOf::Yes } else { VoteOf::No }), - }) - .await; + if let Some(room_id) = room_id() { + spaces_client + .get() + .vote_initiative(InitiativeVoteData { + user: account_address, + room: room_id, + vote: Vote::Standard(if is_vote_aye { + VoteOf::Yes + } else { + VoteOf::No + }), + }) + .await + .map_err(|e| { + log::warn!("Failed to persist vote: {:?}", e); + translate!(i18, "errors.vote.persist_failed") + })?; + } - topup_then_initiative_vote(membership_id, initiativeid, is_vote_aye).await; + topup_then_initiative_vote(membership_id, initiativeid, is_vote_aye) + .await + .map_err(|e| { + log::warn!("Failed to vote on-chain: {:?}", e); + translate!(i18, "errors.vote.chain") + })?; on_handle_vote.send(()); + tooltip.hide(); + + notification.handle_success(&translate!(i18, "governance.tips.voted.description")); + let path = format!("/dao/{id}/initiatives"); nav.push(vec![], &path); Ok::<(), String>(()) } - .unwrap_or_else(move |e: String| {}), + .unwrap_or_else(move |e: String| { + tooltip.hide(); + notification.handle_error(&e); + }), ); }; use_coroutine(move |_: UnboundedReceiver<()>| async move { on_handle_vote.send(()) }); rsx! { - div { class: "page--initiative", + div { class: "page--vote", div { class: "initiative__form", - div { class: "form__wrapper form__wrapper--initiative", - h2 { class: "form__title", - {translate!(i18, "governance.title")} - } - if let Some(ref initiative) = &*initiative_wrapper.read() { + if let Some(initiative_wrapper) = &*initiative_wrapper.read() { + div { class: "form__wrapper form__wrapper--initiative", + h2 { class: "form__title", + "{initiative_wrapper.info.name}" + } + div { class: "details__metadata", + KeyValue { + class: "key-value", + text: format!("{}: ", translate!(i18, "governance.description.details.by")), + size: ElementSize::Medium, + variant: KeyValueVariant::Secondary, + body: rsx!( + { + let hex_string = hex::encode(&initiative_wrapper.ongoing.submission_deposit.who); + format!("0x{}", hex_string) + } + ) + } + } div { class: "steps__wrapper", div { class: "row", - section { class: "details__proposal", + section { class: "details__voting", div { class: "vote-card", - div { class: "details__metadata", - KeyValue { - class: "key-value", - text: format!("{}: ", translate!(i18, "governance.description.details.by")), - size: ElementSize::Medium, - variant: KeyValueVariant::Secondary, - body: rsx!( - { - let hex_string = hex::encode(&initiative.ongoing.submission_deposit.who); - format!("0x{}", hex_string) + h4 { class: "vote-card__title", + "Request" + } + button { class: "button--tertiary", + onclick: move |_| show_requests.toggle(), + ActionRequest { + name: if show_requests() { "Hide all requests" } else { "See all requests" }, + details: initiative_wrapper.info.actions.iter().map(|item| { + match item { + ActionItem::AddMembers(action) => action.members.len(), + ActionItem::KusamaTreasury(action) => action.periods.len(), + ActionItem::VotingOpenGov(action) => action.proposals.len(), } - ) + }).sum::().to_string(), + size: ElementSize::Small } } - div { class: "details__tags", - div { class: "card__tags", - for tag in initiative.clone().info.tags { - { - rsx!( - Badge { - class: "badge--lavanda-dark", - text: tag + if show_requests() { + { + initiative_wrapper.info.actions.iter().map(|request| { + rsx!( + div { class: "requests", + match request { + ActionItem::AddMembers(action) => { + rsx!( + ActionRequest { + name: "Add Members", + details: action.members.len().to_string() + } + ul { class: "requests", + { + action.members.iter().map(|member| { + rsx!( + li { + ActionRequest { + name: format!("{}...", member.account[..10].to_string()), + } + } + ) + }) + } + } + ) + }, + ActionItem::KusamaTreasury(action) => { + rsx!( + ActionRequest { + name: "Kusama Treasury Request" + } + ul { class: "requests", + { + action.periods.iter().enumerate().map(|(index, period)| { + rsx!( + li { + ActionRequest { + name: format!("Periodo: #{}", index + 1), + details: format!("{} KSM", period.amount as f64 / 1_000_000_000_000.0 ) + } + } + ) + }) + } + } + ) + }, + ActionItem::VotingOpenGov(action) => { + rsx!( + ActionRequest { + name: "Voting Open Gov", + details: action.proposals.len().to_string() + } + ul { class: "requests", + { + action.proposals.iter().map(|proposal| { + rsx!( + li { + match &proposal.vote { + VoteType::Standard(vote) => { + let conviction = match vote.conviction { + ConvictionVote::None => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.none"), + ConvictionVote::Locked1x => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.locked_1"), + ConvictionVote::Locked2x => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.locked_2"), + ConvictionVote::Locked3x => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.locked_3"), + ConvictionVote::Locked4x => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.locked_4"), + ConvictionVote::Locked5x => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.locked_5"), + ConvictionVote::Locked6x => translate!(i18, "initiative.steps.actions.voting_open_gov.standard.conviction.locked_6"), + }; + rsx!( + ActionRequest { + name: format!("{} - {}", translate!(i18, "initiative.steps.actions.voting_open_gov.standard.title"), proposal.poll_index), + details: format!("{} - {} KSM", conviction, vote.balance as f64 / 1_000_000_000_000.0 ), + } + ) + } + } + } + ) + }) + } + } + ) + }, } - ) - } - } + } + ) + }) } } - - hr { class: "form__divider" } - - div { class: "details__title", - "{initiative.info.name}" - } - - div { class: "details__description markdown-preview", - dangerous_inner_html: "{html_buf}" - } } } - section { class: "details__voting", div { class: "vote-card", - KeyValue { - class: "key-value--row", - text: translate!(i18, "governance.description.details.status.title"), - variant: KeyValueVariant::Secondary, - body: { - let status = ProposalStatus::VOTING; - let (badge_title, badge_color) = match status { - ProposalStatus::APPROVED => (translate!(i18, "governance.description.details.status.options.approved"), "badge--green-dark"), - ProposalStatus::REJECTED => (translate!(i18, "governance.description.details.status.options.rejected"), "badge--red-dark"), - ProposalStatus::VOTING => (translate!(i18, "governance.description.details.status.options.voting"), "badge--lavanda-dark"), - }; + div { class: "details__statistics", + div { class: "details__head", + h2 { class: "vote-card__title statistics__title", + {translate!(i18, "governance.description.details.status.title")} + } + { + let status = if initiative_wrapper.ongoing.in_queue | initiative_wrapper.ongoing.deciding.is_none() { + ProposalStatus::QUEUE + } else { + ProposalStatus::VOTING + }; + let (badge_title, badge_color) = match status { + ProposalStatus::APPROVED => (translate!(i18, "governance.description.details.status.options.approved"), "badge--green-dark"), + ProposalStatus::REJECTED => (translate!(i18, "governance.description.details.status.options.rejected"), "badge--red-dark"), + ProposalStatus::VOTING => (translate!(i18, "governance.description.details.status.options.voting"), "badge--lavanda-dark"), + ProposalStatus::QUEUE => (translate!(i18, "governance.description.details.status.options.queue"), "badge--blue-light"), + }; + + rsx!( + Badge { + text: badge_title, + class: badge_color.to_string() + } + ) + } + } + div { - rsx!( - Badge { - text: badge_title, - class: badge_color.to_string() + { + let mut consumed = 0; + + if let Some(deciding) = &initiative_wrapper.ongoing.deciding { + if current_block() > 0 { + consumed = current_block() - deciding.since; + } } - ) + + let decision = match &*track_info.read() { + Some(info) => info.decision_period, + None => 36000 + }; + + let consumed_percent = 100.0 / decision as f64 * consumed as f64; + + rsx!( + Bar { + left_value: consumed_percent, + right_value: 100.0 - consumed_percent, + right_helper: if blocks_to_days(decision - consumed) == 0 { + format!("{}", blocks_to_days(decision - consumed) + 1) + } else { + format!("{}", blocks_to_days(decision - consumed)) + }, + left_title: "Decision", + right_title: match blocks_to_times(decision) { + Times::Minutes(time) => {format!("{} Minutes", time)}, + Times::Hours(time) => {format!("{} Hours", time)}, + Times::Days(time) => {format!("{} Days", time)}, + }, + } + ) + } } } } + } + section { class: "details__voting", div { class: "vote-card", div { class: "details__statistics", - h2 { class: "statistics__title", - {translate!(i18, "governance.description.voting.title")} + div { class: "details__head", + h2 { class: "vote-card__title statistics__title", + {translate!(i18, "governance.description.voting.title")} + } + Button { + text: if show_vote() { "Hide vote" } else { "Vote" }, + size: ElementSize::Small, + variant: Variant::Secondary, + on_click: move |_| { + show_vote.toggle(); + }, + status: None, + } } - div { - class: "statistics__bar", - class: if votes_statistics().percent_aye() > 50.0 {"statistics__bar--aye"} else {"statistics__bar--nay"}, - div { - class: "statistics__bar__content statistics__bar__content--aye", - style: format!("width: {}%", votes_statistics().percent_aye()) + if show_vote() { + div { class: "note", + "Explain that this is a dynamic voting, and thresholds might change." } - div { - class: "statistics__bar__content statistics__bar__content--nay", - style: format!("width: {}%", votes_statistics().percent_nay()) + } + if show_vote() { + if can_vote() { + div { class: "row", + Button { + class: "vote-cta", + text: translate!(i18, "governance.description.voting.cta.for"), + size: ElementSize::Medium, + variant: Variant::Secondary, + on_click: move |_| { + handle_vote(true) + }, + status: None, + left_icon: rsx!( + Icon { + icon: CircleCheck, + height: 16, + width: 16, + stroke_width: 2, + stroke: "#56C95F" + } + ) + } + Button { + class: "vote-cta", + text: translate!(i18, "governance.description.voting.cta.against"), + size: ElementSize::Medium, + variant: Variant::Secondary, + on_click: move |_| { + handle_vote(false) + }, + status: None, + left_icon: rsx!( + Icon { + icon: StopSign, + height: 16, + width: 16, + stroke_width: 2, + stroke: "#f44336bd" + } + ) + } + } } } - div { class: "statistics__votes", - div { class: "votes-counter votes-counter--for", - div { class: "votes-counter__line" } - p { class: "votes-counter__title", - {translate!(i18, "governance.description.voting.for")} + Bar { + left_value: votes_statistics().percent_aye(), + center_value: approval_threshold(), + right_value: votes_statistics().percent_nay(), + left_helper: translate!(i18, "governance.description.voting.for"), + right_helper: translate!(i18, "governance.description.voting.against"), + left_title: format!("{:.1}%", votes_statistics().percent_aye()), + right_title: format!("{:.1}%", votes_statistics().percent_nay()), + variant: crate::components::atoms::bar::Variant::Vote + } + if show_vote() { + div { class: "note", + KeyValue { + class: "key-value--row", + text: "Threshold", + size: ElementSize::Medium, + body: rsx!( + { + format!("{:.1}%", approval_threshold()) + } + ) + } + KeyValue { + class: "key-value--row", + text: "Current approval", + size: ElementSize::Medium, + body: rsx!( + { + format!("{:.1}%", votes_statistics().percent_aye()) + } + ) } - p { class: "votes-counter__percent", - {format!("{:.2} %", votes_statistics().percent_aye())} + } + } + if show_vote() { + div { + div { class: "votes-counter votes-counter--for", + Icon { + icon: CircleCheck, + height: 16, + width: 16, + stroke_width: 2, + stroke: "#56C95F" + } + p { class: "votes-counter__total", + "{votes_statistics().aye} " {translate!(i18, "governance.description.voting.votes")} + } } - p { class: "votes-counter__total", - "{votes_statistics().aye} " {translate!(i18, "governance.description.voting.votes")} + + div { class: "votes-counter votes-counter--against", + Icon { + icon: StopSign, + height: 16, + width: 16, + stroke_width: 2, + stroke: "#f44336bd" + } + p { class: "votes-counter__total", + "{votes_statistics().nay} " {translate!(i18, "governance.description.voting.votes")} + } } } - div { class: "votes-counter votes-counter--against", - div { class: "votes-counter__line" } - p { class: "votes-counter__title", - {translate!(i18, "governance.description.voting.against")} + div { + { + let consumed_percent = 100.0 / members() as f64 * votes_statistics().total() as f64; + rsx!( + Bar { + left_value: consumed_percent, + center_value: participation_threshold(), + right_value: 100.0 - consumed_percent, + left_helper: "Participation", + left_title: "{votes_statistics().total()}", + right_title: "{members()}", + } + ) } - p { class: "votes-counter__percent", - {format!("{:.2} %", votes_statistics().percent_nay())} + } + div { class: "note", + KeyValue { + class: "key-value--row", + text: "Paricipation threshold", + size: ElementSize::Medium, + body: rsx!( + { + format!("{:.1}%", participation_threshold()) + } + ) } - p { class: "votes-counter__total", - "{votes_statistics().nay} " {translate!(i18, "governance.description.voting.votes")} + KeyValue { + class: "key-value--row", + text: "Current support", + size: ElementSize::Medium, + body: rsx!( + { + let consumed_percent = 100.0 / members() as f64 * votes_statistics().total() as f64; + format!("{:.1}%", consumed_percent) + } + ) } } } - - hr { class: "form__divider" } - - div { class: "statistics__card", - - KeyValue { - class: "key-value--row", - size: ElementSize::Small, - text: translate!(i18, "governance.description.voting.total.title"), - body: rsx!( - "{votes_statistics().total()} " {translate!(i18, "governance.description.voting.total.voters")} - ) - } - } } } - if can_vote() { - div { class: "vote-card", - div { class: "row", - Button { - class: "", - text: translate!(i18, "governance.description.voting.cta.for"), - size: ElementSize::Small, - variant: Variant::Secondary, - on_click: move |_| { - handle_vote(true) - }, - status: None, - } - Button { - class: "", - text: translate!(i18, "governance.description.voting.cta.against"), - size: ElementSize::Small, - variant: Variant::Secondary, - on_click: move |_| { - handle_vote(false) - }, - status: None, - } + } + } + section { class: "details__proposal", + div { class: "details__subtitle", + "Content" + } + div { class: "details__tags", + div { class: "card__tags", + for tag in initiative_wrapper.clone().info.tags { + { + rsx!( + Badge { + class: "badge--lavanda-dark", + text: tag + } + ) } } } } + + div { class: "details__description markdown-preview", + dangerous_inner_html: "{html_buf}" + } } } } } } - div { class: "form__cta form__cta--initiatives", - p { class: "wip", - {translate!(i18, "initiative.disclaimer")} - } - } } } } + +enum Times { + Minutes(u32), + Hours(u32), + Days(u32), +} + +fn blocks_to_times(blocks: u32) -> Times { + let seconds = blocks * 12; + let minutes = seconds / 60; + + log::info!("minutes {}", minutes); + + if minutes / (24 * 60) > 0 { + Times::Days(minutes / (24 * 60)) + } else if minutes / 60 > 0 { + Times::Hours(minutes / 60) + } else { + Times::Minutes(minutes) + } +} + +fn blocks_to_days(blocks: u32) -> u32 { + let seconds = blocks * 12; + let minutes = seconds / 60; + + minutes / (24 * 60) +} + +fn calculate_threshold( + track_info: &Option, + deciding: &Option, + current_block: u32, + threshold_fn: F, +) -> f64 +where + F: Fn(&TrackInfo, f64) -> f64, +{ + let Some(info) = track_info else { return 100.0 }; + let Some(deciding) = deciding else { + return 100.0; + }; + + if current_block == 0 { + return 100.0; + } + + let consumed = current_block - deciding.since; + let progress = consumed as f64 / 36000.0; + + threshold_fn(info, progress) +} + +fn get_approval_threshold( + track_info: &Option, + deciding: &Option, + current_block: u32, +) -> f64 { + calculate_threshold(track_info, deciding, current_block, |info, progress| { + info.min_approval.calculate_threshold(progress) + }) +} + +fn get_participation_threshold( + track_info: &Option, + deciding: &Option, + current_block: u32, +) -> f64 { + calculate_threshold(track_info, deciding, current_block, |info, progress| { + info.min_support.calculate_threshold(progress) + }) +} diff --git a/src/services/bot/client.rs b/src/services/bot/client.rs index 8235828..a596ed5 100644 --- a/src/services/bot/client.rs +++ b/src/services/bot/client.rs @@ -104,7 +104,7 @@ impl SpacesClient { vote: InitiativeVoteData, ) -> Result<(), reqwest::Error> { let path = format!("{}/initiative/vote", self.base_path); - let response = self + self .client .post(path) .json(&vote) diff --git a/src/services/kreivo/community_referenda.rs b/src/services/kreivo/community_referenda.rs index 9adaee0..645ccbc 100644 --- a/src/services/kreivo/community_referenda.rs +++ b/src/services/kreivo/community_referenda.rs @@ -1,10 +1,8 @@ -use codec::Decode; use serde::{Deserialize, Serialize}; -use serde_json::{from_str, from_value, to_string, Value}; -use std::str::FromStr; +use serde_json::{from_value, Value}; use sube::{sube, Response}; -use crate::{pages::dashboard::Community, services::kreivo::community_track::ChainStateError}; +use crate::services::kreivo::community_track::ChainStateError; #[derive(Debug, Deserialize)] pub struct TrackInfo { @@ -79,6 +77,11 @@ pub struct Deposit { pub amount: u64, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Deciding { + pub since: u32, +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize)] pub struct Tally { pub ayes: u64, @@ -87,12 +90,23 @@ pub struct Tally { pub bare_ayes: u64, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Alarm { + Single(u32), + Multiple(Vec), +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Ongoing { pub track: u32, pub origin: Origin, + pub submitted: u32, pub submission_deposit: Deposit, + pub deciding: Option, + pub in_queue: bool, pub tally: Tally, + pub alarm: Option>, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/src/services/kreivo/community_track.rs b/src/services/kreivo/community_track.rs index 2f3c638..313c7ad 100644 --- a/src/services/kreivo/community_track.rs +++ b/src/services/kreivo/community_track.rs @@ -35,9 +35,56 @@ pub async fn tracksIds() -> Result { const DEFAULT_MAX_TRACK_NAME_LEN: usize = 25; const N: usize = DEFAULT_MAX_TRACK_NAME_LEN; -#[derive(Decode, Debug, Deserialize)] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum Curve { + LinearDecreasing { + ceil: u64, + floor: u64, + length: u64, + }, + SteppedDecreasing { + begin: u64, + end: u64, + step: u64, + period: u64, + }, + Reciprocal { + factor: i64, + x_offset: i64, + y_offset: i64, + }, +} + +impl Curve { + pub fn calculate_threshold(&self, progress: f64) -> f64 { + match self { + Curve::LinearDecreasing { + ceil, + floor, + length, + } => { + let length = *length as f64 / 10_000_000.0; + let ceil = *ceil as f64 / 10_000_000.0; + let floor = *floor as f64 / 10_000_000.0; + + let progress = progress / (length / 100.0); + ceil - progress * (ceil - floor) + } + _ => 100.0, + } + } +} + +#[derive(Debug, Deserialize)] pub struct TrackInfo { pub name: [u8; N], + #[serde(rename = "decision_period")] + pub decision_period: u32, + #[serde(rename = "min_approval")] + pub min_approval: Curve, + #[serde(rename = "min_support")] + pub min_support: Curve, } pub async fn tracks(track: u16) -> Result { @@ -52,9 +99,12 @@ pub async fn tracks(track: u16) -> Result { return Err(ChainStateError::InternalError); }; - let data = value.as_ref(); + let Ok(value) = serde_json::to_value(&value) else { + return Err(ChainStateError::InternalError); + }; + let account_info = - TrackInfo::decode(&mut &data[..]).map_err(|_| ChainStateError::FailedDecode)?; + serde_json::from_value::(value).map_err(|_| ChainStateError::FailedDecode)?; Ok(account_info) } diff --git a/src/services/kreivo/system.rs b/src/services/kreivo/system.rs new file mode 100644 index 0000000..41309b0 --- /dev/null +++ b/src/services/kreivo/system.rs @@ -0,0 +1,21 @@ +use sube::{sube, Response}; + +use crate::services::kreivo::communities::ChainStateError; + +pub async fn number() -> Result { + let query = format!("wss://kreivo.io/system/number"); + + let response = sube!(&query) + .await + .map_err(|_| ChainStateError::FailedQuery)?; + + let Response::Value(value) = response else { + return Err(ChainStateError::InternalError); + }; + + let value = serde_json::to_value(&value).map_err(|_| ChainStateError::FailedDecode)?; + let number = + serde_json::from_value::(value).map_err(|_| ChainStateError::FailedDecode)?; + + Ok(number) +}