diff --git a/src/app.rs b/src/app.rs index 30d1970dc..118519327 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::app_dir::{ app_user_data_file_path, copy_env_file_if_not_exists, create_app_user_data_directory_if_not_exists, }; +use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::core::CoreItem; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::components::core_zmq_listener::{CoreZMQListener, ZMQMessage}; @@ -9,7 +10,9 @@ use crate::context::AppContext; use crate::database::Database; use crate::logging::initialize_logger; use crate::ui::document_query_screen::DocumentQueryScreen; -use crate::ui::dpns_contested_names_screen::{DPNSContestedNamesScreen, DPNSSubscreen}; +use crate::ui::dpns_contested_names_screen::{ + DPNSContestedNamesScreen, DPNSSubscreen, IndividualVoteCastingStatus, +}; use crate::ui::identities::identities_screen::IdentitiesScreen; use crate::ui::network_chooser_screen::NetworkChooserScreen; use crate::ui::tool_screens::proof_log_screen::ProofLogScreen; @@ -18,12 +21,13 @@ use crate::ui::wallet::wallets_screen::WalletsBalancesScreen; use crate::ui::withdrawal_statuses_screen::WithdrawsStatusScreen; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use derive_more::From; use eframe::{egui, App}; use std::collections::BTreeMap; use std::ops::BitOrAssign; use std::sync::{mpsc, Arc}; -use std::time::Instant; +use std::time::{Duration, Instant, SystemTime}; use std::vec; use tokio::sync::mpsc as tokiompsc; @@ -56,11 +60,13 @@ pub struct AppState { pub task_result_sender: tokiompsc::Sender, // Channel sender for sending task results pub task_result_receiver: tokiompsc::Receiver, // Channel receiver for receiving task results last_repaint: Instant, // Track the last time we requested a repaint + last_scheduled_vote_check: Instant, // Last time we checked if there are scheduled masternode votes to cast } #[derive(Debug, Clone, PartialEq)] pub enum DesiredAppAction { None, + Refresh, PopScreen, GoToMainScreen, SwitchNetwork(Network), @@ -72,6 +78,7 @@ impl DesiredAppAction { pub fn create_action(&self, app_context: &Arc) -> AppAction { match self { DesiredAppAction::None => AppAction::None, + DesiredAppAction::Refresh => AppAction::Refresh, DesiredAppAction::PopScreen => AppAction::PopScreen, DesiredAppAction::GoToMainScreen => AppAction::GoToMainScreen, DesiredAppAction::AddScreenType(screen_type) => { @@ -88,11 +95,13 @@ impl DesiredAppAction { #[derive(Debug, PartialEq)] pub enum AppAction { None, + Refresh, PopScreen, PopScreenAndRefresh, GoToMainScreen, SwitchNetwork(Network), SetMainScreen(RootScreenType), + SetMainScreenThenPopScreen(RootScreenType), AddScreen(Screen), PopThenAddScreenToMainScreen(RootScreenType, Screen), BackendTask(BackendTask), @@ -145,6 +154,8 @@ impl AppState { DPNSContestedNamesScreen::new(&mainnet_app_context, DPNSSubscreen::Past); let mut dpns_my_usernames_screen = DPNSContestedNamesScreen::new(&mainnet_app_context, DPNSSubscreen::Owned); + let mut dpns_scheduled_votes_screen = + DPNSContestedNamesScreen::new(&mainnet_app_context, DPNSSubscreen::ScheduledVotes); let mut transition_visualizer_screen = TransitionVisualizerScreen::new(&mainnet_app_context); let mut proof_log_screen = ProofLogScreen::new(&mainnet_app_context); @@ -187,6 +198,10 @@ impl AppState { DPNSContestedNamesScreen::new(&testnet_app_context, DPNSSubscreen::Past); dpns_my_usernames_screen = DPNSContestedNamesScreen::new(&testnet_app_context, DPNSSubscreen::Owned); + dpns_scheduled_votes_screen = DPNSContestedNamesScreen::new( + &testnet_app_context, + DPNSSubscreen::ScheduledVotes, + ); transition_visualizer_screen = TransitionVisualizerScreen::new(testnet_app_context); document_query_screen = DocumentQueryScreen::new(testnet_app_context); wallets_balances_screen = WalletsBalancesScreen::new(testnet_app_context); @@ -244,6 +259,10 @@ impl AppState { RootScreenType::RootScreenDPNSOwnedNames, Screen::DPNSContestedNamesScreen(dpns_my_usernames_screen), ), + ( + RootScreenType::RootScreenDPNSScheduledVotes, + Screen::DPNSContestedNamesScreen(dpns_scheduled_votes_screen), + ), ( RootScreenType::RootScreenWalletsBalances, Screen::WalletsBalancesScreen(wallets_balances_screen), @@ -281,6 +300,7 @@ impl AppState { task_result_sender, task_result_receiver, last_repaint, + last_scheduled_vote_check: Instant::now(), } } @@ -396,6 +416,7 @@ impl App for AppState { *status = event; } } + // Poll the receiver for any new task results while let Ok(task_result) = self.task_result_receiver.try_recv() { // Handle the result on the main thread @@ -404,6 +425,9 @@ impl App for AppState { BackendTaskSuccessResult::None => { self.visible_screen_mut().pop_on_success(); } + BackendTaskSuccessResult::Refresh => { + self.visible_screen_mut().refresh(); + } BackendTaskSuccessResult::Message(message) => { self.visible_screen_mut() .display_message(&message, MessageType::Success); @@ -417,6 +441,16 @@ impl App for AppState { BackendTaskSuccessResult::SuccessfulVotes(_) => { self.visible_screen_mut().refresh(); } + BackendTaskSuccessResult::CastScheduledVote(vote) => { + let _ = self + .current_app_context() + .mark_vote_executed(vote.voter_id.as_slice(), vote.contested_name); + self.visible_screen_mut().display_message( + "Successfully cast scheduled vote", + MessageType::Success, + ); + self.visible_screen_mut().refresh(); + } BackendTaskSuccessResult::WithdrawalStatus(_) => { self.visible_screen_mut().display_task_result(message); } @@ -478,6 +512,75 @@ impl App for AppState { } } + // Check if there are scheduled masternode votes to cast and if so, cast them + let now = Instant::now(); + if now.duration_since(self.last_scheduled_vote_check) > Duration::from_secs(60) { + self.last_scheduled_vote_check = now; + let app_context = self.current_app_context(); + + // Query the database + let db_votes = match app_context.get_scheduled_votes() { + Ok(votes) => votes, + Err(e) => { + eprintln!("Error querying scheduled votes: {}", e); + return; + } + }; + + // Filter due votes + let current_time = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let due_votes: Vec<_> = db_votes + .into_iter() + .filter(|v| { + v.unix_timestamp <= current_time + && !v.executed_successfully + && !(v.unix_timestamp + 120000 < current_time) // Don't cast votes more than 2 minutes behind current time + }) + .collect(); + + // For each due vote, construct a BackendTask and handle it + if !due_votes.is_empty() { + let local_identities = match app_context.load_local_voting_identities() { + Ok(identities) => identities, + Err(e) => { + eprintln!("Error querying local voting identities: {}", e); + return; + } + }; + + for vote in due_votes { + if let Some(voter) = local_identities + .iter() + .find(|i| i.identity.id() == vote.voter_id) + { + let dpns_screen = self + .main_screens + .get_mut(&RootScreenType::RootScreenDPNSScheduledVotes) + .unwrap(); + if let Screen::DPNSContestedNamesScreen(screen) = dpns_screen { + screen.vote_cast_in_progress = true; + screen + .scheduled_votes + .lock() + .unwrap() + .iter_mut() + .find(|(v, _)| v == &vote) + .map(|(_, s)| *s = IndividualVoteCastingStatus::InProgress); + } + let task = BackendTask::ContestedResourceTask( + ContestedResourceTask::CastScheduledVote(vote, voter.clone()), + ); + self.handle_backend_task(task); + } else { + eprintln!("Voter not found for scheduled vote: {:?}", vote); + } + } + } + } + // Use a timer to repaint the UI every 0.05 seconds ctx.request_repaint_after(std::time::Duration::from_millis(50)); @@ -486,6 +589,7 @@ impl App for AppState { match action { AppAction::AddScreen(screen) => self.screen_stack.push(screen), AppAction::None => {} + AppAction::Refresh => self.visible_screen_mut().refresh(), AppAction::PopScreen => { if !self.screen_stack.is_empty() { self.screen_stack.pop(); @@ -515,6 +619,16 @@ impl App for AppState { .update_settings(root_screen_type) .ok(); } + AppAction::SetMainScreenThenPopScreen(root_screen_type) => { + self.selected_main_screen = root_screen_type; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context() + .update_settings(root_screen_type) + .ok(); + if !self.screen_stack.is_empty() { + self.screen_stack.pop(); + } + } AppAction::SwitchNetwork(network) => { self.change_network(network); self.current_app_context() diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 05844899b..e1164fdb3 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -8,6 +8,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::Identifier; use dash_sdk::Sdk; use std::sync::Arc; use tokio::sync::mpsc; @@ -15,8 +16,21 @@ use tokio::sync::mpsc; #[derive(Debug, Clone, PartialEq)] pub(crate) enum ContestedResourceTask { QueryDPNSContestedResources, - QueryDPNSVoteContenders(String), VoteOnDPNSName(String, ResourceVoteChoice, Vec), + ScheduleDPNSVotes(Vec), + CastScheduledVote(ScheduledDPNSVote, QualifiedIdentity), + ClearAllScheduledVotes, + ClearExecutedScheduledVotes, + DeleteScheduledVote(Identifier, String), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ScheduledDPNSVote { + pub contested_name: String, + pub voter_id: Identifier, + pub choice: ResourceVoteChoice, + pub unix_timestamp: u64, + pub executed_successfully: bool, } impl AppContext { @@ -31,14 +45,37 @@ impl AppContext { .query_dpns_contested_resources(sdk, sender) .await .map(|_| BackendTaskSuccessResult::None), - ContestedResourceTask::QueryDPNSVoteContenders(name) => self - .query_dpns_vote_contenders(name, sdk, sender) - .await - .map(|_| BackendTaskSuccessResult::None), ContestedResourceTask::VoteOnDPNSName(name, vote_choice, voters) => { self.vote_on_dpns_name(name, *vote_choice, voters, sdk, sender) .await } + ContestedResourceTask::ScheduleDPNSVotes(scheduled_votes) => self + .insert_scheduled_votes(scheduled_votes) + .map(|_| BackendTaskSuccessResult::Message("Votes scheduled".to_string())) + .map_err(|e| format!("Error inserting scheduled votes: {}", e.to_string())), + ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => self + .vote_on_dpns_name( + &scheduled_vote.contested_name, + scheduled_vote.choice, + &vec![voter.clone()], + sdk, + sender, + ) + .await + .map(|_| BackendTaskSuccessResult::CastScheduledVote(scheduled_vote.clone())) + .map_err(|e| format!("Error casting scheduled vote: {}", e.to_string())), + ContestedResourceTask::ClearAllScheduledVotes => self + .clear_all_scheduled_votes() + .map(|_| BackendTaskSuccessResult::Refresh) + .map_err(|e| format!("Error clearing all scheduled votes: {}", e.to_string())), + ContestedResourceTask::ClearExecutedScheduledVotes => self + .clear_executed_scheduled_votes() + .map(|_| BackendTaskSuccessResult::Refresh) + .map_err(|e| format!("Error clearing executed scheduled votes: {}", e.to_string())), + ContestedResourceTask::DeleteScheduledVote(voter_id, contested_name) => self + .delete_scheduled_vote(voter_id.as_slice(), contested_name) + .map(|_| BackendTaskSuccessResult::Refresh) + .map_err(|e| format!("Error clearing scheduled vote: {}", e.to_string())), } } } diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index 1685eccd9..82abdc7dd 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -23,8 +23,15 @@ impl AppContext { vote_choice: ResourceVoteChoice, voters: &Vec, sdk: &Sdk, - _sender: mpsc::Sender, + sender: mpsc::Sender, ) -> Result { + // Send a refresh task to the frontend + // In particular, use this to show the cast is in progress on Scheduled Votes Screen + sender + .send(TaskResult::Refresh) + .await + .map_err(|e| format!("Error voting: {}", e.to_string()))?; + // Fetch DPNS contract and document type information let data_contract = self.dpns_contract.as_ref(); let document_type = data_contract @@ -32,7 +39,7 @@ impl AppContext { .expect("expected document type"); let Some(contested_index) = document_type.find_contested_index() else { - return Err("No contested index on dpns domains".to_string()); + return Err("Error voting: No contested index on dpns domains".to_string()); }; // Hardcoded values for DPNS @@ -75,12 +82,13 @@ impl AppContext { vote_results.push(result); } else { return Err(format!( - "No associated voter identity for qualified identity: {:?}", + "Error voting: No associated voter identity for qualified identity: {:?}", qualified_identity.identity.id() )); } } + // To do: if the voter already voted previously, the previous vote count should be removed self.db .update_vote_count( name, @@ -88,7 +96,7 @@ impl AppContext { strength, vote_choice, ) - .map_err(|e| format!("error updating ending time: {}", e))?; + .map_err(|e| format!("Error voting: Error updating vote count: {}", e))?; Ok(BackendTaskSuccessResult::SuccessfulVotes(vote_results)) } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index c9b9a2ae8..5183c62f7 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -7,6 +7,7 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::withdrawal_statuses::{WithdrawStatusPartialData, WithdrawalsTask}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use contested_names::ScheduledDPNSVote; use dash_sdk::dpp::voting::votes::Vote; use dash_sdk::query_types::Documents; use std::sync::Arc; @@ -32,12 +33,14 @@ pub(crate) enum BackendTask { #[derive(Debug, Clone, PartialEq)] pub(crate) enum BackendTaskSuccessResult { None, + Refresh, Message(String), Documents(Documents), CoreItem(CoreItem), RegisteredIdentity(QualifiedIdentity), ToppedUpIdentity(QualifiedIdentity), SuccessfulVotes(Vec), + CastScheduledVote(ScheduledDPNSVote), WithdrawalStatus(WithdrawStatusPartialData), } diff --git a/src/context.rs b/src/context.rs index 54b15e7af..29733f7e6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,3 +1,4 @@ +use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::config::{Config, NetworkConfig}; use crate::context_provider::Provider; @@ -162,6 +163,10 @@ impl AppContext { .update_local_qualified_identity(qualified_identity, self) } + pub fn set_alias(&self, identifier: &Identifier, new_alias: Option<&str>) -> Result<()> { + self.db.set_alias(identifier, new_alias) + } + /// This is for before we know if Platform will accept the identity pub fn insert_local_qualified_identity_in_creation( &self, @@ -182,6 +187,10 @@ impl AppContext { self.db.get_local_qualified_identities(self, &wallets) } + pub fn load_local_voting_identities(&self) -> Result> { + self.db.get_local_voting_identities(self) + } + pub fn all_contested_names(&self) -> Result> { self.db.get_all_contested_names(self) } @@ -190,6 +199,32 @@ impl AppContext { self.db.get_ongoing_contested_names(self) } + pub fn insert_scheduled_votes(&self, scheduled_votes: &Vec) -> Result<()> { + self.db.insert_scheduled_votes(self, &scheduled_votes) + } + + pub fn get_scheduled_votes(&self) -> Result> { + self.db.get_scheduled_votes(&self) + } + + pub fn clear_all_scheduled_votes(&self) -> Result<()> { + self.db.clear_all_scheduled_votes(self) + } + + pub fn clear_executed_scheduled_votes(&self) -> Result<()> { + self.db.clear_executed_scheduled_votes(self) + } + + pub fn delete_scheduled_vote(&self, identity_id: &[u8], contested_name: &String) -> Result<()> { + self.db + .delete_scheduled_vote(self, identity_id, &contested_name) + } + + pub fn mark_vote_executed(&self, identity_id: &[u8], contested_name: String) -> Result<()> { + self.db + .mark_vote_executed(self, identity_id, contested_name) + } + /// Fetches the local identities from the database and then maps them to their DPNS names. pub fn local_dpns_names(&self) -> Result> { let wallets = self.wallets.read().unwrap(); @@ -255,6 +290,7 @@ impl AppContext { Ok(contracts) } + pub(crate) fn received_transaction_finality( &self, tx: &Transaction, diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 5aec0d490..361cae293 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{params, Connection}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 4; +pub const DEFAULT_DB_VERSION: u16 = 5; pub const DEFAULT_NETWORK: &str = "dash"; @@ -34,6 +34,9 @@ impl Database { fn apply_version_changes(&self, version: u16) -> rusqlite::Result<()> { match version { + 5 => { + self.initialize_scheduled_votes_table()?; + } 4 => { self.initialize_top_up_table()?; } @@ -347,8 +350,8 @@ impl Database { )?; self.initialize_proof_log_table()?; - self.initialize_top_up_table()?; + self.initialize_scheduled_votes_table()?; Ok(()) } diff --git a/src/database/mod.rs b/src/database/mod.rs index 942dfe471..4caef82bc 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -4,6 +4,7 @@ mod contracts; mod identities; mod initialization; mod proof_log; +mod scheduled_votes; mod settings; mod top_ups; mod utxo; diff --git a/src/database/scheduled_votes.rs b/src/database/scheduled_votes.rs new file mode 100644 index 000000000..0e2f46118 --- /dev/null +++ b/src/database/scheduled_votes.rs @@ -0,0 +1,170 @@ +use crate::{ + backend_task::contested_names::ScheduledDPNSVote, context::AppContext, database::Database, +}; +use dash_sdk::{ + dpp::{ + platform_value::string_encoding::Encoding, + voting::vote_choices::resource_vote_choice::ResourceVoteChoice, + }, + platform::Identifier, +}; +use rusqlite::params; + +impl Database { + pub fn initialize_scheduled_votes_table(&self) -> rusqlite::Result<()> { + // Create the scheduled_votes table + self.execute( + "CREATE TABLE IF NOT EXISTS scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0, + network TEXT NOT NULL, + PRIMARY KEY (identity_id, contested_name) + )", + [], + )?; + Ok(()) + } + + pub fn insert_scheduled_votes( + &self, + app_context: &AppContext, + votes: &Vec, + ) -> rusqlite::Result<()> { + let network = app_context.network_string(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + for vote in votes { + let vote_choice = vote.choice.to_string(); + tx.execute( + "INSERT OR REPLACE INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) VALUES (?, ?, ?, ?, 0, ?)", + params![vote.voter_id.as_slice(), vote.contested_name, vote_choice, vote.unix_timestamp, network], + )?; + } + tx.commit()?; + Ok(()) + } + + pub fn delete_scheduled_vote( + &self, + app_context: &AppContext, + identity_id: &[u8], + contested_name: &str, + ) -> rusqlite::Result<()> { + let network = app_context.network_string(); + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM scheduled_votes WHERE identity_id = ? AND contested_name = ? AND network = ?", + params![identity_id, contested_name, network], + )?; + Ok(()) + } + + pub fn mark_vote_executed( + &self, + app_context: &AppContext, + identity_id: &[u8], + contested_name: String, + ) -> rusqlite::Result<()> { + let network = app_context.network_string(); + self.execute( + "UPDATE scheduled_votes SET executed = 1 WHERE identity_id = ? AND contested_name = ? AND network = ?", + params![identity_id, contested_name, network], + )?; + Ok(()) + } + + pub fn get_scheduled_votes( + &self, + app_context: &AppContext, + ) -> rusqlite::Result> { + let network = app_context.network_string(); + + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT * FROM scheduled_votes WHERE network = ?")?; + let votes_iter = stmt.query_map(params![network], |row| { + let voter_id_bytes: Vec = row.get(0)?; + let contested_name: String = row.get(1)?; + let vote_choice_string: String = row.get(2)?; + let time: u64 = row.get(3)?; + let executed_successfully: bool = match row.get(4)? { + 0 => false, + 1 => true, + _ => unreachable!(), + }; + + let vote_choice = match vote_choice_string.as_str() { + "Abstain" => ResourceVoteChoice::Abstain, + "Lock" => ResourceVoteChoice::Lock, + other => { + if let Some(inner) = other.strip_prefix("TowardsIdentity(") { + if let Some(inner) = inner.strip_suffix(')') { + let towards_id = inner.to_string(); + ResourceVoteChoice::TowardsIdentity( + Identifier::from_string(&towards_id, Encoding::Base58).map_err( + |e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Blob, + Box::new(e), + ) + }, + )?, + ) + } else { + return Err(rusqlite::Error::InvalidQuery); + } + } else { + return Err(rusqlite::Error::InvalidQuery); + } + } + }; + + let scheduled_vote = ScheduledDPNSVote { + voter_id: Identifier::from_bytes(&voter_id_bytes).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Blob, + Box::new(e), + ) + })?, + contested_name, + choice: vote_choice, + unix_timestamp: time, + executed_successfully, + }; + + Ok(scheduled_vote) + })?; + + let scheduled_votes: rusqlite::Result> = votes_iter.collect(); + scheduled_votes + } + + /// Clear all scheduled votes from the db + pub fn clear_all_scheduled_votes(&self, app_context: &AppContext) -> rusqlite::Result<()> { + let network = app_context.network_string(); + let conn = self.conn.lock().unwrap(); + + conn.execute( + "DELETE FROM scheduled_votes WHERE network = ?", + params![network], + )?; + + Ok(()) + } + + pub fn clear_executed_scheduled_votes(&self, app_context: &AppContext) -> rusqlite::Result<()> { + let network = app_context.network_string(); + let conn = self.conn.lock().unwrap(); + + conn.execute( + "DELETE FROM scheduled_votes WHERE executed = 1 AND network = ?", + params![network], + )?; + + Ok(()) + } +} diff --git a/src/ui/components/dpns_subscreen_chooser_panel.rs b/src/ui/components/dpns_subscreen_chooser_panel.rs index c88a7771c..91f848792 100644 --- a/src/ui/components/dpns_subscreen_chooser_panel.rs +++ b/src/ui/components/dpns_subscreen_chooser_panel.rs @@ -11,6 +11,7 @@ pub fn add_dpns_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) DPNSSubscreen::Active, DPNSSubscreen::Past, DPNSSubscreen::Owned, + DPNSSubscreen::ScheduledVotes, ]; let active_screen = match app_context.get_settings() { @@ -18,6 +19,7 @@ pub fn add_dpns_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) ui::RootScreenType::RootScreenDPNSActiveContests => DPNSSubscreen::Active, ui::RootScreenType::RootScreenDPNSPastContests => DPNSSubscreen::Past, ui::RootScreenType::RootScreenDPNSOwnedNames => DPNSSubscreen::Owned, + ui::RootScreenType::RootScreenDPNSScheduledVotes => DPNSSubscreen::ScheduledVotes, _ => DPNSSubscreen::Active, }, _ => DPNSSubscreen::Active, // Fallback to Active screen if settings unavailable @@ -66,6 +68,11 @@ pub fn add_dpns_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) RootScreenType::RootScreenDPNSOwnedNames, ) } + DPNSSubscreen::ScheduledVotes => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenDPNSScheduledVotes, + ) + } } } diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index a159e6cc7..e4c605d51 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -152,8 +152,10 @@ pub fn add_top_panel( // Right-aligned content with buttons ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add_space(5.0); + for (text, right_button_action) in right_buttons.into_iter().rev() { - ui.add_space(8.0); + ui.add_space(3.0); let font_id = egui::FontId::proportional(16.0); // Adjust font size as needed let color = Color32::WHITE; diff --git a/src/ui/dpns_contested_names_screen.rs b/src/ui/dpns_contested_names_screen.rs index d9edf03a7..85cc2d8d4 100644 --- a/src/ui/dpns_contested_names_screen.rs +++ b/src/ui/dpns_contested_names_screen.rs @@ -1,7 +1,9 @@ use super::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; +use super::dpns_vote_scheduling_screen::ScheduleVoteScreen; use super::{Screen, ScreenType}; use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::backend_task::identity::IdentityTask; use crate::backend_task::BackendTask; use crate::context::AppContext; @@ -13,10 +15,11 @@ use crate::ui::identities::add_existing_identity_screen::AddExistingIdentityScre use crate::ui::{MessageType, RootScreenType, ScreenLike}; use chrono::{DateTime, LocalResult, TimeZone, Utc}; use chrono_humanize::HumanTime; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; -use egui::{Context, Frame, Margin, Ui}; +use egui::{Color32, Context, Frame, Margin, Ui}; use egui_extras::{Column, TableBuilder}; use itertools::Itertools; use std::sync::{Arc, Mutex}; @@ -43,6 +46,15 @@ pub enum DPNSSubscreen { Active, Past, Owned, + ScheduledVotes, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum IndividualVoteCastingStatus { + NotStarted, + InProgress, + Failed, + Completed, } impl DPNSSubscreen { @@ -51,6 +63,7 @@ impl DPNSSubscreen { Self::Active => "Active contests", Self::Past => "Past contests", Self::Owned => "My usernames", + Self::ScheduledVotes => "Scheduled votes", } } } @@ -61,11 +74,14 @@ pub struct DPNSContestedNamesScreen { user_identities: Vec, contested_names: Arc>>, local_dpns_names: Arc>>, + pub scheduled_votes: Arc>>, pub app_context: Arc, error_message: Option<(String, MessageType, DateTime)>, sort_column: SortColumn, sort_order: SortOrder, show_vote_popup_info: Option<(String, ContestedResourceTask)>, + popup_pending_vote_action: Option, + pub vote_cast_in_progress: bool, pub dpns_subscreen: DPNSSubscreen, refreshing: bool, } @@ -82,12 +98,24 @@ impl DPNSContestedNamesScreen { Vec::new() }), DPNSSubscreen::Owned => Vec::new(), + DPNSSubscreen::ScheduledVotes => Vec::new(), })); let local_dpns_names = Arc::new(Mutex::new(match dpns_subscreen { DPNSSubscreen::Active => Vec::new(), DPNSSubscreen::Past => Vec::new(), DPNSSubscreen::Owned => app_context.local_dpns_names().unwrap_or_default(), + DPNSSubscreen::ScheduledVotes => Vec::new(), })); + let scheduled_votes = app_context.get_scheduled_votes().unwrap_or_default(); + let scheduled_votes_with_status = Arc::new(Mutex::new( + scheduled_votes + .iter() + .map(|vote| match vote.executed_successfully { + true => (vote.clone(), IndividualVoteCastingStatus::Completed), + false => (vote.clone(), IndividualVoteCastingStatus::NotStarted), + }) + .collect::>(), + )); let voting_identities = app_context .db .get_local_voting_identities(&app_context) @@ -101,11 +129,14 @@ impl DPNSContestedNamesScreen { user_identities, contested_names, local_dpns_names, + scheduled_votes: scheduled_votes_with_status, app_context: app_context.clone(), error_message: None, sort_column: SortColumn::ContestedName, sort_order: SortOrder::Ascending, show_vote_popup_info: None, + popup_pending_vote_action: None, + vote_cast_in_progress: false, dpns_subscreen, refreshing: false, } @@ -141,12 +172,13 @@ impl DPNSContestedNamesScreen { if ui.button(text).clicked() { self.show_vote_popup_info = Some(( format!( - "Confirm Voting for Contestant {} for name \"{}\".\n\nSelect the identity to vote with:", + "Confirm Voting for Contestant {} for name \"{}\".", contestant.id, contestant.name ), ContestedResourceTask::VoteOnDPNSName( contested_name.normalized_contested_name.clone(), - ResourceVoteChoice::TowardsIdentity(contestant.id),vec![] + ResourceVoteChoice::TowardsIdentity(contestant.id), + vec![], ), )); } @@ -232,28 +264,43 @@ impl DPNSContestedNamesScreen { .color(egui::Color32::GRAY), ); } + DPNSSubscreen::ScheduledVotes => { + ui.label( + egui::RichText::new("No scheduled votes.") + .heading() + .strong() + .color(egui::Color32::GRAY), + ); + } } ui.add_space(10.0); - ui.label("Please check back later or try refreshing the list."); - ui.add_space(20.0); - if ui.button("Refresh").clicked() { - if self.refreshing { - app_action |= AppAction::None; - } else { - match self.dpns_subscreen { - DPNSSubscreen::Active | DPNSSubscreen::Past => { - app_action |= - AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::QueryDPNSContestedResources, + if self.dpns_subscreen != DPNSSubscreen::ScheduledVotes { + ui.label("Please check back later or try refreshing the list."); + ui.add_space(20.0); + if ui.button("Refresh").clicked() { + if self.refreshing { + app_action |= AppAction::None; + } else { + match self.dpns_subscreen { + DPNSSubscreen::Active | DPNSSubscreen::Past => { + app_action |= + AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContestedResources, + )); + } + DPNSSubscreen::Owned => { + app_action |= AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, )); - } - DPNSSubscreen::Owned => { - app_action |= AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, - )); + } + _ => { + app_action |= AppAction::Refresh; + } } } } + } else { + ui.label("Go to the Active Contests subscreen to schedule votes."); } }); @@ -375,7 +422,7 @@ impl DPNSContestedNamesScreen { }; // Vote button logic for locked votes if ui.button(label_text).clicked() { - self.show_vote_popup_info = Some((format!("Confirm Voting to Lock the name \"{}\".\n\nSelect the identity to vote with:", contested_name.normalized_contested_name.clone()), ContestedResourceTask::VoteOnDPNSName(contested_name.normalized_contested_name.clone(), ResourceVoteChoice::Lock, vec![]))); + self.show_vote_popup_info = Some((format!("Confirm Voting to Lock the name \"{}\".", contested_name.normalized_contested_name.clone()), ContestedResourceTask::VoteOnDPNSName(contested_name.normalized_contested_name.clone(), ResourceVoteChoice::Lock, vec![]))); } }); row.col(|ui| { @@ -387,7 +434,7 @@ impl DPNSContestedNamesScreen { "Fetching".to_string() }; if ui.button(label_text).clicked() { - self.show_vote_popup_info = Some((format!("Confirm Voting to Abstain on distribution of \"{}\".\n\nSelect the identity to vote with:", contested_name.normalized_contested_name.clone()), ContestedResourceTask::VoteOnDPNSName(contested_name.normalized_contested_name.clone(), ResourceVoteChoice::Abstain, vec![]))); + self.show_vote_popup_info = Some((format!("Confirm Voting to Abstain on distribution of \"{}\".", contested_name.normalized_contested_name.clone()), ContestedResourceTask::VoteOnDPNSName(contested_name.normalized_contested_name.clone(), ResourceVoteChoice::Abstain, vec![]))); } }); row.col(|ui| { @@ -688,6 +735,200 @@ impl DPNSContestedNamesScreen { }); } + fn render_table_scheduled_votes(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let mut sorted_votes = { + let scheduled_votes_guard = self.scheduled_votes.lock().unwrap(); + let scheduled_votes = scheduled_votes_guard.clone(); + scheduled_votes + }; + + sorted_votes.sort_by(|a, b| match self.sort_column { + SortColumn::ContestedName => { + let order = a.0.contested_name.cmp(&b.0.contested_name); // Sort by DPNS Name + if self.sort_order == SortOrder::Descending { + order.reverse() + } else { + order + } + } + SortColumn::EndingTime => { + let order = a.0.unix_timestamp.cmp(&b.0.unix_timestamp); // Sort by Vote Time + if self.sort_order == SortOrder::Descending { + order.reverse() + } else { + order + } + } + _ => std::cmp::Ordering::Equal, + }); + + // Render table UI + egui::ScrollArea::vertical().show(ui, |ui| { + Frame::group(ui.style()) + .fill(ui.visuals().panel_fill) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.inactive.bg_stroke.color, + )) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::initial(100.0).resizable(true)) // DPNS Name + .column(Column::initial(200.0).resizable(true)) // Voter ID + .column(Column::initial(200.0).resizable(true)) // Choice + .column(Column::initial(200.0).resizable(true)) // Scheduled vote time + .column(Column::initial(100.0).resizable(true)) // Executed? + .column(Column::initial(100.0).resizable(true)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { + if ui.button("Contested Name").clicked() { + self.toggle_sort(SortColumn::ContestedName); + } + }); + header.col(|ui| { + ui.label("Voter"); + }); + header.col(|ui| { + ui.label("Vote Choice"); + }); + header.col(|ui| { + if ui.button("Scheduled Time").clicked() { + self.toggle_sort(SortColumn::EndingTime); + } + }); + header.col(|ui| { + ui.label("Status"); + }); + header.col(|ui| { + ui.label("Actions"); + }); + }) + .body(|mut body| { + for vote in sorted_votes.iter_mut() { + body.row(25.0, |mut row| { + row.col(|ui| { + ui.add( + egui::Label::new(vote.0.contested_name.clone()) + .truncate(), + ); + }); + row.col(|ui| { + ui.add( + egui::Label::new( + vote.0.voter_id.to_string(Encoding::Hex), + ) + .truncate(), + ); + }); + row.col(|ui| { + let display_text = match &vote.0.choice { + ResourceVoteChoice::TowardsIdentity(identifier) => { + identifier.to_string(Encoding::Base58) + } + other => other.to_string(), + }; + ui.add(egui::Label::new(display_text).truncate()); + }); + row.col(|ui| { + if let LocalResult::Single(datetime) = + Utc.timestamp_millis_opt(vote.0.unix_timestamp as i64) + { + let iso_date = + datetime.format("%Y-%m-%d %H:%M:%S").to_string(); + let relative_time = + HumanTime::from(datetime).to_string(); + let display_text = + format!("{} ({})", iso_date, relative_time); + ui.add(egui::Label::new(display_text).truncate()); + } else { + ui.label("Invalid timestamp"); + } + }); + row.col(|ui| match vote.1 { + IndividualVoteCastingStatus::NotStarted => { + ui.label("Pending"); + } + IndividualVoteCastingStatus::InProgress => { + ui.label("Casting..."); + } + IndividualVoteCastingStatus::Failed => { + ui.colored_label(Color32::DARK_RED, "Failed"); + } + IndividualVoteCastingStatus::Completed => { + ui.colored_label(Color32::DARK_GREEN, "Casted"); + } + }); + row.col(|ui| { + if ui.button("Remove").clicked() { + action = AppAction::BackendTask( + BackendTask::ContestedResourceTask( + ContestedResourceTask::DeleteScheduledVote( + vote.0.voter_id.clone(), + vote.0.contested_name.clone(), + ), + ), + ); + } + + let cast_button = match vote.1 { + IndividualVoteCastingStatus::NotStarted => egui::Button::new("Cast Now"), + IndividualVoteCastingStatus::InProgress => egui::Button::new("Casting..."), + IndividualVoteCastingStatus::Failed => egui::Button::new("Cast Now"), + IndividualVoteCastingStatus::Completed => egui::Button::new("Completed"), + }; + + if !self.vote_cast_in_progress && (vote.1 == IndividualVoteCastingStatus::NotStarted || vote.1 == IndividualVoteCastingStatus::Failed) { + if ui.add(cast_button).clicked() { + self.vote_cast_in_progress = true; + + // Update the local vote + vote.1 = IndividualVoteCastingStatus::InProgress; + + // Now also update self.scheduled_votes + if let Ok(mut scheduled_guard) = self.scheduled_votes.lock() { + if let Some(sched_vote) = scheduled_guard.iter_mut().find(|(sv, _)| { + sv.voter_id == vote.0.voter_id && sv.contested_name == vote.0.contested_name + }) { + sched_vote.1 = IndividualVoteCastingStatus::InProgress; + } + } + + // Trigger the CastScheduledVote task + let local_identities = + match self.app_context.load_local_voting_identities() { + Ok(identities) => identities, + Err(e) => { + eprintln!("Error querying local voting identities: {}", e); + return; + } + }; + + if let Some(voter) = local_identities + .iter() + .find(|i| i.identity.id() == vote.0.voter_id) + { + action = AppAction::BackendTask( + BackendTask::ContestedResourceTask( + ContestedResourceTask::CastScheduledVote(vote.0.clone(), voter.clone()), + ), + ); + } + } + } + }); + }); + } + }); + }); + }); + + action + } + fn show_vote_popup(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; if self.voting_identities.is_empty() { @@ -700,63 +941,105 @@ impl DPNSContestedNamesScreen { } if ui.button("Cancel").clicked() { self.show_vote_popup_info = None; + self.popup_pending_vote_action = None; } } else if let Some((message, action)) = self.show_vote_popup_info.clone() { ui.label(message); + ui.add_space(10.0); + + if self.popup_pending_vote_action.is_none() { + ui.label("Select the identity to vote with:"); + } else { + ui.label("Would you like to vote now or schedule your votes?"); + } + + ui.add_space(10.0); + ui.horizontal(|ui| { - // Only modify `voters` if `action` is `VoteOnDPNSName` if let ContestedResourceTask::VoteOnDPNSName( contested_name, vote_choice, mut voters, ) = action { - // Iterate over the voting identities and create a button for each one - for identity in self.voting_identities.iter() { - if ui.button(identity.display_short_string()).clicked() { - // Add the selected identity to the `voters` field - voters.push(identity.clone()); + // If we haven't yet chosen any voters (pending_vote_action is None), we show the identities + if self.popup_pending_vote_action.is_none() { + // Iterate over the voting identities and create a button for each one + for identity in self.voting_identities.iter() { + if ui.button(identity.display_short_string()).clicked() { + // Add the selected identity to the `voters` field + voters.push(identity.clone()); - // Create a new `VoteOnDPNSName` task with updated voters + // Store the updated action, but don't finalize yet + let updated_action = ContestedResourceTask::VoteOnDPNSName( + contested_name.clone(), + vote_choice.clone(), + voters.clone(), + ); + self.popup_pending_vote_action = Some(updated_action); + } + } + + // Vote with all identities + if ui.button("All").clicked() { + voters.extend(self.voting_identities.iter().cloned()); let updated_action = ContestedResourceTask::VoteOnDPNSName( contested_name.clone(), vote_choice.clone(), - voters.clone(), // Updated voters - ); - - // Pass updated action to BackendTask - app_action = AppAction::BackendTask( - BackendTask::ContestedResourceTask(updated_action), + voters.clone(), ); + self.popup_pending_vote_action = Some(updated_action); + } + } else { + // If we have a pending vote action, ask whether to vote now or schedule + if ui.button("Vote Now").clicked() { + // Finalize the vote now + app_action = + AppAction::BackendTask(BackendTask::ContestedResourceTask( + self.popup_pending_vote_action.take().unwrap(), + )); self.show_vote_popup_info = None; } - } + if ui.button("Schedule").clicked() { + // Move to a scheduling screen instead + let pending = self.popup_pending_vote_action.take().unwrap(); + if let ContestedResourceTask::VoteOnDPNSName( + name_string, + vote_choice, + voters, + ) = pending + { + // Lock and get a reference to the contested names + let contested_names = self.contested_names.lock().unwrap(); - // Vote with all identities - if ui.button("All").clicked() { - for identity in self.voting_identities.iter() { - voters.push(identity.clone()); + // Find the contested name that matches the given name_string + let ending_time = contested_names + .iter() + .find(|cn| cn.normalized_contested_name == name_string) + .and_then(|cn| cn.end_time) + .unwrap_or_default(); + let contested_name = name_string.clone(); + let schedule_screen = ScheduleVoteScreen::new( + &self.app_context, + contested_name, + ending_time, + voters, + vote_choice, + ); + app_action = AppAction::AddScreen(Screen::ScheduleVoteScreen( + schedule_screen, + )); + } + self.show_vote_popup_info = None; } - - // Create a new `VoteOnDPNSName` task with all voters - let updated_action = ContestedResourceTask::VoteOnDPNSName( - contested_name.clone(), - vote_choice.clone(), - voters.clone(), // Updated voters - ); - - // Pass updated action to BackendTask - app_action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - updated_action, - )); - self.show_vote_popup_info = None; } } // Add the "Cancel" button if ui.button("Cancel").clicked() { self.show_vote_popup_info = None; + self.popup_pending_vote_action = None; } }); } @@ -767,8 +1050,10 @@ impl DPNSContestedNamesScreen { impl ScreenLike for DPNSContestedNamesScreen { fn refresh(&mut self) { + self.vote_cast_in_progress = false; let mut contested_names = self.contested_names.lock().unwrap(); let mut dpns_names = self.local_dpns_names.lock().unwrap(); + let mut scheduled_votes = self.scheduled_votes.lock().unwrap(); match self.dpns_subscreen { DPNSSubscreen::Active => { *contested_names = self @@ -782,6 +1067,36 @@ impl ScreenLike for DPNSContestedNamesScreen { DPNSSubscreen::Owned => { *dpns_names = self.app_context.local_dpns_names().unwrap_or_default(); } + DPNSSubscreen::ScheduledVotes => { + *scheduled_votes = { + let new_scheduled_votes = + self.app_context.get_scheduled_votes().unwrap_or_default(); + new_scheduled_votes + .iter() + .map(|new_vote| match new_vote.executed_successfully { + true => (new_vote.clone(), IndividualVoteCastingStatus::Completed), + false => scheduled_votes + .iter() + .find(|(old_vote, _)| { + old_vote.contested_name == new_vote.contested_name + && old_vote.voter_id == new_vote.voter_id + }) + .map(|(_, status)| { + if status == &IndividualVoteCastingStatus::InProgress { + (new_vote.clone(), IndividualVoteCastingStatus::InProgress) + } else if status == &IndividualVoteCastingStatus::Failed { + (new_vote.clone(), IndividualVoteCastingStatus::Failed) + } else { + (new_vote.clone(), IndividualVoteCastingStatus::NotStarted) + } + }) + .unwrap_or_else(|| { + (new_vote.clone(), IndividualVoteCastingStatus::NotStarted) + }), + }) + .collect::>() + } + } } } @@ -802,6 +1117,7 @@ impl ScreenLike for DPNSContestedNamesScreen { let mut contested_names = self.contested_names.lock().unwrap(); let mut dpns_names = self.local_dpns_names.lock().unwrap(); + let mut scheduled_votes = self.scheduled_votes.lock().unwrap(); match self.dpns_subscreen { DPNSSubscreen::Active => { *contested_names = self @@ -815,6 +1131,38 @@ impl ScreenLike for DPNSContestedNamesScreen { DPNSSubscreen::Owned => { *dpns_names = self.app_context.local_dpns_names().unwrap_or_default(); } + DPNSSubscreen::ScheduledVotes => { + *scheduled_votes = { + let new_scheduled_votes = + self.app_context.get_scheduled_votes().unwrap_or_default(); + new_scheduled_votes + .iter() + .map(|new_vote| match new_vote.executed_successfully { + true => (new_vote.clone(), IndividualVoteCastingStatus::Completed), + // If false, it could be failed, in progress, or not started + // Check screen state to see if vote is in progress + false => scheduled_votes + .iter() + .find(|(old_vote, _)| { + old_vote.contested_name == new_vote.contested_name + && old_vote.voter_id == new_vote.voter_id + }) + .map(|(_, status)| { + if status == &IndividualVoteCastingStatus::InProgress { + (new_vote.clone(), IndividualVoteCastingStatus::InProgress) + } else if status == &IndividualVoteCastingStatus::Failed { + (new_vote.clone(), IndividualVoteCastingStatus::Failed) + } else { + (new_vote.clone(), IndividualVoteCastingStatus::NotStarted) + } + }) + .unwrap_or_else(|| { + (new_vote.clone(), IndividualVoteCastingStatus::NotStarted) + }), + }) + .collect::>() + } + } } } @@ -826,41 +1174,105 @@ impl ScreenLike for DPNSContestedNamesScreen { { self.refreshing = false; } + if message.contains("Error casting scheduled vote") { + self.vote_cast_in_progress = false; + let mut scheduled_votes = self.scheduled_votes.lock().unwrap(); + for vote in scheduled_votes.iter_mut() { + if vote.1 == IndividualVoteCastingStatus::InProgress { + vote.1 = IndividualVoteCastingStatus::Failed; + } + } + } + if message.contains("Successfully cast scheduled vote") { + self.vote_cast_in_progress = false; + } self.error_message = Some((message.to_string(), message_type, Utc::now())); } fn ui(&mut self, ctx: &Context) -> AppAction { self.check_error_expiration(); - let mut top_panel_refresh_button = match self.dpns_subscreen { - DPNSSubscreen::Active | DPNSSubscreen::Past => ( - "Refresh", - DesiredAppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::QueryDPNSContestedResources, - )), - ), - DPNSSubscreen::Owned => ( - "Refresh", - DesiredAppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, - )), - ), - }; - if self.refreshing { - top_panel_refresh_button = ("Refreshing...", DesiredAppAction::None) - } let has_identity_that_can_register = !self.user_identities.is_empty(); - let right_buttons = if has_identity_that_can_register { - vec![ + + // Determine the right-side buttons based on the current DPNSSubscreen + let mut right_buttons = match self.dpns_subscreen { + DPNSSubscreen::Active => { + let refresh_button = if self.refreshing { + ("Refreshing...", DesiredAppAction::None) + } else { + ( + "Refresh", + DesiredAppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContestedResources, + )), + ) + }; + + vec![refresh_button] + } + + DPNSSubscreen::Past => { + // Past contests: similar to Active + let refresh_button = if self.refreshing { + ("Refreshing...", DesiredAppAction::None) + } else { + ( + "Refresh", + DesiredAppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContestedResources, + )), + ) + }; + + vec![refresh_button] + } + + DPNSSubscreen::Owned => { + // Owned names: refresh or refreshing + let refresh_button = if self.refreshing { + ("Refreshing...", DesiredAppAction::None) + } else { + ( + "Refresh", + DesiredAppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, + )), + ) + }; + + vec![refresh_button] + } + + DPNSSubscreen::ScheduledVotes => { + // Scheduled votes: "Refresh", "Clear All", and "Clear Executed" + vec![ + ("Refresh", DesiredAppAction::Refresh), + ( + "Clear All", + DesiredAppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::ClearAllScheduledVotes, + )), + ), + ( + "Clear Executed", + DesiredAppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::ClearExecutedScheduledVotes, + )), + ), + ] + } + }; + + if has_identity_that_can_register { + right_buttons.insert( + 0, ( "Register Name", DesiredAppAction::AddScreenType(ScreenType::RegisterDpnsName), ), - top_panel_refresh_button, - ] - } else { - vec![top_panel_refresh_button] - }; + ); + } + let mut action = add_top_panel( ctx, &self.app_context, @@ -890,6 +1302,13 @@ impl ScreenLike for DPNSContestedNamesScreen { RootScreenType::RootScreenDPNSOwnedNames, ); } + DPNSSubscreen::ScheduledVotes => { + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenDPNSScheduledVotes, + ); + } } action |= add_dpns_subscreen_chooser_panel(ctx, self.app_context.as_ref()); @@ -944,6 +1363,11 @@ impl ScreenLike for DPNSContestedNamesScreen { let dpns_names = self.local_dpns_names.lock().unwrap(); !dpns_names.is_empty() }; + // Check if there are any scheduled votes to display + let has_scheduled_votes = { + let scheduled_votes = self.scheduled_votes.lock().unwrap(); + !scheduled_votes.is_empty() + }; // Render the proper table match self.dpns_subscreen { @@ -968,6 +1392,13 @@ impl ScreenLike for DPNSContestedNamesScreen { action |= self.render_no_active_contests_or_owned_names(ui); } } + DPNSSubscreen::ScheduledVotes => { + if has_scheduled_votes { + action |= self.render_table_scheduled_votes(ui); + } else { + action |= self.render_no_active_contests_or_owned_names(ui); + } + } } }); diff --git a/src/ui/dpns_vote_scheduling_screen.rs b/src/ui/dpns_vote_scheduling_screen.rs new file mode 100644 index 000000000..bf3f80a0f --- /dev/null +++ b/src/ui/dpns_vote_scheduling_screen.rs @@ -0,0 +1,289 @@ +use crate::app::AppAction; +use crate::backend_task::contested_names::ScheduledDPNSVote; +use crate::backend_task::{contested_names::ContestedResourceTask, BackendTask}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::{MessageType, ScreenLike}; +use chrono::offset::LocalResult; +use chrono::{Duration, TimeZone, Utc}; +use chrono_humanize::HumanTime; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use eframe::egui::Context; +use eframe::egui::{self, Color32, RichText, Ui}; +use std::sync::Arc; + +use super::components::top_panel::add_top_panel; +use super::RootScreenType; + +enum VoteOption { + None, + Scheduled { days: u32, hours: u32, minutes: u32 }, +} + +pub struct ScheduleVoteScreen { + pub app_context: Arc, + pub contested_name: String, + pub ending_time: u64, + pub identities: Vec, + pub vote_choice: ResourceVoteChoice, + identity_options: Vec, + message: Option<(MessageType, String)>, +} + +impl ScheduleVoteScreen { + pub fn new( + app_context: &Arc, + contested_name: String, + ending_time: u64, + potential_voting_identities: Vec, + vote_choice: ResourceVoteChoice, + ) -> Self { + let identity_options = potential_voting_identities + .iter() + .map(|_| VoteOption::Scheduled { + days: 0, + hours: 0, + minutes: 0, + }) + .collect(); + + // Default everything to 0 (i.e., "now") + Self { + app_context: app_context.clone(), + contested_name, + ending_time, + identities: potential_voting_identities, + vote_choice, + identity_options, + message: None, + } + } + + fn display_identity_options(&mut self, ui: &mut Ui) { + // For each identity, show a row with their alias/ID and voting options + for (i, identity) in self.identities.iter().enumerate() { + ui.group(|ui| { + ui.horizontal(|ui| { + // Identity label + let identity_label = identity + .alias + .as_ref() + .map(|a| a.clone()) + .unwrap_or(identity.identity.id().to_string(Encoding::Base58)); + ui.label(format!("Identity: {}", identity_label)); + + // Dropdown for None/VoteNow/Scheduled + let current_option = &mut self.identity_options[i]; + egui::ComboBox::from_id_salt(format!("combo_for_identity_{}", i)) + .selected_text(match current_option { + VoteOption::None => "None".to_string(), + VoteOption::Scheduled { .. } => "Scheduled".to_string(), + }) + .show_ui(ui, |ui| { + if ui + .selectable_label( + matches!(current_option, VoteOption::None), + "None", + ) + .clicked() + { + *current_option = VoteOption::None; + self.message = None; + } + if ui + .selectable_label( + matches!(current_option, VoteOption::Scheduled { .. }), + "Scheduled", + ) + .clicked() + { + // If we had a previous scheduled option, keep the old values. Otherwise, default to 0. + let (days, hours, minutes) = match current_option { + VoteOption::Scheduled { + days, + hours, + minutes, + } => (*days, *hours, *minutes), + _ => (0, 0, 0), + }; + *current_option = VoteOption::Scheduled { + days, + hours, + minutes, + }; + self.message = None; + } + }); + + // If Scheduled is chosen, let the user pick how far in the future + if let VoteOption::Scheduled { + days, + hours, + minutes, + } = current_option + { + ui.label("Schedule Vote In:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(days).range(0..=14).prefix("Days: ")); + ui.add(egui::DragValue::new(hours).range(0..=23).prefix("Hours: ")); + ui.add(egui::DragValue::new(minutes).range(0..=59).prefix("Min: ")); + }); + } + }); + }); + + ui.add_space(10.0); + } + } + + fn cast_votes_button(&mut self) -> AppAction { + let mut scheduled_votes = Vec::new(); + + for (identity, option) in self.identities.iter().zip(self.identity_options.iter()) { + match option { + VoteOption::None => {} + VoteOption::Scheduled { + days, + hours, + minutes, + } => { + let now = chrono::Utc::now(); + let offset = Duration::days((*days).into()) + + Duration::hours((*hours).into()) + + Duration::minutes((*minutes).into()); + + let scheduled_time = now + offset; + let chosen_time = scheduled_time.timestamp_millis() as u64; + + if chosen_time > self.ending_time { + self.message = Some(( + MessageType::Error, + "Scheduled time is after contest end time.".to_string(), + )); + return AppAction::None; + } + + let scheduled_vote = ScheduledDPNSVote { + contested_name: self.contested_name.clone(), + voter_id: identity.identity.id().clone(), + choice: self.vote_choice, + unix_timestamp: chosen_time, + executed_successfully: false, + }; + scheduled_votes.push(scheduled_vote); + } + } + } + + if scheduled_votes.is_empty() { + self.message = Some((MessageType::Error, "No votes selected.".to_string())); + return AppAction::None; + } + + let updated_action = ContestedResourceTask::ScheduleDPNSVotes(scheduled_votes); + AppAction::BackendTask(BackendTask::ContestedResourceTask(updated_action)) + } + + fn show_success(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Center the content vertically and horizontally + ui.vertical_centered(|ui| { + ui.add_space(50.0); + + ui.heading("🎉"); + ui.heading("Successfully scheduled votes."); + + ui.add_space(20.0); + + if ui.button("Go to Scheduled Votes Screen").clicked() { + // Handle navigation back to the identities screen + action = AppAction::SetMainScreenThenPopScreen( + RootScreenType::RootScreenDPNSScheduledVotes, + ); + } + + ui.add_space(10.0); + + if ui.button("Go back to Active Contests").clicked() { + // Handle navigation back to the identities screen + action = AppAction::PopScreenAndRefresh; + } + }); + + action + } +} + +impl ScreenLike for ScheduleVoteScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message_type, message.to_string())); + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("DPNS", AppAction::GoToMainScreen), + ("Schedule Votes", AppAction::None), + ], + vec![], + ); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("Schedule Votes"); + ui.add_space(10.0); + + ui.label("Please note that Dash Evo Tool must be running and connected to Platform in order for scheduled votes to execute at the specified time."); + ui.add_space(10.0); + + // Convert the timestamp to a DateTime object using timestamp_millis_opt + if let LocalResult::Single(datetime) = Utc.timestamp_millis_opt(self.ending_time as i64) { + let iso_date = datetime.format("%Y-%m-%d %H:%M:%S").to_string(); + let relative_time = HumanTime::from(datetime).to_string(); + let display_text = format!( + "Contest for name {} ends at {} ({})", + self.contested_name, iso_date, relative_time + ); + ui.label(display_text); + } else { + ui.colored_label( + Color32::DARK_RED, + "Error getting contest ending time".to_string(), + ); + } + ui.add_space(10.0); + + self.display_identity_options(ui); + ui.add_space(10.0); + + let button = egui::Button::new(RichText::new("Schedule Votes").color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .rounding(3.0); + if ui.add(button).clicked() { + action = self.cast_votes_button(); + } + ui.add_space(10.0); + + if let Some(message) = &self.message { + match message.0 { + MessageType::Error => { + ui.colored_label(Color32::DARK_RED, message.1.clone()); + } + MessageType::Success => { + action = self.show_success(ui); + } + MessageType::Info => { + ui.colored_label(Color32::DARK_BLUE, message.1.clone()); + } + } + ui.add_space(10.0); + } + }); + + action + } +} diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index b3e3e4883..8ff9f50aa 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -88,7 +88,7 @@ impl IdentitiesScreen { } else { identity_to_update.alias = Some(alias); } - match self.app_context.db.set_alias( + match self.app_context.set_alias( &identity_to_update.identity.id(), identity_to_update.alias.as_ref().map(|s| s.as_str()), ) { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9a3513a62..4b77e0aa1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,8 +20,10 @@ use crate::ui::wallet::wallets_screen::WalletsBalancesScreen; use crate::ui::withdrawal_statuses_screen::WithdrawsStatusScreen; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::prelude::IdentityPublicKey; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpns_contested_names_screen::DPNSSubscreen; -use egui::{Context, Widget}; +use dpns_vote_scheduling_screen::ScheduleVoteScreen; +use egui::Context; use identities::add_existing_identity_screen::AddExistingIdentityScreen; use identities::add_new_identity_screen::AddNewIdentityScreen; use identities::identities_screen::IdentitiesScreen; @@ -35,6 +37,7 @@ use wallet::add_new_wallet_screen::AddNewWalletScreen; pub mod components; pub mod document_query_screen; pub mod dpns_contested_names_screen; +pub mod dpns_vote_scheduling_screen; pub(crate) mod identities; pub mod network_chooser_screen; pub mod tool_screens; @@ -48,6 +51,7 @@ pub enum RootScreenType { RootScreenDPNSActiveContests, RootScreenDPNSPastContests, RootScreenDPNSOwnedNames, + RootScreenDPNSScheduledVotes, RootScreenDocumentQuery, RootScreenWalletsBalances, RootScreenToolsProofLogScreen, @@ -70,6 +74,7 @@ impl RootScreenType { RootScreenType::RootScreenNetworkChooser => 7, RootScreenType::RootScreenWithdrawsStatus => 8, RootScreenType::RootScreenToolsProofLogScreen => 9, + RootScreenType::RootScreenDPNSScheduledVotes => 10, } } @@ -86,6 +91,7 @@ impl RootScreenType { 7 => Some(RootScreenType::RootScreenNetworkChooser), 8 => Some(RootScreenType::RootScreenWithdrawsStatus), 9 => Some(RootScreenType::RootScreenToolsProofLogScreen), + 10 => Some(RootScreenType::RootScreenDPNSScheduledVotes), _ => None, } } @@ -106,6 +112,7 @@ impl From for ScreenType { RootScreenType::RootScreenNetworkChooser => ScreenType::NetworkChooser, RootScreenType::RootScreenWalletsBalances => ScreenType::WalletsBalances, RootScreenType::RootScreenToolsProofLogScreen => ScreenType::ProofLog, + RootScreenType::RootScreenDPNSScheduledVotes => ScreenType::ScheduledVotes, } } } @@ -138,6 +145,8 @@ pub enum ScreenType { RegisterDpnsName, ProofLog, TopUpIdentity(QualifiedIdentity), + ScheduleVoteScreen(String, u64, Vec, ResourceVoteChoice), + ScheduledVotes, } impl ScreenType { @@ -207,6 +216,21 @@ impl ScreenType { Screen::ImportWalletScreen(ImportWalletScreen::new(app_context)) } ScreenType::ProofLog => Screen::ProofLogScreen(ProofLogScreen::new(app_context)), + ScreenType::ScheduleVoteScreen( + contested_name, + ending_time, + identities, + vote_choice, + ) => Screen::ScheduleVoteScreen(ScheduleVoteScreen::new( + app_context, + contested_name.clone(), + ending_time.clone(), + identities.clone(), + vote_choice.clone(), + )), + ScreenType::ScheduledVotes => Screen::DPNSContestedNamesScreen( + DPNSContestedNamesScreen::new(app_context, DPNSSubscreen::ScheduledVotes), + ), } } } @@ -231,6 +255,7 @@ pub enum Screen { WithdrawsStatusScreen(WithdrawsStatusScreen), NetworkChooserScreen(NetworkChooserScreen), WalletsBalancesScreen(WalletsBalancesScreen), + ScheduleVoteScreen(ScheduleVoteScreen), } impl Screen { @@ -255,6 +280,7 @@ impl Screen { Screen::WithdrawsStatusScreen(screen) => screen.app_context = app_context, Screen::ImportWalletScreen(screen) => screen.app_context = app_context, Screen::ProofLogScreen(screen) => screen.app_context = app_context, + Screen::ScheduleVoteScreen(screen) => screen.app_context = app_context, } } } @@ -317,6 +343,10 @@ impl Screen { dpns_subscreen: DPNSSubscreen::Owned, .. }) => ScreenType::DPNSMyUsernames, + Screen::DPNSContestedNamesScreen(DPNSContestedNamesScreen { + dpns_subscreen: DPNSSubscreen::ScheduledVotes, + .. + }) => ScreenType::ScheduledVotes, Screen::TransitionVisualizerScreen(_) => ScreenType::TransitionVisualizer, Screen::WithdrawalScreen(screen) => { ScreenType::WithdrawalScreen(screen.identity.clone()) @@ -335,6 +365,12 @@ impl Screen { Screen::WithdrawsStatusScreen(_) => ScreenType::WithdrawsStatus, Screen::ImportWalletScreen(_) => ScreenType::ImportWallet, Screen::ProofLogScreen(_) => ScreenType::ProofLog, + Screen::ScheduleVoteScreen(screen) => ScreenType::ScheduleVoteScreen( + screen.contested_name.clone(), + screen.ending_time.clone(), + screen.identities.clone(), + screen.vote_choice.clone(), + ), } } } @@ -361,6 +397,7 @@ impl ScreenLike for Screen { Screen::NetworkChooserScreen(screen) => screen.refresh(), Screen::WalletsBalancesScreen(screen) => screen.refresh(), Screen::ProofLogScreen(screen) => screen.refresh(), + Screen::ScheduleVoteScreen(screen) => screen.refresh(), } } @@ -385,6 +422,7 @@ impl ScreenLike for Screen { Screen::NetworkChooserScreen(screen) => screen.refresh_on_arrival(), Screen::WalletsBalancesScreen(screen) => screen.refresh_on_arrival(), Screen::ProofLogScreen(screen) => screen.refresh_on_arrival(), + Screen::ScheduleVoteScreen(screen) => screen.refresh_on_arrival(), } } @@ -409,6 +447,7 @@ impl ScreenLike for Screen { Screen::NetworkChooserScreen(screen) => screen.ui(ctx), Screen::WalletsBalancesScreen(screen) => screen.ui(ctx), Screen::ProofLogScreen(screen) => screen.ui(ctx), + Screen::ScheduleVoteScreen(screen) => screen.ui(ctx), } } @@ -439,6 +478,7 @@ impl ScreenLike for Screen { Screen::NetworkChooserScreen(screen) => screen.display_message(message, message_type), Screen::WalletsBalancesScreen(screen) => screen.display_message(message, message_type), Screen::ProofLogScreen(screen) => screen.display_message(message, message_type), + Screen::ScheduleVoteScreen(screen) => screen.display_message(message, message_type), } } @@ -501,6 +541,9 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::ScheduleVoteScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } } } @@ -525,6 +568,7 @@ impl ScreenLike for Screen { Screen::NetworkChooserScreen(screen) => screen.pop_on_success(), Screen::WalletsBalancesScreen(screen) => screen.pop_on_success(), Screen::ProofLogScreen(screen) => screen.pop_on_success(), + Screen::ScheduleVoteScreen(screen) => screen.pop_on_success(), } } }