diff --git a/docs/user-stories.md b/docs/user-stories.md index f7bff300c..a4a9d98ee 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -657,6 +657,7 @@ As a user, I want to register a human-readable username on DPNS so that others c - Choose identity, enter desired name. - Cost estimate displayed before confirmation. - While registration runs, a full-window blocking overlay (UX-001) is shown so the same name cannot be submitted twice; it lowers automatically on success or error. +- Completion feedback distinguishes a username registered for immediate use from a request submitted for community voting. ### DPN-002: View owned usernames [Implemented] **Persona:** Alex, Priya @@ -721,6 +722,16 @@ As a masternode operator, I want my previously scheduled DPNS votes to survive a - A single unreadable vote row costs only itself: the readable votes in the same batch still import. - The report of unreadable votes returns on every launch until it is explicitly acknowledged, so a vote whose deadline is still open cannot lose its only notice to a missed or dismissed banner. +### DPN-010: See a pending username registration [Implemented] +**Persona:** Alex + +As a user who has requested a username that is not yet awarded, I want to see that the request is pending so that I am not told to "pick a username" for a name I have already chosen. + +- A requested-but-unawarded name shows a "Pending" pill next to the identity — on both the Identities list and the Identity Home hero card. +- The hero card shows the requested name with the pill instead of the "No username yet — Pick a username" prompt. +- The onboarding checklist counts the submitted request as completing "Pick a username" while clearly stating that Dash masternodes are voting. +- The pill's tooltip explains that Dash masternodes decide who receives the username and, when the decision time is known, gives an estimated decision time. + --- ## DashPay (DPY) diff --git a/src/app.rs b/src/app.rs index dcd2e7220..c79924cd6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -201,6 +201,24 @@ fn clear_scheduled_vote_sweep_guard_on_error( } } +fn clear_profile_saving_banner_after_error(ctx: &egui::Context, context: &BackendTaskContext) { + if let Some(identity_id) = context.dashpay_profile_update_identity() { + crate::ui::identity::settings::clear_profile_saving_banner(ctx, &identity_id); + } +} + +fn clear_profile_saving_banner_after_success( + ctx: &egui::Context, + context: &BackendTaskContext, + result: &BackendTaskSuccessResult, +) { + if let BackendTaskSuccessResult::DashPayProfileUpdated(saved_id) = result + && context.dashpay_profile_update_identity() == Some(*saved_id) + { + crate::ui::identity::settings::clear_profile_saving_banner(ctx, saved_id); + } +} + fn unix_time_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -278,6 +296,15 @@ mod backend_task_join_tests { use crate::backend_task::tokens::TokenTask; use crate::utils::egui_mpsc::SenderAsync; + fn profile_update_context(dispatch_id: u64, identity_byte: u8) -> BackendTaskContext { + BackendTaskContext::Dispatched { + dispatch_id, + operation: Box::new(BackendTaskContext::DashPayProfileUpdate(Identifier::from( + [identity_byte; 32], + ))), + } + } + #[test] fn backend_task_error_retains_originating_context() { let task = BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)); @@ -301,6 +328,125 @@ mod backend_task_join_tests { ); } + #[test] + fn profile_update_error_clears_only_its_saving_banner() { + let ctx = egui::Context::default(); + let identity_id = Identifier::from([1; 32]); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, identity_id); + let saving = MessageBanner::set_global( + &ctx, + crate::ui::identity::settings::PROFILE_SAVING, + MessageType::Info, + ); + + clear_profile_saving_banner_after_error(&ctx, &profile_update_context(1, 1)); + + assert!( + saving.elapsed().is_none(), + "a failed profile update must dismiss its persistent progress banner" + ); + } + + #[test] + fn profile_update_success_clears_its_saving_banner() { + let ctx = egui::Context::default(); + let identity_id = Identifier::from([1; 32]); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, identity_id); + let saving = MessageBanner::set_global( + &ctx, + crate::ui::identity::settings::PROFILE_SAVING, + MessageType::Info, + ); + + clear_profile_saving_banner_after_success( + &ctx, + &profile_update_context(1, 1), + &BackendTaskSuccessResult::DashPayProfileUpdated(identity_id), + ); + + assert!( + saving.elapsed().is_none(), + "a successful profile update must dismiss its persistent progress banner" + ); + } + + #[test] + fn unrelated_error_does_not_clear_profile_saving_banner() { + let ctx = egui::Context::default(); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([1; 32])); + let saving = MessageBanner::set_global( + &ctx, + crate::ui::identity::settings::PROFILE_SAVING, + MessageType::Info, + ); + + clear_profile_saving_banner_after_error(&ctx, &BackendTaskContext::Other); + + assert!(saving.elapsed().is_some()); + } + + #[test] + fn one_identity_error_does_not_clear_another_identity_saving_banner() { + let ctx = egui::Context::default(); + let identity_a = profile_update_context(1, 1); + let identity_b = profile_update_context(2, 2); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([1; 32])); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([2; 32])); + let saving = MessageBanner::set_global( + &ctx, + crate::ui::identity::settings::PROFILE_SAVING, + MessageType::Info, + ); + + clear_profile_saving_banner_after_error(&ctx, &identity_a); + + assert!( + saving.elapsed().is_some(), + "identity A's error must not dismiss identity B's progress banner" + ); + + clear_profile_saving_banner_after_error(&ctx, &identity_b); + assert!( + saving.elapsed().is_none(), + "identity B's error must dismiss identity B's progress banner" + ); + } + + #[test] + fn one_identity_success_does_not_clear_another_identity_saving_banner() { + let ctx = egui::Context::default(); + let identity_a = profile_update_context(1, 1); + let identity_b = profile_update_context(2, 2); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([1; 32])); + crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([2; 32])); + let saving = MessageBanner::set_global( + &ctx, + crate::ui::identity::settings::PROFILE_SAVING, + MessageType::Info, + ); + + clear_profile_saving_banner_after_success( + &ctx, + &identity_a, + &BackendTaskSuccessResult::DashPayProfileUpdated(Identifier::from([1; 32])), + ); + + assert!( + saving.elapsed().is_some(), + "identity A's success must not dismiss identity B's progress banner" + ); + + clear_profile_saving_banner_after_success( + &ctx, + &identity_b, + &BackendTaskSuccessResult::DashPayProfileUpdated(Identifier::from([2; 32])), + ); + assert!( + saving.elapsed().is_none(), + "identity B's success must dismiss identity B's progress banner" + ); + } + #[test] fn backend_task_success_retains_originating_context() { let task = BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)); @@ -2355,6 +2501,7 @@ impl App for AppState { result: message, } => { let unboxed_message = *message; + clear_profile_saving_banner_after_success(ctx, &context, &unboxed_message); self.route_contact_request_result_to_hidden_hub(&unboxed_message); match unboxed_message { BackendTaskSuccessResult::None => {} @@ -2602,6 +2749,7 @@ impl App for AppState { context, error: err, } => { + clear_profile_saving_banner_after_error(ctx, &context); clear_scheduled_vote_sweep_guard_on_error( &mut self.scheduled_vote_sweeps_in_progress, &context, diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index 75c29c909..bb1391769 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -221,6 +221,8 @@ impl AppContext { } } + self.refresh_pending_dpns_usernames()?; + sender .send(TaskResult::unattributed_success( BackendTaskSuccessResult::RefreshedDpnsContests, diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 55b7ede98..9678dd8bf 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -3104,7 +3104,6 @@ mod tests { InvalidTokenNameCharacterError, InvalidTokenNameLengthError, }; use dash_sdk::dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; - use dash_sdk::dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError; use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError; use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; use dash_sdk::dpp::consensus::state::identity::IdentityInsufficientBalanceError; @@ -3585,19 +3584,9 @@ mod tests { #[test] fn from_sdk_error_duplicate_unique_index_dpns_named_fields_is_generic() { - let consensus = ConsensusError::from(DuplicateUniqueIndexError::new( - Identifier::random(), - vec![ - "normalizedParentDomainName".to_string(), - "normalizedLabel".to_string(), - ], + let err = TaskError::from(crate::test_support::duplicate_unique_index_broadcast_error( + vec!["normalizedParentDomainName", "normalizedLabel"], )); - let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { - code: 40105, - message: "duplicate unique index".to_string(), - cause: Some(consensus), - }; - let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err)); assert_eq!( err.to_string(), @@ -3613,20 +3602,13 @@ mod tests { #[test] fn from_sdk_error_duplicate_unique_index_other_document_is_actionable() { - let consensus = ConsensusError::from(DuplicateUniqueIndexError::new( - Identifier::random(), + let err = TaskError::from(crate::test_support::duplicate_unique_index_broadcast_error( vec![ - "normalizedParentDomainName".to_string(), - "normalizedLabel".to_string(), - "serialNumber".to_string(), + "normalizedParentDomainName", + "normalizedLabel", + "serialNumber", ], )); - let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { - code: 40105, - message: "duplicate unique index".to_string(), - cause: Some(consensus), - }; - let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err)); assert_eq!( err.to_string(), @@ -3642,17 +3624,10 @@ mod tests { #[test] fn from_sdk_error_duplicate_unique_index_boundary_property_counts_are_generic() { - for properties in [vec![], vec!["normalizedLabel".to_string()]] { - let consensus = ConsensusError::from(DuplicateUniqueIndexError::new( - Identifier::random(), + for properties in [vec![], vec!["normalizedLabel"]] { + let err = TaskError::from(crate::test_support::duplicate_unique_index_broadcast_error( properties, )); - let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { - code: 40105, - message: "duplicate unique index".to_string(), - cause: Some(consensus), - }; - let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err)); match &err { TaskError::PlatformEntryConflict { source_error } => { diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index d313af6a1..cc54fba39 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -2,10 +2,13 @@ use std::collections::BTreeMap; use crate::backend_task::FeeResult; use crate::backend_task::error::TaskError; -use crate::{context::AppContext, model::qualified_identity::DPNSNameInfo}; +use crate::{ + context::AppContext, + model::{dpns::classify_dpns_registration_outcome, qualified_identity::DPNSNameInfo}, +}; use bip39::rand::{Rng, SeedableRng, rngs::StdRng}; use dash_sdk::{ - Sdk, + Error as SdkError, Sdk, dpp::{ data_contract::{ accessors::v0::DataContractV0Getters, document_type::accessors::DocumentTypeV0Getters, @@ -132,6 +135,12 @@ impl AppContext { updated_at_core_block_height: None, transferred_at_core_block_height: None, }); + let outcome = classify_dpns_registration_outcome( + &domain_document_type, + &domain_document, + sdk.version(), + ) + .map_err(|error| SdkError::Protocol(*error))?; let public_key = qualified_identity .document_signing_key(&preorder_document_type) @@ -152,6 +161,8 @@ impl AppContext { &qualified_identity, None, ) + // Not rebranded: preorder's only unique index, `saltedDomainHash`, is unrelated to + // usernames, so conflicts keep the generic `PlatformEntryConflict` message. .await?; let _ = domain_document @@ -249,31 +260,22 @@ impl AppContext { self.update_local_qualified_identity(&qualified_identity)?; let fee_result = FeeResult::new(estimated_fee, actual_fee); - Ok(BackendTaskSuccessResult::RegisteredDpnsName(fee_result)) + Ok(BackendTaskSuccessResult::RegisteredDpnsName { + outcome, + fee_result, + }) } } #[cfg(test)] mod tests { use super::*; - use dash_sdk::dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError; + use dash_sdk::dpp::consensus::ConsensusError::StateError as ConsensusStateError; use dash_sdk::dpp::consensus::state::state_error::StateError; - use dash_sdk::dpp::consensus::{ - ConsensusError, ConsensusError::StateError as ConsensusStateError, - }; - use dash_sdk::platform::Identifier; fn duplicate_unique_index_conflict(properties: Vec<&str>) -> TaskError { - let consensus = ConsensusError::from(DuplicateUniqueIndexError::new( - Identifier::random(), - properties.into_iter().map(str::to_string).collect(), - )); - let source_error = Box::new(dash_sdk::Error::StateTransitionBroadcastError( - dash_sdk::error::StateTransitionBroadcastError { - code: 40105, - message: "duplicate unique index".to_string(), - cause: Some(consensus), - }, + let source_error = Box::new(crate::test_support::duplicate_unique_index_broadcast_error( + properties, )); TaskError::PlatformEntryConflict { source_error } diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 1a9ef72a9..962b984ee 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -2609,6 +2609,23 @@ impl From for TaskError { } } +/// Test-only synchronization helper: `run` deliberately detaches its DAPI +/// refresh onto a spawned task that queues for `migration_run` behind the +/// caller's own guard (see [`spawn_dapi_refresh`]). A test that calls another +/// `migration_run`-guarded operation (e.g. `delete_local_qualified_identity`) +/// right after `run` returns races that detached task — yield so it queues +/// for the guard, then acquire the same guard after the refresh releases it. +/// +/// `pub(crate)` (not private to this module's `tests`) so sibling test +/// modules — e.g. `v093_upgrade`'s cross-subsystem regression — can reuse it +/// instead of re-deriving the same race-avoidance dance. +#[cfg(test)] +pub(crate) async fn wait_for_dapi_refresh(app_context: &Arc) { + tokio::task::yield_now().await; + let guard = app_context.migration_run.lock().await; + drop(guard); +} + #[cfg(test)] mod tests { use super::*; @@ -2633,14 +2650,6 @@ mod tests { .is_some() } - async fn wait_for_dapi_refresh(app_context: &Arc) { - // `run` deliberately detaches this work; yield so it queues for the guard, - // then acquire the same guard after the refresh releases it. - tokio::task::yield_now().await; - let guard = app_context.migration_run.lock().await; - drop(guard); - } - fn seed_legacy_single_key(app_context: &AppContext) { let path = app_context.db.db_file_path().expect("file-backed database"); let conn = Connection::open(path).expect("open legacy database"); diff --git a/src/backend_task/migration/v093_upgrade.rs b/src/backend_task/migration/v093_upgrade.rs index af9187cd6..9905c5896 100644 --- a/src/backend_task/migration/v093_upgrade.rs +++ b/src/backend_task/migration/v093_upgrade.rs @@ -1485,6 +1485,13 @@ async fn a_second_launch_after_an_unreadable_identity_preserves_user_edits_and_d // keys stop living in this install. ctx.set_identity_alias(&Identifier::from(IDENTITY_ID), Some("my-renamed-node")) .expect("rename identity"); + // The first migration's best-effort DAPI refresh is detached onto a spawned + // task that queues for `migration_run` right behind it (see + // `finish_unwire::spawn_dapi_refresh`). `delete_local_qualified_identity` + // claims that same guard via `try_lock`, so calling it immediately here + // races the detached refresh — flaky under load, not a real contention + // failure. Wait for the refresh to release the guard first. + finish_unwire::wait_for_dapi_refresh(&ctx).await; let deleted = Identifier::from(USER_IDENTITY_ID); ctx.delete_local_qualified_identity(&deleted) .expect("delete identity"); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 6c5b99592..96c284915 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -14,6 +14,7 @@ use crate::context::identity_load_registry::IdentityLoadToken; use crate::model::masternode_input::decode_identity_id; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{PlatformAddressUpdates, WalletSeedHash}; @@ -320,6 +321,8 @@ pub enum BackendTaskContext { FetchDocumentsPage(Box), /// A refresh of all tracked token balances. TokenBalanceRefresh, + /// A DashPay social-profile update for one identity. + DashPayProfileUpdate(Identifier), /// A perpetual-reward estimate for one identity-token pair. TokenRewardEstimate(IdentityTokenIdentifier), /// The destructive per-network database clear. @@ -357,6 +360,13 @@ impl BackendTaskContext { matches!(self.operation(), Self::FetchDocumentsPage(_)) } + pub(crate) fn dashpay_profile_update_identity(&self) -> Option { + match self.operation() { + Self::DashPayProfileUpdate(identity_id) => Some(*identity_id), + _ => None, + } + } + pub(crate) fn dispatched_document_fetch(&self) -> bool { matches!( self, @@ -403,6 +413,12 @@ impl From<&BackendTask> for BackendTaskContext { }), _ => Self::Other, }, + BackendTask::DashPayTask(task) => match task.as_ref() { + DashPayTask::UpdateProfile { identity, .. } => { + Self::DashPayProfileUpdate(identity.identity.id()) + } + _ => Self::Other, + }, BackendTask::SystemTask(SystemTask::ClearNetworkDatabase) => Self::ClearNetworkDatabase, BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }) => { Self::GenerateReceiveAddress { @@ -668,7 +684,10 @@ pub enum BackendTaskSuccessResult { AddedKeyToIdentity(FeeResult), TransferredCredits(FeeResult), WithdrewFromIdentity(FeeResult), - RegisteredDpnsName(FeeResult), + RegisteredDpnsName { + outcome: crate::model::dpns::DpnsRegistrationOutcome, + fee_result: FeeResult, + }, RefreshedIdentity(QualifiedIdentity), LoadedIdentity(QualifiedIdentity), /// This identity's keys were sealed under a password (opt-in). diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index 44630a9d9..03caf537b 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -8,12 +8,16 @@ use super::AppContext; use crate::backend_task::error::TaskError; -use crate::model::contested_name::{ContestState, Contestant, ContestedName}; +use crate::model::contested_name::{ + ContestState, Contestant, ContestedName, PendingUsername, pending_usernames_in, +}; +use crate::model::qualified_identity::QualifiedIdentity; use crate::wallet_backend::{DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::TimestampMillis; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::prelude::{BlockHeight, CoreBlockHeight}; use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; use dash_sdk::platform::Identifier; @@ -192,6 +196,95 @@ impl AppContext { Ok(out) } + /// Rebuild the frame-safe pending-name snapshot from the contest store. + pub(crate) fn refresh_pending_dpns_usernames(&self) -> Result<(), TaskError> { + let contests = self.ongoing_contested_names()?; + let pending = pending_usernames_in(&contests); + *self + .pending_dpns_usernames + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = pending; + Ok(()) + } + + /// The DPNS username `identity_id` has requested but not yet been awarded, + /// if any — read from the frame-safe snapshot. + /// + /// Read-only; returns `Ok(None)` when nothing is pending. Lets the UI tell + /// "requested but still being decided" apart from "no username requested". + pub fn pending_dpns_username_for( + &self, + identity_id: &Identifier, + ) -> std::result::Result, TaskError> { + let now_ms = std::time::UNIX_EPOCH + .elapsed() + .unwrap_or_default() + .as_millis() as u64; + Ok(self + .pending_dpns_usernames + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(identity_id) + .filter(|pending| pending.decided_at.is_none_or(|end| end > now_ms)) + .cloned()) + } + + /// Map each of `identity_ids` to its pending DPNS username request, if any. + /// + /// Reads only the frame-safe snapshot. Identities with nothing pending are + /// omitted. + pub fn pending_dpns_usernames( + &self, + identity_ids: &[Identifier], + ) -> std::result::Result, TaskError> { + let now_ms = std::time::UNIX_EPOCH + .elapsed() + .unwrap_or_default() + .as_millis() as u64; + let pending = self + .pending_dpns_usernames + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(identity_ids + .iter() + .filter_map(|id| { + pending + .get(id) + .filter(|name| name.decided_at.is_none_or(|end| end > now_ms)) + .cloned() + .map(|name| (*id, name)) + }) + .collect()) + } + + /// Return a pending DPNS username only while `identity` owns no awarded name. + pub fn pending_dpns_username_for_identity( + &self, + identity: &QualifiedIdentity, + ) -> Option { + if identity_owns_dpns_name(identity) { + None + } else { + self.pending_dpns_username_for(&identity.identity.id()) + .ok() + .flatten() + } + } + + /// Map identities without an awarded name to their pending DPNS usernames. + pub fn pending_dpns_usernames_for_identities( + &self, + identities: &[QualifiedIdentity], + ) -> HashMap { + let identity_ids = identities + .iter() + .filter(|identity| !identity_owns_dpns_name(identity)) + .map(|identity| identity.identity.id()) + .collect::>(); + self.pending_dpns_usernames(&identity_ids) + .unwrap_or_default() + } + /// Summarise a masternode/evonode node's DPNS voting position for its card. /// /// `voter_id` is the node's voter-identity id (`associated_voter_identity`); @@ -401,11 +494,26 @@ impl AppContext { } } +fn identity_owns_dpns_name(identity: &QualifiedIdentity) -> bool { + identity + .dpns_names + .iter() + .any(|name| !name.name.trim().is_empty()) +} + #[cfg(test)] mod tests { use super::*; + use crate::context::test_support::test_app_context; + use crate::model::contested_name::pending_username_in; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{ + DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, + }; use crate::wallet_backend::DetKv; use crate::wallet_backend::kv_test_support::InMemoryKv; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; use std::sync::Arc; fn empty_kv() -> DetKv { @@ -425,6 +533,73 @@ mod tests { } } + fn qualified_identity(id: u8, dpns_name: &str) -> QualifiedIdentity { + let identity = + Identity::create_basic_identity(Identifier::from([id; 32]), PlatformVersion::latest()) + .expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![DPNSNameInfo { + name: dpns_name.to_string(), + acquired_at: 0, + }], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + #[test] + fn pending_dpns_usernames_for_identities_omits_owned_identity() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = test_app_context(temp_dir.path()); + let owned = qualified_identity(1, "alice"); + let unowned = qualified_identity(2, " "); + { + let mut pending = context + .pending_dpns_usernames + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.insert( + owned.identity.id(), + PendingUsername { + name: "alice".to_string(), + decided_at: None, + }, + ); + pending.insert( + unowned.identity.id(), + PendingUsername { + name: "bob".to_string(), + decided_at: None, + }, + ); + } + + let pending = context.pending_dpns_usernames_for_identities(&[owned, unowned]); + + assert!( + !pending.contains_key(&Identifier::from([1; 32])), + "an awarded DPNS name must suppress the stale pending indicator" + ); + assert_eq!( + pending + .get(&Identifier::from([2; 32])) + .map(|name| name.name.as_str()), + Some("bob"), + "a blank DPNS entry is not ownership and must keep the pending indicator" + ); + } + // ---------------------------------------------------------------- // Decode: contest state branches the cache resolves at read time. // ---------------------------------------------------------------- @@ -540,4 +715,39 @@ mod tests { fn contest_key_is_prefixed_with_normalized_name() { assert_eq!(contested_name_key("dash"), "det:contested_name:dash"); } + + // ---------------------------------------------------------------- + // Bridge: a stored contest decodes into a detectable pending username. + // ---------------------------------------------------------------- + + #[test] + fn stored_undecided_contest_yields_pending_username() { + // A contestant with a timestamp and no winner resolves to an active + // (Ongoing) state, so the identity has a pending username request. + let stored = StoredContestedName { + normalized_contested_name: "det1".to_string(), + end_time: Some(9_999), + contestants: vec![contestant(5, Some(100))], + ..Default::default() + }; + let cn = stored.to_contested_name(Network::Testnet); + let me = Identifier::from([5u8; 32]); + let pending = pending_username_in(std::slice::from_ref(&cn), &me) + .expect("undecided contender must surface a pending username"); + assert_eq!(pending.name, "name-5"); + assert_eq!(pending.decided_at, Some(9_999)); + } + + #[test] + fn stored_awarded_contest_yields_no_pending_username() { + let stored = StoredContestedName { + normalized_contested_name: "det1".to_string(), + awarded_to: Some([5u8; 32]), + contestants: vec![contestant(5, Some(100))], + ..Default::default() + }; + let cn = stored.to_contested_name(Network::Testnet); + let me = Identifier::from([5u8; 32]); + assert!(pending_username_in(std::slice::from_ref(&cn), &me).is_none()); + } } diff --git a/src/context/mod.rs b/src/context/mod.rs index 31703d8c5..5dce7ef60 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -47,7 +47,7 @@ use dash_sdk::platform::Identifier; use egui::Context; use migration_status::MigrationStatus; use platform_wallet_storage::secrets::SecretStore; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr as _; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; @@ -127,6 +127,9 @@ pub struct AppContext { /// Cached settings to avoid repeated k/v reads + bincode decoding. /// Use RwLock to allow multiple readers but exclusive writers for cache invalidation. cached_settings: RwLock>, + /// Frame-safe pending DPNS names rebuilt after the contest cache changes. + pending_dpns_usernames: + RwLock>, /// Shared app-level k/v store at `/det-app.sqlite`. /// Cross-network, global-scoped slot used for `AppSettings` and other /// DET-owned application data that must outlive a single network's @@ -425,6 +428,7 @@ impl AppContext { single_key_wallets: RwLock::new(single_key_wallets), animations_disabled: AtomicBool::new(false), cached_settings: RwLock::new(None), + pending_dpns_usernames: RwLock::new(HashMap::new()), app_kv, secret_store, subtasks, @@ -1083,6 +1087,12 @@ impl AppContext { .await?; self.wallet_backend.store(Some(Arc::new(backend))); drop(_build_guard); + if let Err(error) = self.refresh_pending_dpns_usernames() { + tracing::warn!( + ?error, + "Pending DPNS username cache could not be warmed from stored contests" + ); + } self.restore_selected_wallet_from_kv(); self.restore_selected_identity_from_kv(); // Render the platform section (per-address tab, total, "Addresses synced" diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index f30666b7b..893dcde65 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -3,7 +3,11 @@ use bincode::{Decode, Encode}; use dash_sdk::dpp::identity::{KeyID, TimestampMillis}; use dash_sdk::dpp::prelude::{BlockHeight, CoreBlockHeight, Identifier}; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use std::collections::BTreeMap; +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap}; + +/// Maximum number of Unicode scalar values rendered for a pending DPNS label. +pub const MAX_PENDING_USERNAME_DISPLAY_CHARS: usize = 63; #[derive(Debug, Encode, Decode, Clone, PartialEq)] pub enum ContestState { @@ -41,6 +45,154 @@ impl ContestedName { pub fn is_open_for_voter(&self, voter_id: &Identifier) -> bool { self.state.state_is_votable() && !self.my_votes.keys().any(|(id, _, _)| id == voter_id) } + + /// The pending DPNS username this contest represents for `identity_id`, if + /// the identity is a still-undecided contender in it. + /// + /// Returns `None` when the contest is already decided (`WonBy` or `Locked`) + /// or the identity is not among its contenders — an awarded or lost name is + /// no longer "pending". Used to tell "requested but not yet awarded" apart + /// from "no username requested". + pub fn pending_username_for(&self, identity_id: &Identifier) -> Option { + if matches!(self.state, ContestState::WonBy(_) | ContestState::Locked) { + return None; + } + let mine = self + .contestants + .as_ref()? + .iter() + .find(|c| c.id == *identity_id)?; + Some(PendingUsername { + name: mine.name.clone(), + decided_at: self.end_time, + }) + } +} + +/// A DPNS username an identity has requested but has not yet been awarded — the +/// name contest is still open. Surfaced in the UI as a "Pending" indicator so a +/// requested-but-unawarded name is not mistaken for "no username requested". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingUsername { + /// The requested name label (without the `.dash` suffix), as submitted. + pub name: String, + /// When the request is expected to be decided, in Unix milliseconds. + /// `None` when the timing is not yet known. + pub decided_at: Option, +} + +/// The highest-priority pending DPNS username `identity_id` has across `contests`. +/// +/// Pure and side-effect-free: the caller supplies the contest set (typically the +/// ongoing-contest cache). The earliest known decision time wins; unknown times +/// follow known times, and the name provides a stable tie-breaker. +pub fn pending_username_in<'a, I>(contests: I, identity_id: &Identifier) -> Option +where + I: IntoIterator, +{ + contests + .into_iter() + .filter_map(|contest| contest.pending_username_for(identity_id)) + .min_by(pending_username_priority) +} + +/// Build the deterministic pending-username snapshot for every contender. +pub fn pending_usernames_in<'a, I>(contests: I) -> HashMap +where + I: IntoIterator, +{ + let mut pending_by_identity = HashMap::::new(); + for contest in contests { + if matches!(contest.state, ContestState::WonBy(_) | ContestState::Locked) { + continue; + } + let Some(contestants) = contest.contestants.as_ref() else { + continue; + }; + for contestant in contestants { + let candidate = PendingUsername { + name: contestant.name.clone(), + decided_at: contest.end_time, + }; + pending_by_identity + .entry(contestant.id) + .and_modify(|current| { + if pending_username_priority(&candidate, current).is_lt() { + current.clone_from(&candidate); + } + }) + .or_insert(candidate); + } + } + pending_by_identity +} + +fn pending_username_priority(left: &PendingUsername, right: &PendingUsername) -> Ordering { + ( + left.decided_at.is_none(), + left.decided_at.unwrap_or(u64::MAX), + &left.name, + ) + .cmp(&( + right.decided_at.is_none(), + right.decided_at.unwrap_or(u64::MAX), + &right.name, + )) +} + +/// Return a bounded pending-name label safe to interpolate into UI text. +pub fn sanitize_pending_username_for_display(name: &str) -> String { + name.chars() + .filter(|character| !character.is_control() && !is_bidi_control(*character)) + .take(MAX_PENDING_USERNAME_DISPLAY_CHARS) + .collect() +} + +fn is_bidi_control(character: char) -> bool { + matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) +} + +/// Build a complete pending-name tooltip for `decided_at_ms`, measured from +/// `now_ms` (both Unix milliseconds). +/// +/// Returns `None` when the deadline is already reached or past — the caller +/// should then use its no-ETA fallback. The estimate is intentionally coarse +/// because the decision time is itself an estimate. +pub fn approximate_time_until(decided_at_ms: TimestampMillis, now_ms: u64) -> Option { + let remaining_ms = decided_at_ms.checked_sub(now_ms)?; + if remaining_ms == 0 { + return None; + } + const HOUR: u64 = 3_600; + const DAY: u64 = 86_400; + let secs = remaining_ms / 1_000; + Some(if secs < HOUR { + "Dash masternodes vote on who receives this username. A decision is expected in less than an hour." + .to_string() + } else if secs < 2 * HOUR { + "Dash masternodes vote on who receives this username. A decision is expected in about 1 hour." + .to_string() + } else if secs < DAY { + let hours = secs / HOUR; + format!( + "Dash masternodes vote on who receives this username. A decision is expected in about {hours} hours." + ) + } else if secs < 2 * DAY { + "Dash masternodes vote on who receives this username. A decision is expected in about 1 day." + .to_string() + } else { + let days = secs / DAY; + format!( + "Dash masternodes vote on who receives this username. A decision is expected in about {days} days." + ) + }) } /// Per-node DPNS voting summary shown on the Masternodes card grid. @@ -89,6 +241,27 @@ mod tests { } } + fn contestant(id: [u8; 32], name: &str) -> Contestant { + Contestant { + id: Identifier::from(id), + name: name.to_string(), + info: String::new(), + votes: 0, + created_at: None, + created_at_block_height: None, + created_at_core_block_height: None, + document_id: Identifier::from([0u8; 32]), + } + } + + fn contest_with(state: ContestState, contestants: Vec) -> ContestedName { + ContestedName { + contestants: Some(contestants), + end_time: Some(9_999), + ..contest(state) + } + } + #[test] fn open_for_voter_when_votable_and_not_yet_voted() { let voter = Identifier::from([7u8; 32]); @@ -128,4 +301,186 @@ mod tests { ); assert!(c.is_open_for_voter(&voter)); } + + // ---------------------------------------------------------------- + // Pending username detection: requested-but-unawarded vs owned/none. + // ---------------------------------------------------------------- + + #[test] + fn pending_username_reported_when_contender_and_undecided() { + let me = [5u8; 32]; + for state in [ + ContestState::Ongoing, + ContestState::Joinable, + ContestState::Unknown, + ] { + let c = contest_with(state.clone(), vec![contestant(me, "det1")]); + let pending = c + .pending_username_for(&Identifier::from(me)) + .expect("an undecided contender has a pending username"); + assert_eq!(pending.name, "det1"); + assert_eq!(pending.decided_at, Some(9_999)); + } + } + + #[test] + fn no_pending_username_when_contest_is_decided() { + let me = [5u8; 32]; + // Won by me, won by another, and locked are all "decided" — the name is + // no longer pending regardless of who ends up owning it. + for state in [ + ContestState::WonBy(Identifier::from(me)), + ContestState::WonBy(Identifier::from([9u8; 32])), + ContestState::Locked, + ] { + let c = contest_with(state, vec![contestant(me, "det1")]); + assert!(c.pending_username_for(&Identifier::from(me)).is_none()); + } + } + + #[test] + fn no_pending_username_when_identity_is_not_a_contender() { + let c = contest_with(ContestState::Ongoing, vec![contestant([5u8; 32], "det1")]); + assert!( + c.pending_username_for(&Identifier::from([6u8; 32])) + .is_none() + ); + } + + #[test] + fn no_pending_username_when_contest_has_no_contenders() { + let c = contest(ContestState::Ongoing); // contestants: None + assert!( + c.pending_username_for(&Identifier::from([5u8; 32])) + .is_none() + ); + } + + #[test] + fn pending_username_in_scans_multiple_contests() { + let me = Identifier::from([5u8; 32]); + let contests = vec![ + contest_with(ContestState::Locked, vec![contestant([5u8; 32], "taken")]), + contest_with(ContestState::Ongoing, vec![contestant([5u8; 32], "det1")]), + ]; + let pending = pending_username_in(&contests, &me).expect("second contest is pending"); + assert_eq!(pending.name, "det1"); + } + + #[test] + fn pending_username_in_prioritizes_earliest_known_decision_deterministically() { + let me = Identifier::from([5u8; 32]); + let mut later = contest_with(ContestState::Ongoing, vec![contestant([5u8; 32], "later")]); + later.end_time = Some(20_000); + let mut earlier = contest_with( + ContestState::Ongoing, + vec![contestant([5u8; 32], "earlier")], + ); + earlier.end_time = Some(10_000); + let mut unknown = contest_with( + ContestState::Ongoing, + vec![contestant([5u8; 32], "unknown")], + ); + unknown.end_time = None; + + for contests in [ + vec![later.clone(), unknown.clone(), earlier.clone()], + vec![unknown.clone(), earlier.clone(), later.clone()], + vec![earlier.clone(), later.clone(), unknown.clone()], + ] { + assert_eq!( + pending_username_in(&contests, &me).map(|pending| pending.name), + Some("earlier".to_string()) + ); + } + } + + #[test] + fn pending_username_in_uses_name_as_a_stable_deadline_tiebreaker() { + let me = Identifier::from([5u8; 32]); + let alpha = contest_with(ContestState::Ongoing, vec![contestant([5u8; 32], "alpha")]); + let zulu = contest_with(ContestState::Ongoing, vec![contestant([5u8; 32], "zulu")]); + + for contests in [vec![zulu.clone(), alpha.clone()], vec![alpha, zulu]] { + assert_eq!( + pending_username_in(&contests, &me).map(|pending| pending.name), + Some("alpha".to_string()) + ); + } + } + + #[test] + fn pending_username_in_returns_none_without_matches() { + let contests = vec![contest_with( + ContestState::Ongoing, + vec![contestant([1u8; 32], "other")], + )]; + assert!(pending_username_in(&contests, &Identifier::from([5u8; 32])).is_none()); + } + + // ---------------------------------------------------------------- + // ETA humanization. + // ---------------------------------------------------------------- + + #[test] + fn approximate_time_until_buckets_durations() { + let now = 1_000_000_000_000u64; + let ms = |secs: u64| now + secs * 1_000; + assert_eq!( + approximate_time_until(ms(30 * 60), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in less than an hour." + ) + ); + assert_eq!( + approximate_time_until(ms(90 * 60), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in about 1 hour." + ) + ); + assert_eq!( + approximate_time_until(ms(3 * 3_600), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in about 3 hours." + ) + ); + assert_eq!( + approximate_time_until(ms(36 * 3_600), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in about 1 day." + ) + ); + assert_eq!( + approximate_time_until(ms(3 * 86_400), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in about 3 days." + ) + ); + } + + #[test] + fn approximate_time_until_is_none_when_deadline_passed_or_now() { + let now = 1_000_000_000_000u64; + assert!(approximate_time_until(now, now).is_none()); + assert!(approximate_time_until(now - 1, now).is_none()); + } + + #[test] + fn pending_username_display_sanitizer_strips_controls_and_bidi_markers() { + assert_eq!( + sanitize_pending_username_for_display("al\u{0000}i\u{061c}c\u{200f}e\u{202e}\u{2066}"), + "alice" + ); + } + + #[test] + fn pending_username_display_sanitizer_clamps_unicode_scalar_count() { + let unsafe_name = "é".repeat(MAX_PENDING_USERNAME_DISPLAY_CHARS + 10); + let sanitized = sanitize_pending_username_for_display(&unsafe_name); + assert_eq!( + sanitized.chars().count(), + MAX_PENDING_USERNAME_DISPLAY_CHARS + ); + assert!(sanitized.chars().all(|character| character == 'é')); + } } diff --git a/src/model/dpns.rs b/src/model/dpns.rs index 06f1cd9b5..a74b91316 100644 --- a/src/model/dpns.rs +++ b/src/model/dpns.rs @@ -4,11 +4,41 @@ //! pipeline so every DPNS lookup uses the same logic, and provides shared //! extraction utilities for DPNS domain documents. +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::voting::vote_polls::VotePoll; use dash_sdk::platform::{Document, Identifier}; +/// The immediate result of submitting a DPNS registration document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsRegistrationOutcome { + /// The submitted username was registered without a community vote. + Registered, + /// The submitted username entered a community vote before it can be awarded. + PendingCommunityVote, +} + +/// Classify a submitted DPNS document using its document type's contest rules. +pub fn classify_dpns_registration_outcome( + document_type: &impl DocumentTypeV0Methods, + document: &Document, + platform_version: &PlatformVersion, +) -> Result> { + match document_type + .contested_vote_poll_for_document(document, platform_version) + .map_err(Box::new)? + { + Some(VotePoll::ContestedDocumentResourceVotePoll(_)) => { + Ok(DpnsRegistrationOutcome::PendingCommunityVote) + } + None => Ok(DpnsRegistrationOutcome::Registered), + } +} + /// The `.dash` parent domain suffix (case-insensitive match target). const DASH_SUFFIX: &str = ".dash"; @@ -104,6 +134,90 @@ pub fn extract_identity_id_from_dpns_document(document: &Document) -> Option Document { + let owner_id = Identifier::from([1; 32]); + Document::V0(DocumentV0 { + id: Identifier::from([2; 32]), + owner_id, + creator_id: None, + properties: BTreeMap::from([ + ("parentDomainName".to_string(), "dash".into()), + ("normalizedParentDomainName".to_string(), "dash".into()), + ("label".to_string(), label.into()), + ( + "normalizedLabel".to_string(), + convert_to_homograph_safe_chars(label).into(), + ), + ("preorderSalt".to_string(), [0_u8; 32].into()), + ( + "records".to_string(), + BTreeMap::from([("identity".to_string(), Value::from(owner_id))]).into(), + ), + ( + "subdomainRules".to_string(), + BTreeMap::from([("allowSubdomains".to_string(), Value::Bool(false))]).into(), + ), + ]), + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }) + } + + #[test] + fn registration_outcome_is_pending_for_contested_dpns_document() { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("bundled DPNS contract"); + let document_type = contract + .document_type_for_name("domain") + .expect("domain document type"); + + assert_eq!( + classify_dpns_registration_outcome( + &document_type, + &domain_document("alice"), + platform_version, + ) + .expect("classification succeeds"), + DpnsRegistrationOutcome::PendingCommunityVote + ); + } + + #[test] + fn registration_outcome_is_registered_for_non_contested_dpns_document() { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("bundled DPNS contract"); + let document_type = contract + .document_type_for_name("domain") + .expect("domain document type"); + + assert_eq!( + classify_dpns_registration_outcome( + &document_type, + &domain_document("alice2"), + platform_version, + ) + .expect("classification succeeds"), + DpnsRegistrationOutcome::Registered + ); + } #[test] fn normalize_bare_label() { diff --git a/src/test_support.rs b/src/test_support.rs index 16a330fb2..2e9c41793 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -2,5 +2,22 @@ use std::sync::Mutex; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError; +use dash_sdk::platform::Identifier; + /// Serializes every unit test that mutates the process-global data directory. pub(crate) static DASH_EVO_DATA_DIR_LOCK: Mutex<()> = Mutex::new(()); + +pub(crate) fn duplicate_unique_index_broadcast_error(properties: Vec<&str>) -> dash_sdk::Error { + let consensus = ConsensusError::from(DuplicateUniqueIndexError::new( + Identifier::random(), + properties.into_iter().map(str::to_string).collect(), + )); + + dash_sdk::Error::StateTransitionBroadcastError(dash_sdk::error::StateTransitionBroadcastError { + code: 40105, + message: "duplicate unique index".to_string(), + cause: Some(consensus), + }) +} diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 2a81a03b5..6f7b46b02 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -30,6 +30,7 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | DomainType | Description | |-----------|------|------------|-------------| | `Avatar` | `avatar.rs` | N/A (display) | DashPay contact/profile avatar from a URL. Renders image / spinner / `👤` fallback, decoding + uploading the texture on the UI thread. Backed by `ui/state/avatar_cache.rs` (`AvatarCache`), which fetches off-frame via `DashPayTask::FetchAvatar`. `show(ui, &mut AvatarCache)` returns `AvatarResponse { fetch, clicked }`; the caller dispatches `fetch`. Builders: `corner_radius`, `clickable(tooltip)`. | +| `accent_pill()` / `pending_username_pill()` | `pill.rs` | N/A (display) | Shared accent-badge renderer plus the DPNS-specific pending-name pill. `IdentityHeroCard`, Settings, and the Identities list delegate their badges to these renderers. | ## Placement Rule diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index fcadc55bf..6534cbbba 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -17,6 +17,7 @@ pub mod message_banner; pub mod modal_chrome; pub mod passphrase_modal; pub mod password_input; +pub mod pill; pub mod progress_overlay; pub mod secret_prompt_host; pub mod selection_dialog; diff --git a/src/ui/components/pill.rs b/src/ui/components/pill.rs new file mode 100644 index 000000000..a64312a3f --- /dev/null +++ b/src/ui/components/pill.rs @@ -0,0 +1,140 @@ +//! Reusable inline "pill" badge — a small rounded label used for identity +//! type, network, and status indicators. +//! +//! The [`accent_pill`] renderer is the shared style: a label tinted in an +//! accent color on a 12%-accent fill with a 1px accent ring. [`pending_username_pill`] +//! builds the DPNS "still being decided" indicator on top of it so the Identity +//! Home hero card and the Identities list render an identical badge. + +use crate::model::contested_name::{PendingUsername, approximate_time_until}; +use crate::ui::theme::{DashColors, ResponseExt, Shape}; +use eframe::egui::{ + Color32, CornerRadius, Frame, Margin, Response, RichText, Sense, Stroke, StrokeKind, Ui, +}; + +/// Label shown on the DPNS pending-registration pill. +pub const PENDING_USERNAME_PILL_LABEL: &str = "Pending"; + +/// Paint an inline pill: `label` in `accent`, on a 12%-accent fill with a 1px +/// accent ring. `tooltip`, when present, is attached on hover. Returns the +/// pill's [`Response`]. +pub fn accent_pill(ui: &mut Ui, label: &str, accent: Color32, tooltip: Option<&str>) -> Response { + let fill = + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (0.12 * 255.0) as u8); + let stroke_color = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180); + + let text = RichText::new(label).color(accent).size(12.0).strong(); + let inner = Frame::new() + .fill(fill) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(Shape::RADIUS_FULL)) + .inner_margin(Margin::symmetric(10, 3)) + .show(ui, |ui| { + ui.add(eframe::egui::Label::new(text).sense(Sense::hover())) + }); + + // Paint the ring manually so its color matches the accent exactly. + ui.painter().rect_stroke( + inner.response.rect, + CornerRadius::same(Shape::RADIUS_FULL), + Stroke::new(1.0, stroke_color), + StrokeKind::Outside, + ); + + match tooltip { + Some(text) => inner.response.info_tooltip(text), + None => inner.response, + } +} + +/// Paint the DPNS "Pending" pill for a username the identity has requested but +/// not yet been awarded. The hover tooltip carries the estimated ready time +/// when known (see [`pending_username_tooltip`]). +pub fn pending_username_pill(ui: &mut Ui, pending: &PendingUsername) -> Response { + let tooltip = pending_username_tooltip(pending); + accent_pill( + ui, + PENDING_USERNAME_PILL_LABEL, + DashColors::WARNING_BRIGHT, + Some(&tooltip), + ) +} + +/// Build the pending pill's hover tooltip as a complete sentence. When the +/// decision time is known and still in the future, the estimate is included; +/// otherwise a generic reassurance is returned. Kept separate from the render +/// path so it is unit-testable without a frame. +pub fn pending_username_tooltip(pending: &PendingUsername) -> String { + let now_ms = std::time::UNIX_EPOCH + .elapsed() + .unwrap_or_default() + .as_millis() as u64; + pending + .decided_at + .and_then(|decided_at| approximate_time_until(decided_at, now_ms)) + .unwrap_or_else(|| { + "Dash masternodes vote on who receives this username. Check back later for updates." + .to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + #[test] + fn tooltip_includes_eta_when_decision_time_is_in_the_future() { + let now_ms = std::time::UNIX_EPOCH + .elapsed() + .unwrap_or_default() + .as_millis() as u64; + let pending = PendingUsername { + name: "det1".to_string(), + decided_at: Some(now_ms + 3 * 3_600 * 1_000), + }; + let tip = pending_username_tooltip(&pending); + assert!( + tip.contains("about 3 hours"), + "tooltip should carry the ETA: {tip}" + ); + assert!(tip.contains("Dash masternodes vote")); + assert!(tip.ends_with('.'), "tooltip must be a complete sentence"); + } + + #[test] + fn tooltip_omits_eta_when_decision_time_is_unknown_or_past() { + for decided_at in [None, Some(0)] { + let pending = PendingUsername { + name: "det1".to_string(), + decided_at, + }; + let tip = pending_username_tooltip(&pending); + assert!( + !tip.contains("expected in"), + "no ETA phrase expected: {tip}" + ); + assert!(tip.contains("Dash masternodes vote")); + assert!(tip.ends_with('.'), "tooltip must be a complete sentence"); + } + } + + #[test] + fn pending_pill_renders_the_pending_label() { + let pending = PendingUsername { + name: "det1".to_string(), + decided_at: None, + }; + let mut harness = Harness::builder().build_ui(move |ui| { + pending_username_pill(ui, &pending); + }); + harness.run(); + assert!( + harness + .query_by_label(PENDING_USERNAME_PILL_LABEL) + .is_some(), + "the pending pill must render its '{PENDING_USERNAME_PILL_LABEL}' label" + ); + } +} diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index c9811b934..51cf1a1d3 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -20,7 +20,7 @@ use crate::ui::dashpay::DashPaySubscreen; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::theme::DashColors; +use crate::ui::theme::{DashColors, Typography}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; @@ -386,20 +386,20 @@ impl ScreenLike for AddContactScreen { // Show retry suggestion for recoverable errors if err.is_recoverable() { - ui.label(RichText::new("You can try again.").small().color(DashColors::text_secondary(dark_mode))); + ui.label(RichText::new("You can try again.").font(Typography::hint()).color(DashColors::text_secondary(dark_mode))); } // Show action suggestion for user errors if err.requires_user_action() { match err { DashPayError::UsernameResolutionFailed { .. } => { - ui.label(RichText::new("Tip: Make sure the username is spelled correctly and exists on Dash Platform.").small().color(DashColors::text_secondary(dark_mode))); + ui.label(RichText::new("Tip: Make sure the username is spelled correctly and exists on Dash Platform.").font(Typography::hint()).color(DashColors::text_secondary(dark_mode))); } DashPayError::InvalidUsername { .. } => { - ui.label(RichText::new("Tip: Usernames must end with '.dash' (e.g., alice).").small().color(DashColors::text_secondary(dark_mode))); + ui.label(RichText::new("Tip: Usernames must end with '.dash' (e.g., alice).").font(Typography::hint()).color(DashColors::text_secondary(dark_mode))); } DashPayError::AccountLabelTooLong { .. } => { - ui.label(RichText::new("Tip: Try a shorter, more descriptive label.").small().color(DashColors::text_secondary(dark_mode))); + ui.label(RichText::new("Tip: Try a shorter, more descriptive label.").font(Typography::hint()).color(DashColors::text_secondary(dark_mode))); } DashPayError::MissingEncryptionKey => { ui.add_space(5.0); diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 343b43b9b..7bb556842 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -3,12 +3,14 @@ use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; +use crate::model::contested_name::PendingUsername; use crate::model::qualified_identity::PrivateKeyTarget::{ PrivateKeyOnMainIdentity, PrivateKeyOnVoterIdentity, }; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use crate::model::wallet::WalletSeedHash; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::pill::pending_username_pill; use crate::ui::components::styled::{ConfirmationDialog, ConfirmationStatus, island_central_panel}; use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; @@ -54,6 +56,44 @@ enum IdentitiesSortOrder { Descending, } +/// Render the Name column for one identity: its alias (or a "Set Alias" button +/// when unset) followed by a "Pending" pill when the identity has a DPNS name +/// request that has not yet been awarded. Returns `true` when the "Set Alias" +/// button was clicked. Free of screen state so it is unit-testable with an +/// injected pending value. +fn render_identity_name_cell( + ui: &mut Ui, + alias: Option<&str>, + pending_username: Option<&PendingUsername>, + dark_mode: bool, +) -> bool { + let mut set_alias_clicked = false; + if let Some(alias) = alias { + ui.label(RichText::new(alias).color(DashColors::text_primary(dark_mode))); + } else { + let button = egui::Button::new( + RichText::new("Set Alias") + .small() + .color(DashColors::text_secondary(dark_mode)), + ) + .small() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )) + .corner_radius(egui::CornerRadius::same(3)); + if ui.add(button).clicked() { + set_alias_clicked = true; + } + } + // Requested-but-unawarded DPNS name → "Pending" pill next to the name. + if let Some(pending) = pending_username { + pending_username_pill(ui, pending); + } + set_alias_clicked +} + pub struct IdentitiesScreen { pub identities: Arc>>, pub app_context: Arc, @@ -222,30 +262,22 @@ impl IdentitiesScreen { "".to_owned() } - fn show_alias(&mut self, ui: &mut Ui, qualified_identity: &QualifiedIdentity) { + fn show_alias( + &mut self, + ui: &mut Ui, + qualified_identity: &QualifiedIdentity, + pending_username: Option<&PendingUsername>, + ) { let dark_mode = ui.style().visuals.dark_mode; - - if let Some(alias) = &qualified_identity.alias { - ui.label(RichText::new(alias).color(DashColors::text_primary(dark_mode))); - } else { - let button = egui::Button::new( - RichText::new("Set Alias") - .small() - .color(DashColors::text_secondary(dark_mode)), - ) - .small() - .fill(egui::Color32::TRANSPARENT) - .stroke(egui::Stroke::new( - 1.0, - DashColors::text_secondary(dark_mode), - )) - .corner_radius(egui::CornerRadius::same(3)); - - if ui.add(button).clicked() { - self.editing_alias_identity = Some(qualified_identity.identity.id()); - self.editing_alias_opening_guard.arm(); - self.editing_alias_value.clear(); - } + if render_identity_name_cell( + ui, + qualified_identity.alias.as_deref(), + pending_username, + dark_mode, + ) { + self.editing_alias_identity = Some(qualified_identity.identity.id()); + self.editing_alias_opening_guard.arm(); + self.editing_alias_value.clear(); } } @@ -496,6 +528,13 @@ impl IdentitiesScreen { self.sort_vec(&mut local_identities); } + // Pending DPNS username requests (requested but not yet awarded), keyed + // by identity id. One cache read serves every row; a failure yields an + // empty map so the list still renders. + let pending_usernames = self + .app_context + .pending_dpns_usernames_for_identities(&local_identities); + // Space allocation for UI elements is handled by the layout system egui::ScrollArea::both().show(ui, |ui| { @@ -503,7 +542,7 @@ impl IdentitiesScreen { .striped(false) .resizable(true) .cell_layout(egui::Layout::left_to_right(Align::Center)) - .column(Column::initial(80.0).resizable(true)) // Name + .column(Column::initial(150.0).resizable(true)) // Name (+ pending pill) .column(Column::initial(330.0).resizable(true)) // Identity ID .column(Column::initial(60.0).resizable(true)) // In Wallet .column(Column::initial(80.0).resizable(true)) // Type @@ -551,13 +590,20 @@ impl IdentitiesScreen { // Check if identity is active let is_active = qualified_identity.status == IdentityStatus::Active; + let pending_username = + pending_usernames.get(&qualified_identity.identity.id()); + body.row(30.0, |mut row| { row.col(|ui| { ui.vertical_centered(|ui| { ui.horizontal_centered(|ui| { // Disable UI elements if identity is not active ui.add_enabled_ui(is_active, |ui| { - self.show_alias(ui, qualified_identity); + self.show_alias( + ui, + qualified_identity, + pending_username, + ); }); }); }); @@ -1196,3 +1242,54 @@ impl ScreenLike for IdentitiesScreen { action } } + +#[cfg(test)] +mod tests { + use super::render_identity_name_cell; + use crate::model::contested_name::PendingUsername; + use crate::ui::components::pill::PENDING_USERNAME_PILL_LABEL; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + /// The Identities list Name cell shows the identity's name and, when a DPNS + /// registration is pending, a "Pending" pill beside it. + #[test] + fn name_cell_shows_alias_and_pending_pill() { + let pending = PendingUsername { + name: "det1".to_string(), + decided_at: None, + }; + let mut harness = Harness::builder().build_ui(move |ui| { + render_identity_name_cell(ui, Some("det1.dash"), Some(&pending), false); + }); + harness.run(); + + assert!( + harness.query_by_label("det1.dash").is_some(), + "the identity name must render" + ); + assert!( + harness + .query_by_label(PENDING_USERNAME_PILL_LABEL) + .is_some(), + "a pending registration must render the 'Pending' pill in the list" + ); + } + + /// With no pending registration, the Name cell renders no pill. + #[test] + fn name_cell_without_pending_has_no_pill() { + let mut harness = Harness::builder().build_ui(move |ui| { + render_identity_name_cell(ui, Some("alex.dash"), None, false); + }); + harness.run(); + + assert!(harness.query_by_label("alex.dash").is_some()); + assert!( + harness + .query_by_label(PENDING_USERNAME_PILL_LABEL) + .is_none(), + "no pending registration → no pill" + ); + } +} diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index ca46f8181..ee93f69b2 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::identity::{IdentityTask, RegisterDpnsNameInput}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::dpns::DpnsRegistrationOutcome; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -61,6 +62,7 @@ pub struct RegisterDpnsNameScreen { show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + registration_outcome: Option, // Source of navigation to this screen pub source: RegisterDpnsNameSource, /// Bucket A overlay-adoption pattern: a button-less full-window block raised @@ -139,6 +141,7 @@ impl RegisterDpnsNameScreen { wallet_open_attempted: false, show_advanced_options: false, completed_fee_result: None, + registration_outcome: None, source, op_overlay: None, } @@ -319,9 +322,20 @@ impl RegisterDpnsNameScreen { } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { + let Some(outcome) = self.registration_outcome else { + return AppAction::None; + }; + let success_message = match outcome { + DpnsRegistrationOutcome::Registered => { + "Your username is registered. You can use it now." + } + DpnsRegistrationOutcome::PendingCommunityVote => { + "Your username request was submitted. Other people can also request this name, so the community will vote on who receives it. Check the Pending label on your identity for updates." + } + }; let action = crate::ui::helpers::show_success_screen_with_info( ui, - "DPNS Name Registered!".to_string(), + success_message.to_string(), vec![ ("Back".to_string(), AppAction::PopScreenAndRefresh), ( @@ -339,6 +353,7 @@ impl RegisterDpnsNameScreen { self.name_input = String::new(); self.register_dpns_name_status = RegisterDpnsNameStatus::NotStarted; self.completed_fee_result = None; + self.registration_outcome = None; return AppAction::None; } @@ -359,11 +374,14 @@ impl ScreenLike for RegisterDpnsNameScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { // Tear down the blocking overlay on the success terminal path. - if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = - backend_task_success_result + if let BackendTaskSuccessResult::RegisteredDpnsName { + outcome, + fee_result, + } = backend_task_success_result { self.op_overlay.take_and_clear(); self.completed_fee_result = Some(fee_result); + self.registration_outcome = Some(outcome); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } } diff --git a/src/ui/identity/contacts.rs b/src/ui/identity/contacts.rs index f4eb198c6..3d89043c6 100644 --- a/src/ui/identity/contacts.rs +++ b/src/ui/identity/contacts.rs @@ -342,9 +342,7 @@ fn render_state( } } -/// Centered gate card. The `Why?` panel toggle is a caller-owned boolean -/// persisted on the hub screen in a follow-up task; rendering it collapsed -/// here is the correct default for first paint. +/// Centered gate card shown when the active identity has no social profile. /// /// Exposed to integration tests so IT-CONTACTS-01 can mount the gated view /// without constructing a full `AppContext`. @@ -367,12 +365,6 @@ pub fn render_gated(ui: &mut Ui, handle: Option<&str>) -> AppAction { } } } - if response.why_toggled { - // TODO(identity-hub): persist the expanded flag on the hub screen so - // the panel stays open across frames. Until then the card is - // re-rendered collapsed each frame; the click still surfaces a - // visible press so the affordance is not dead. - } AppAction::None } diff --git a/src/ui/identity/home.rs b/src/ui/identity/home.rs index d988f19e7..1c9468489 100644 --- a/src/ui/identity/home.rs +++ b/src/ui/identity/home.rs @@ -28,6 +28,7 @@ use super::identity_hero_card::{HeroIdentityKind, IdentityHeroCard}; use super::onboarding_checklist::{ChecklistAction, ChecklistStep, OnboardingChecklist}; use crate::app::AppAction; use crate::context::AppContext; +use crate::model::contested_name::PendingUsername; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; @@ -278,6 +279,13 @@ pub fn render( } }; + // A DPNS username requested but not yet awarded — surfaced on the hero and + // the onboarding checklist so a pending request is not mistaken for "no + // username". Only meaningful when the identity owns no name yet; the cache + // read is best-effort, so a failure simply omits the indicator. + let pending_username: Option = + app_context.pending_dpns_username_for_identity(&identity); + // A tiny local closure that dispatches via the pure // `home_button_action` function and merges the result into the // `(action, outcome)` pair. Using this at every click site keeps the UI @@ -291,7 +299,7 @@ pub fn render( }; // --- Hero card ---------------------------------------------------- - let hero = build_hero(app_context, &identity, profiles); + let hero = build_hero(app_context, &identity, profiles, pending_username.clone()); let hero_has_social_profile = hero.has_social_profile(); let hero_response = hero.show(ui); if hero_response.pick_username_clicked() { @@ -432,6 +440,10 @@ pub fn render( } if primary_handle.is_some() { checklist = checklist.mark_complete(ChecklistStep::PickUsername); + } else if let Some(pending) = &pending_username { + // Requested but not yet awarded — reflect the pending state instead + // of nagging the user to pick a name they already chose. + checklist = checklist.with_pending_username(pending.name.clone()); } if hero_has_social_profile { checklist = checklist.mark_complete(ChecklistStep::SetDisplayName); @@ -608,6 +620,7 @@ fn build_hero( app_context: &Arc, qi: &QualifiedIdentity, profiles: &mut super::profile_cache::ProfileCache, + pending_username: Option, ) -> IdentityHeroCard { let kind: HeroIdentityKind = qi.identity_type.into(); let balance_dash = format_credits_short(qi.identity.balance()); @@ -623,8 +636,14 @@ fn build_hero( let display_name = load_display_name_opt(profiles, qi); let mut card = IdentityHeroCard::new(kind, balance_dash); - if let Some(handle) = handle { - card = card.with_dpns_handle(handle); + match handle { + Some(handle) => card = card.with_dpns_handle(handle), + // No owned name: show a pending request when one exists. + None => { + if let Some(pending) = pending_username { + card = card.with_pending_username(pending); + } + } } if let Some(name) = display_name { card = card.with_display_name(name); diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 13d974fdc..0a1e12728 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -9,10 +9,9 @@ use super::breadcrumb_switcher::{self, BreadcrumbEffect}; use super::identity_hub_tab_bar::IdentityHubTabBar; use crate::app::AppAction; -use crate::backend_task::BackendTask; -use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::error::TaskError; +use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::dashpay::UnreadableContactInfoPolicy; use crate::ui::components::component_trait::Component; @@ -528,13 +527,12 @@ impl ScreenLike for IdentityHubScreen { // Settings tab so the Save button re-enables only after the next // edit. Guard by identity ID to reject stale results. BackendTaskSuccessResult::DashPayProfileUpdated(saved_id) => { - let matches = self - .settings_tab - .selected_identity() - .is_some_and(|qi| qi.identity.id() == saved_id); - if matches { - self.settings_tab.on_profile_saved(); - } + handle_profile_updated( + &mut self.settings_tab, + &mut self.profile_cache, + self.app_context.egui_ctx(), + *saved_id, + ); } // Populate the Received/Sent request caches so the Contacts tab // can render real RequestCard rows instead of hardcoded empties. @@ -581,6 +579,13 @@ impl ScreenLike for IdentityHubScreen { } } + fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + if let Some(identity_id) = context.dashpay_profile_update_identity() { + self.settings_tab + .clear_pending_save_for_identity(&identity_id); + } + } + fn display_task_error(&mut self, error: &TaskError) -> bool { if self.handle_contact_request_error(error) { return matches!( @@ -601,14 +606,6 @@ impl ScreenLike for IdentityHubScreen { } release_request_guard_for_error(&mut self.contacts_state, error); - // Clear any dangling pending_save so a failed UpdateProfile doesn't - // leave a stale snapshot around. If a later DashPayProfileUpdated from - // a different path (e.g. the legacy ProfileScreen) arrives it would - // otherwise commit the stale submitted values as the new baseline. - // Clearing on any error is safe: pending_save is None most of the time, - // and clearing it while it's None is a no-op. - self.settings_tab.clear_pending_save(); - // Let AppState render the default error banner — the hub has no // other special-case error handling of its own yet. false @@ -778,6 +775,25 @@ fn applies_to_selected_identity( selected.as_ref() == Some(result_identity) } +fn handle_profile_updated( + settings: &mut SettingsTab, + profiles: &mut super::profile_cache::ProfileCache, + ctx: &egui::Context, + saved_id: Identifier, +) { + let matches = settings + .selected_identity() + .is_some_and(|identity| identity.identity.id() == saved_id); + if matches && let Some(fields) = settings.on_profile_saved() { + profiles.record_saved(saved_id, fields); + MessageBanner::set_global( + ctx, + crate::ui::identity::settings::PROFILE_SAVED, + MessageType::Success, + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -833,6 +849,20 @@ mod tests { Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") } + #[test] + fn stale_profile_success_does_not_show_confirmation() { + let ctx = egui::Context::default(); + let mut settings = SettingsTab::new(); + let mut profiles = crate::ui::identity::profile_cache::ProfileCache::default(); + + handle_profile_updated(&mut settings, &mut profiles, &ctx, id(1)); + + assert!( + !MessageBanner::has_global(&ctx), + "a stale result has no pending save to confirm" + ); + } + async fn wired_test_context() -> (tempfile::TempDir, Arc) { let temp_dir = tempfile::tempdir().expect("tempdir"); let context = crate::context::test_support::test_app_context(temp_dir.path()); diff --git a/src/ui/identity/identity_hero_card.rs b/src/ui/identity/identity_hero_card.rs index f976aee49..534c1d189 100644 --- a/src/ui/identity/identity_hero_card.rs +++ b/src/ui/identity/identity_hero_card.rs @@ -18,12 +18,14 @@ //! This component follows `docs/COMPONENT_DESIGN_PATTERN.md`: private fields + //! builder methods + a response struct implementing [`ComponentResponse`]. +use crate::model::contested_name::PendingUsername; use crate::model::qualified_identity::IdentityType; use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::components::pill; use crate::ui::theme::{DashColors, ResponseExt, Shadow, Shape, Spacing}; use eframe::egui::{ - self, Color32, CornerRadius, FontFamily, FontId, Frame, Margin, Response, RichText, Sense, - Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, + self, Color32, CornerRadius, FontFamily, FontId, Frame, Margin, RichText, Sense, Stroke, + TextureHandle, TextureOptions, Ui, }; /// One of the three supported identity kinds. Maps 1:1 to the project's @@ -181,6 +183,10 @@ pub struct IdentityHeroCard { /// `avatar_uses_initials_fallback()` stays honest even without a render /// pass. avatar_decode_ok: bool, + /// A DPNS username the identity has requested but not yet been awarded. + /// When set and no owned `dpns_handle` exists, the hero shows the requested + /// name with a "Pending" pill instead of the `No username yet` prompt. + pending_username: Option, } impl IdentityHeroCard { @@ -197,6 +203,7 @@ impl IdentityHeroCard { network_tooltip: None, avatar_bytes: None, avatar_decode_ok: false, + pending_username: None, } } @@ -219,6 +226,16 @@ impl IdentityHeroCard { self } + /// Attach a pending DPNS username request (requested but not yet awarded). + /// Shown only when the identity has no owned `dpns_handle` — an owned name + /// always wins. + pub fn with_pending_username(mut self, pending: PendingUsername) -> Self { + if !pending.name.trim().is_empty() { + self.pending_username = Some(pending); + } + self + } + /// Attach a fiat-equivalent line rendered below the balance. pub fn with_fiat_equivalent(mut self, text: impl Into) -> Self { let text = text.into(); @@ -328,24 +345,24 @@ impl IdentityHeroCard { .color(DashColors::text_primary(dark_mode)), ); } - match (&self.display_name, &self.dpns_handle) { - (_, Some(handle)) => { + match (&self.dpns_handle, &self.pending_username) { + (Some(handle), _) => { + // Owned DPNS name always wins over a pending request. ui.label( RichText::new(format!("@{handle}")) .size(16.0) .color(DashColors::text_secondary(dark_mode)), ); } - (Some(_), None) => { - // Social profile set but no DPNS handle — show - // the `Pick a username` prompt. - if self.paint_pick_username_prompt(ui, dark_mode) { - action = Some(HeroAction::PickUsernameClicked); - } + (None, Some(pending)) => { + // Requested but not yet awarded — show the requested + // name with a `Pending` pill so it is not mistaken + // for "no username requested". + self.paint_pending_username_line(ui, dark_mode, pending); } (None, None) => { - // No display name and no DPNS handle — still show - // the prompt so the user has a recovery path. + // No owned name and nothing pending — show the + // `Pick a username` prompt so the user has a path. if self.paint_pick_username_prompt(ui, dark_mode) { action = Some(HeroAction::PickUsernameClicked); } @@ -377,7 +394,7 @@ impl IdentityHeroCard { // Identity-type + network pill row. ui.horizontal(|ui| { - self.paint_pill( + pill::accent_pill( ui, self.kind.badge_label(), self.kind.badge_accent(), @@ -385,7 +402,7 @@ impl IdentityHeroCard { ); if let Some(label) = &self.network_label { ui.add_space(Spacing::XS); - self.paint_pill( + pill::accent_pill( ui, label, DashColors::INFO, @@ -519,45 +536,22 @@ impl IdentityHeroCard { clicked } - /// Paint an identity-type or network pill. Fill is the accent color at - /// 12 % opacity, with a 1 px stroke at the accent color. See §E. - fn paint_pill( - &self, - ui: &mut Ui, - label: &str, - accent: Color32, - tooltip: Option<&str>, - ) -> Response { - let fill = Color32::from_rgba_unmultiplied( - accent.r(), - accent.g(), - accent.b(), - (0.12 * 255.0) as u8, - ); - let stroke_color = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180); - - let text = RichText::new(label).color(accent).size(12.0).strong(); - // Build as an inline pill: small frame around the label. - let inner = Frame::new() - .fill(fill) - .stroke(Stroke::NONE) - .corner_radius(CornerRadius::same(Shape::RADIUS_FULL)) - .inner_margin(Margin::symmetric(10, 3)) - .show(ui, |ui| { - ui.add(egui::Label::new(text).sense(Sense::hover())) - }); - // Paint the stroke manually so the ring color matches the accent. - ui.painter().rect_stroke( - inner.response.rect, - CornerRadius::same(Shape::RADIUS_FULL), - Stroke::new(1.0, stroke_color), - StrokeKind::Outside, - ); - if let Some(text) = tooltip { - inner.response.info_tooltip(text) - } else { - inner.response - } + /// Paint the pending-username line: the requested `@name` (muted, italic to + /// signal it is not yet final) followed by a shared `Pending` pill whose + /// tooltip carries the estimated ready time. + fn paint_pending_username_line(&self, ui: &mut Ui, dark_mode: bool, pending: &PendingUsername) { + let name = + crate::model::contested_name::sanitize_pending_username_for_display(&pending.name); + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("@{name}")) + .size(16.0) + .italics() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(Spacing::XS); + pill::pending_username_pill(ui, pending); + }); } } @@ -727,4 +721,76 @@ mod tests { "avatar_decode_ok must be false for undecodable bytes" ); } + + // ─── Pending DPNS username tests ───────────────────────── + + fn pending(name: &str) -> PendingUsername { + PendingUsername { + name: name.to_string(), + decided_at: None, + } + } + + #[test] + fn with_pending_username_ignores_blank_names() { + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.0") + .with_pending_username(pending(" ")); + assert!(hero.pending_username.is_none()); + } + + /// The pending variant renders the requested name with a `Pending` pill and + /// suppresses the misleading `No username yet` prompt. + #[test] + fn hero_pending_variant_shows_pill_not_pick_username_prompt() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.0") + .with_pending_username(pending("det1")); + assert!(hero.dpns_handle.is_none(), "precondition: no owned name"); + + let mut harness = Harness::builder().build_ui(move |ui| { + hero.show(ui); + }); + harness.run(); + + assert!( + harness + .query_by_label(pill::PENDING_USERNAME_PILL_LABEL) + .is_some(), + "pending hero must render the 'Pending' pill" + ); + assert!( + harness.query_by_label("No username yet").is_none(), + "pending hero must NOT show the 'No username yet' prompt" + ); + } + + /// An owned DPNS name always wins: no `Pending` pill even if a pending + /// request is also attached. + #[test] + fn hero_owned_handle_wins_over_pending() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.0") + .with_dpns_handle("alex") + .with_pending_username(pending("det1")); + + let mut harness = Harness::builder().build_ui(move |ui| { + hero.show(ui); + }); + harness.run(); + + assert!( + harness.query_by_label("@alex").is_some(), + "owned handle must render" + ); + assert!( + harness + .query_by_label(pill::PENDING_USERNAME_PILL_LABEL) + .is_none(), + "owned name must suppress the pending pill" + ); + } } diff --git a/src/ui/identity/onboarding_checklist.rs b/src/ui/identity/onboarding_checklist.rs index d59cec06b..3385d9d04 100644 --- a/src/ui/identity/onboarding_checklist.rs +++ b/src/ui/identity/onboarding_checklist.rs @@ -18,7 +18,7 @@ //! response struct implementing [`ComponentResponse`]. use crate::ui::components::component_trait::ComponentResponse; -use crate::ui::theme::{DashColors, ResponseExt, Shape, Spacing}; +use crate::ui::theme::{DashColors, ResponseExt, Shape, Spacing, Typography}; use eframe::egui::{self, Color32, CornerRadius, Frame, Margin, RichText, Sense, Stroke, Ui}; /// The three canonical onboarding steps. `Hidden` is applied by the caller @@ -172,6 +172,11 @@ pub struct OnboardingChecklist { /// subtext for `PickUsername` reads "You are @{handle}." instead of a /// generic fallback. Set via [`with_handle`](Self::with_handle). handle: Option, + /// A DPNS username the identity has requested but not yet been awarded + /// (without the `.dash` suffix). When set, `PickUsername` is complete and + /// shows voting-status subtext instead of the generic completion copy. Set via + /// [`with_pending_username`](Self::with_pending_username). + pending_username: Option, } impl OnboardingChecklist { @@ -183,6 +188,7 @@ impl OnboardingChecklist { steps: ChecklistStep::ALL.to_vec(), completed: Vec::new(), handle: None, + pending_username: None, } } @@ -197,6 +203,23 @@ impl OnboardingChecklist { self } + /// Attach a DPNS username the identity has requested but not yet been + /// awarded (without the `.dash` suffix). This completes `PickUsername`, + /// shows voting-status subtext, and hides the action button. + pub fn with_pending_username(mut self, name: impl Into) -> Self { + let name = name.into(); + if !name.trim().is_empty() { + self.pending_username = Some(name); + self = self.mark_complete(ChecklistStep::PickUsername); + } + self + } + + /// Whether `PickUsername` has a requested-but-unawarded username attached. + fn pick_username_has_pending_request(&self, step: ChecklistStep) -> bool { + step == ChecklistStep::PickUsername && self.pending_username.is_some() + } + /// Mark a step as complete. No-op if the step was already complete. pub fn mark_complete(mut self, step: ChecklistStep) -> Self { if !self.completed.contains(&step) { @@ -355,7 +378,15 @@ impl OnboardingChecklist { ui.label(rich); // Descriptive subtext (V3). - let subtext: String = if complete { + let subtext: String = if self.pick_username_has_pending_request(step) { + let name = + crate::model::contested_name::sanitize_pending_username_for_display( + self.pending_username.as_deref().unwrap_or_default(), + ); + format!( + "Your request for {name}.dash is pending while Dash masternodes vote." + ) + } else if complete { // For PickUsername done, prefer "You are @{handle}." if step == ChecklistStep::PickUsername { match handle { @@ -370,7 +401,7 @@ impl OnboardingChecklist { }; ui.label( RichText::new(subtext) - .small() + .font(Typography::hint()) .color(DashColors::text_secondary(dark_mode)), ); }); @@ -379,9 +410,10 @@ impl OnboardingChecklist { // The inline action button for pending items is placed outside the // scope so it gets its own visual affordance, but its click still - // counts as a row activation. + // counts as a row activation. A username whose request is already in + // flight hides its button — re-picking is not the next action. let mut action_clicked = false; - if !complete { + if !complete && !self.pick_username_has_pending_request(step) { ui.horizontal(|ui| { ui.add_space(20.0 + Spacing::SM); // align with content column let btn_resp = ui @@ -519,4 +551,72 @@ mod tests { "Add your first contact" ); } + + #[test] + fn with_pending_username_ignores_blank_and_flags_only_pick_username() { + let checklist = OnboardingChecklist::new().with_pending_username(" "); + assert!(checklist.pending_username.is_none()); + + let checklist = OnboardingChecklist::new().with_pending_username("det1"); + assert!(checklist.pick_username_has_pending_request(ChecklistStep::PickUsername)); + assert!(!checklist.pick_username_has_pending_request(ChecklistStep::SetDisplayName)); + } + + #[test] + fn pending_username_marks_pick_username_complete() { + let checklist = OnboardingChecklist::new().with_pending_username("det1"); + + assert!(checklist.is_complete(ChecklistStep::PickUsername)); + } + + #[test] + fn pending_username_contributes_to_all_complete() { + let checklist = OnboardingChecklist::new() + .with_pending_username("det1") + .mark_complete(ChecklistStep::SetDisplayName) + .mark_complete(ChecklistStep::AddFirstContact); + + assert!(checklist.all_complete()); + } + + /// A pending username request renders completed styling with voting-status + /// copy instead of either completion or action prompts. + #[test] + fn pending_username_swaps_the_pick_username_subtext() { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let checklist = OnboardingChecklist::new().with_pending_username("det1"); + let mut harness = Harness::builder().build_ui(move |ui| { + checklist.show(ui); + }); + harness.run(); + + assert!( + harness + .query_by_label( + "Your request for det1.dash is pending while Dash masternodes vote." + ) + .is_some(), + "pending PickUsername must explain its voting status" + ); + assert!(harness.query_by_label("Your username is set.").is_none()); + assert!( + harness + .query_by_label(ChecklistStep::PickUsername.subtext_pending()) + .is_none(), + "the default 'pick a name' nag subtext must be gone while pending" + ); + assert!( + harness + .query_all_by_role_and_label( + Role::Button, + ChecklistStep::PickUsername.action_label() + ) + .next() + .is_none(), + "the pick-name action must be hidden while a request is pending" + ); + } } diff --git a/src/ui/identity/profile_cache.rs b/src/ui/identity/profile_cache.rs index 71547a3fa..fb147a07d 100644 --- a/src/ui/identity/profile_cache.rs +++ b/src/ui/identity/profile_cache.rs @@ -103,6 +103,23 @@ impl ProfileCache { true } + /// Optimistically record a just-saved profile so every tab reflects it + /// immediately, without waiting for a re-fetch. + /// + /// `record_result` only consumes `LoadProfile` results; a save arrives as + /// `DashPayProfileUpdated(id)`, which carries no fields, so without this the + /// cache keeps the pre-save profile and the save appears lost across the + /// app. Clears the debounce bookkeeping for `id` so a later explicit refresh + /// can still re-resolve the authoritative state from the network. + pub fn record_saved(&mut self, id: Identifier, fields: ProfileFields) { + self.loaded.insert(id, Some(fields)); + self.requested.remove(&id); + if self.in_flight == Some(id) { + self.in_flight = None; + } + self.wanted.retain(|q| q.identity.id() != id); + } + /// Drop cached state and pending loads so a refresh re-resolves profiles. pub fn reset(&mut self) { self.loaded.clear(); diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 3e5ff745f..320f3c9b5 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -34,11 +34,14 @@ use crate::ui::MessageType; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::message_banner::MessageBanner; +use crate::ui::components::pill; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use crate::ui::identity::identity_hero_card::HeroIdentityKind; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt, Spacing, Typography}; use crate::ui::{RootScreenType, ScreenType}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; use eframe::egui::{Id, Margin, RichText, TextEdit, Ui}; use std::sync::Arc; @@ -48,6 +51,27 @@ use std::sync::Arc; // --------------------------------------------------------------------------- const TIP_CHANGE_PHOTO: &str = "Upload a square image. Other apps will see this avatar."; +/// Progress banner shown while an `UpdateProfile` save is in flight. +pub(crate) const PROFILE_SAVING: &str = "Saving your social profile…"; +/// Confirmation banner shown after a social profile save succeeds. +pub const PROFILE_SAVED: &str = "Your social profile is saved."; +const PROFILE_SAVING_OWNER_ID: &str = "__profile_saving_owner"; + +pub(crate) fn show_profile_saving_banner(ctx: &egui::Context, identity_id: Identifier) { + ctx.data_mut(|data| data.insert_temp(Id::new(PROFILE_SAVING_OWNER_ID), identity_id)); + MessageBanner::set_global(ctx, PROFILE_SAVING, MessageType::Info).disable_auto_dismiss(); +} + +pub(crate) fn clear_profile_saving_banner(ctx: &egui::Context, identity_id: &Identifier) { + let owner = ctx.data(|data| data.get_temp::(Id::new(PROFILE_SAVING_OWNER_ID))); + if owner.as_ref() != Some(identity_id) { + return; + } + MessageBanner::clear_global_message(ctx, PROFILE_SAVING); + ctx.data_mut(|data| data.remove::(Id::new(PROFILE_SAVING_OWNER_ID))); +} +/// Guidance under the Avatar URL field — supported formats and recommended size. +const AVATAR_URL_HINT: &str = "Link to a public square image (JPEG, PNG, WebP, or GIF); 256×256 pixels or larger is recommended."; const TIP_SAVE_NO_CHANGES: &str = "There are no changes to save."; const TIP_SAVE_INVALID: &str = "Fix the highlighted fields before saving."; const TIP_DELETE_PROFILE: &str = "Remove the display name, bio, and avatar from DashPay. Your identity, usernames, and \ @@ -77,12 +101,6 @@ const ALIAS_SAVED: &str = "Name saved on this device."; const ALIAS_SAVE_FAILED: &str = "This name could not be saved on your device. Try again in a moment."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; -const TIP_BADGE_USER: &str = "A regular identity used for payments, DPNS, and DashPay."; -const TIP_BADGE_MASTERNODE: &str = - "An identity tied to a Dash masternode. It can vote on name contests."; -const TIP_BADGE_EVONODE: &str = - "An identity tied to a Dash evonode. It can vote and validate Platform transactions."; - // Marker strings for controls without a matching backend task. Surfaced in // disabled_tooltip and as a prefix on the row so users know it is a coming // feature, not a stuck UI. @@ -300,6 +318,11 @@ impl SettingsTab { .hint_text("https://example.com/avatar.jpg") .desired_width(f32::INFINITY), ); + ui.label( + RichText::new(AVATAR_URL_HINT) + .font(Typography::hint()) + .color(DashColors::text_secondary(dark_mode)), + ); counter(ui, self.edit_avatar_url.len(), MAX_AVATAR_URL, dark_mode); ui.add_space(12.0); @@ -334,6 +357,10 @@ impl SettingsTab { self.edit_bio.clone(), self.edit_avatar_url.clone(), )); + // Progress feedback: the save round-trips to Platform and can + // take minutes. Keep the banner up (no auto-dismiss) until the + // task finishes. Its attributed result clears this banner. + show_profile_saving_banner(ui.ctx(), identity.identity.id()); action = AppAction::BackendTask(BackendTask::DashPayTask(Box::new( DashPayTask::UpdateProfile { identity: identity.clone(), @@ -393,22 +420,13 @@ impl SettingsTab { let mut action = AppAction::None; let dark_mode = ui.ctx().global_style().visuals.dark_mode; - // Identity-type badge + tooltip — design-spec §B.8 rule 1 moves this - // into Advanced, but we also surface a compact badge here so the - // user knows which identity they are editing. Matches wireframe Frame 8. - let (badge_label, badge_tip) = identity_type_badge(identity.identity_type); - let badge = egui::Button::new(RichText::new(badge_label).small()) - .fill(DashColors::surface(dark_mode)) - .stroke(egui::Stroke::new( - 1.0, - DashColors::text_secondary(dark_mode), - )); - ui.add(badge).info_tooltip(badge_tip); - ui.add_space(8.0); + username_section_header(ui, identity.identity_type, dark_mode); - section_heading(ui, "Username", dark_mode); + // A DPNS name requested but not yet awarded — surfaced only when the + // identity owns no name yet. Best-effort read; a failure omits it. + let pending_username = app_context.pending_dpns_username_for_identity(identity); - // Primary DPNS name. If none, show the CTA card. + // Primary DPNS name. If none, show the pending indicator or the CTA card. let primary = identity.dpns_names.first(); if let Some(name) = primary { ui.horizontal(|ui| { @@ -433,6 +451,21 @@ impl SettingsTab { ui.ctx().copy_text(format!("@{}", name.name)); } }); + } else if let Some(pending) = &pending_username { + // Requested but not yet awarded — show the requested name with a + // "Pending" pill instead of the register CTA. + ui.horizontal(|ui| { + let name = crate::model::contested_name::sanitize_pending_username_for_display( + &pending.name, + ); + ui.label( + RichText::new(format!("@{name}")) + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + pill::pending_username_pill(ui, pending); + }); } else { // Pick-a-username CTA. egui::Frame::group(ui.style()) @@ -876,23 +909,33 @@ impl SettingsTab { /// /// A failed `UpdateProfile` task does NOT call this method, so the baseline /// stays at the last-confirmed state and the user can retry. - pub fn on_profile_saved(&mut self) { - if let Some((dn, bio, url)) = self.pending_save.take() { - self.original_display_name = dn; - self.original_bio = bio; - self.original_avatar_url = url; - } - // If pending_save is None (e.g. stale success after an identity switch - // cleared it) we do nothing — the hub's identity-ID guard should have - // prevented this call, but defending is harmless. + /// + /// Returns the committed fields so the caller can refresh the shared profile + /// cache — otherwise the rest of the app (hero, Contacts gate, this tab on + /// re-entry) keeps reading the pre-save profile and the save looks lost. + /// Returns `None` when there was no pending snapshot (e.g. a stale success + /// after an identity switch cleared it). + pub fn on_profile_saved(&mut self) -> Option { + let (dn, bio, url) = self.pending_save.take()?; + self.original_display_name = dn.clone(); + self.original_bio = bio.clone(); + self.original_avatar_url = url.clone(); + Some(super::profile_cache::ProfileFields { + display_name: dn, + bio, + avatar_url: url, + }) } - /// Clear any in-flight pending save snapshot. Called by the hub's - /// `display_task_error` so a failed `UpdateProfile` doesn't leave a stale - /// snapshot that would be committed if a later `DashPayProfileUpdated` from - /// a different path (e.g. legacy ProfileScreen "Change photo") arrives. - pub fn clear_pending_save(&mut self) { - self.pending_save = None; + /// Clear the pending snapshot only when the failed save belongs to this identity. + pub fn clear_pending_save_for_identity(&mut self, identity_id: &Identifier) { + if self + .selected_identity + .as_ref() + .is_some_and(|identity| identity.identity.id() == *identity_id) + { + self.pending_save = None; + } } /// Validation check used to drive Save button state. Returns `None` when @@ -963,6 +1006,22 @@ fn section_heading(ui: &mut Ui, text: &str, dark_mode: bool) { ui.add_space(4.0); } +fn username_section_header(ui: &mut Ui, identity_type: IdentityType, dark_mode: bool) { + let kind = HeroIdentityKind::from(identity_type); + ui.horizontal(|ui| { + section_heading(ui, "Username", dark_mode); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + pill::accent_pill( + ui, + kind.badge_label(), + kind.badge_accent(), + Some(kind.badge_tooltip()), + ); + }); + }); + ui.add_space(Spacing::XS); +} + fn sub_heading(ui: &mut Ui, text: &str, dark_mode: bool) { ui.label( RichText::new(text) @@ -990,14 +1049,6 @@ fn string_if_set(s: &str) -> Option { } } -fn identity_type_badge(kind: IdentityType) -> (&'static str, &'static str) { - match kind { - IdentityType::User => ("User identity", TIP_BADGE_USER), - IdentityType::Masternode => ("Masternode identity", TIP_BADGE_MASTERNODE), - IdentityType::Evonode => ("Evonode identity", TIP_BADGE_EVONODE), - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1173,16 +1224,25 @@ mod tests { } #[test] - fn identity_type_badge_covers_all_variants() { - for ty in [ - IdentityType::User, - IdentityType::Masternode, - IdentityType::Evonode, - ] { - let (label, tip) = identity_type_badge(ty); - assert!(!label.is_empty()); - assert!(!tip.is_empty()); - } + fn username_heading_and_identity_badge_share_a_row() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut harness = Harness::builder() + .with_size(egui::vec2(400.0, 100.0)) + .build_ui(|ui| { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + username_section_header(ui, IdentityType::User, dark_mode); + }); + harness.run(); + + let heading = harness.get_by_label("Username").rect().center(); + let badge = harness.get_by_label("User identity").rect().center(); + assert!( + (heading.y - badge.y).abs() <= 1.0, + "the badge and heading must share a visual baseline", + ); + assert!(badge.x > heading.x, "the badge must sit to the right"); } /// IT-SETTINGS-01 (section-heading slice) — verifies the three required @@ -1290,10 +1350,52 @@ mod tests { ); // If on_profile_saved is now called (stale result) it must be a no-op. let original_before = tab.original_display_name.clone(); - tab.on_profile_saved(); + assert!( + tab.on_profile_saved().is_none(), + "stale on_profile_saved (no pending snapshot) must return None" + ); assert_eq!( tab.original_display_name, original_before, "stale on_profile_saved must not corrupt baseline" ); } + + #[test] + fn pending_save_is_cleared_only_for_its_identity_error() { + let mut tab = SettingsTab::new(); + tab.selected_identity = Some(qualified_identity()); + tab.pending_save = Some(("Alicia".into(), String::new(), String::new())); + + tab.clear_pending_save_for_identity(&Identifier::from([8; 32])); + assert!( + tab.pending_save.is_some(), + "another identity's failure must preserve the selected identity's snapshot" + ); + + tab.clear_pending_save_for_identity(&Identifier::from([7; 32])); + assert!( + tab.pending_save.is_none(), + "the selected identity's failure must clear its stale snapshot" + ); + } + + /// A confirmed save returns the submitted fields so the hub can refresh the + /// shared profile cache — without this the save looks lost app-wide. + #[test] + fn on_profile_saved_returns_committed_fields_for_cache_refresh() { + let mut tab = SettingsTab::new(); + tab.pending_save = Some(( + "Alicia".into(), + "Loves Dash.".into(), + "https://example.com/a.png".into(), + )); + let fields = tab + .on_profile_saved() + .expect("a confirmed save must return the committed fields"); + assert_eq!(fields.display_name, "Alicia"); + assert_eq!(fields.bio, "Loves Dash."); + assert_eq!(fields.avatar_url, "https://example.com/a.png"); + // Snapshot consumed → a second call is a no-op. + assert!(tab.on_profile_saved().is_none()); + } } diff --git a/src/ui/identity/social_profile_gate_card.rs b/src/ui/identity/social_profile_gate_card.rs index 1aa0b42b5..8894c66df 100644 --- a/src/ui/identity/social_profile_gate_card.rs +++ b/src/ui/identity/social_profile_gate_card.rs @@ -3,9 +3,8 @@ //! //! The card has: //! - A heading (`Set up a social profile first.`), -//! - A body paragraph that interpolates the user's `@handle` when known, -//! - A primary button (`Add a display name`), and -//! - A secondary `Why?` button that toggles an inline explanation panel. +//! - A body paragraph that interpolates the user's `@handle` when known, and +//! - A primary button (`Set up your social profile`). //! //! Follows the project's lazy-init component pattern //! (`docs/COMPONENT_DESIGN_PATTERN.md`): domain/config fields stored on the @@ -18,9 +17,7 @@ use eframe::egui::{CornerRadius, Frame, Margin, RichText, Stroke, Ui}; /// Copy constants, kept as `pub const` so tests and sibling callsites share a /// single source of truth and any future i18n extraction touches one line. pub const HEADING: &str = "Set up a social profile first."; -pub const PRIMARY_LABEL: &str = "Add a display name"; -pub const WHY_LABEL: &str = "Why?"; -pub const WHY_EXPANDED_LABEL: &str = "Hide details"; +pub const PRIMARY_LABEL: &str = "Set up your social profile"; /// Body text when the caller knows the identity's DPNS handle. The `{handle}` /// placeholder is i18n-ready (named, no positional assumptions). @@ -34,12 +31,6 @@ pub const BODY_NO_HANDLE: &str = "Contacts use your display name and avatar to l only unlocks contacts. Without a social profile, you cannot add contacts or \ receive contact requests."; -/// Text shown inside the expanded "Why?" panel. -pub const WHY_PANEL_BODY: &str = "Your username is already yours — it's on Platform and anyone who knows it can pay \ - you. A social profile is different: it adds a display name and avatar, and unlocks \ - the contacts feature so your friends can find you, send you money by name, and see \ - your recent activity if you allow it."; - /// Interpolate `{handle}` into a template. Public for unit tests. Missing /// placeholder returns the template unchanged — callers that pass a template /// without a placeholder still get a sensible output. @@ -47,42 +38,30 @@ pub(crate) fn interpolate_handle(template: &str, handle: &str) -> String { template.replace("{handle}", handle) } -/// Typed action emitted by [`SocialProfileGateCard`] (T12). Replaces the -/// meaning of the raw booleans with an explicit discriminant while keeping the -/// booleans for backward-compat call sites. +/// Typed action emitted by [`SocialProfileGateCard`]. Kept as an enum (rather +/// than a bare bool) so the [`ComponentResponse`] contract has a domain type +/// and future actions extend it without a signature break. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GateCardAction { - /// The primary CTA ("Add a display name") was clicked. + /// The primary CTA ("Set up your social profile") was clicked. PrimaryClicked, - /// The "Why?" / "Hide details" toggle was clicked. - WhyToggled, } -/// Response returned by [`SocialProfileGateCard::show`]. The raw booleans -/// (`primary_clicked`, `why_toggled`) are kept for backward-compat call sites. -/// The typed [`GateCardAction`] (T12) is available via -/// [`action`](Self::action) and via the [`ComponentResponse`] impl. +/// Response returned by [`SocialProfileGateCard::show`]. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SocialProfileGateCardResponse { /// The primary button was clicked this frame. pub primary_clicked: bool, - /// The `Why?` toggle was clicked this frame. - pub why_toggled: bool, /// Typed action cache populated by [`SocialProfileGateCard::show`] so that /// `ComponentResponse::changed_value()` can return a borrow. Private. action_cache: Option, } impl SocialProfileGateCardResponse { - /// Derive the typed [`GateCardAction`] from the raw booleans, if any. + /// Derive the typed [`GateCardAction`] from the raw boolean, if any. pub fn action(&self) -> Option { - if self.primary_clicked { - Some(GateCardAction::PrimaryClicked) - } else if self.why_toggled { - Some(GateCardAction::WhyToggled) - } else { - None - } + self.primary_clicked + .then_some(GateCardAction::PrimaryClicked) } } @@ -91,7 +70,7 @@ impl ComponentResponse for SocialProfileGateCardResponse { type DomainType = GateCardAction; fn has_changed(&self) -> bool { - self.primary_clicked || self.why_toggled + self.primary_clicked } fn is_valid(&self) -> bool { @@ -107,13 +86,11 @@ impl ComponentResponse for SocialProfileGateCardResponse { } } -/// The centered no-profile gate card. Interactive-only state is the `expanded` -/// flag for the explanation panel, which the caller owns so the card can stay -/// stateless between frames and is trivial to test. +/// The centered no-profile gate card. Stateless between frames so it is trivial +/// to test. #[derive(Clone, Debug, Default)] pub struct SocialProfileGateCard { handle: Option, - expanded: bool, max_width: Option, } @@ -127,18 +104,10 @@ impl SocialProfileGateCard { .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string), - expanded: false, max_width: None, } } - /// Set whether the "Why?" explanation panel is expanded. Caller-owned so - /// the card has no cross-frame mutable state. - pub fn with_expanded(mut self, expanded: bool) -> Self { - self.expanded = expanded; - self - } - /// Clamp the card body width. Defaults to a reasonable reading width. pub fn with_max_width(mut self, max_width: f32) -> Self { self.max_width = Some(max_width); @@ -153,18 +122,8 @@ impl SocialProfileGateCard { } } - /// Resolved secondary-button label: flips between "Why?" and a collapse - /// affordance when the panel is expanded. Exposed for tests. - pub fn resolved_why_label(&self) -> &'static str { - if self.expanded { - WHY_EXPANDED_LABEL - } else { - WHY_LABEL - } - } - - /// Render the card. Returns a response describing which buttons were - /// clicked this frame; the caller owns the expansion state. + /// Render the card. Returns a response describing whether the primary button + /// was clicked this frame. pub fn show(&self, ui: &mut Ui) -> SocialProfileGateCardResponse { let dark_mode = ui.ctx().global_style().visuals.dark_mode; let max_width = self.max_width.unwrap_or(520.0); @@ -198,36 +157,11 @@ impl SocialProfileGateCard { .color(DashColors::text_secondary(dark_mode)), ); ui.add_space(16.0); - ui.horizontal(|ui| { - // Spacer to center-align the two buttons inside the - // vertical_centered column. - ui.add_space(0.0); - if ui - .add(ComponentStyles::primary_button(PRIMARY_LABEL)) - .clicked() - { - response.primary_clicked = true; - } - ui.add_space(8.0); - if ui - .add(ComponentStyles::secondary_button( - self.resolved_why_label(), - dark_mode, - )) - .clicked() - { - response.why_toggled = true; - } - }); - if self.expanded { - ui.add_space(12.0); - ui.separator(); - ui.add_space(8.0); - ui.label( - RichText::new(WHY_PANEL_BODY) - .color(DashColors::text_secondary(dark_mode)) - .small(), - ); + if ui + .add(ComponentStyles::primary_button(PRIMARY_LABEL)) + .clicked() + { + response.primary_clicked = true; } }); }); @@ -236,7 +170,7 @@ impl SocialProfileGateCard { }); // Populate the typed action cache so ComponentResponse::changed_value() - // can return a borrow (T12). + // can return a borrow. response.action_cache = response.action(); response } @@ -247,7 +181,7 @@ mod tests { use super::*; /// UT-GATE-01 — No-social-profile gate card: interpolates `{handle}` - /// correctly and the primary button label is `Add a display name`. + /// correctly and the primary button label is `Set up your social profile`. #[test] fn ut_gate_01_interpolates_handle_and_primary_label() { let card = SocialProfileGateCard::new(Some("alex.dash")); @@ -260,7 +194,7 @@ mod tests { !body.contains("{handle}"), "body must not contain the raw placeholder after interpolation" ); - assert_eq!(PRIMARY_LABEL, "Add a display name"); + assert_eq!(PRIMARY_LABEL, "Set up your social profile"); } #[test] @@ -300,24 +234,27 @@ mod tests { assert!(SocialProfileGateCard::new(Some(" ")).handle.is_none()); } - #[test] - fn why_label_flips_when_expanded() { - let collapsed = SocialProfileGateCard::new(None); - let expanded = SocialProfileGateCard::new(None).with_expanded(true); - assert_eq!(collapsed.resolved_why_label(), WHY_LABEL); - assert_eq!(expanded.resolved_why_label(), WHY_EXPANDED_LABEL); - } - #[test] fn heading_is_complete_sentence() { assert!(HEADING.ends_with('.')); assert!(HEADING.chars().next().unwrap().is_ascii_uppercase()); } + #[test] + fn primary_click_maps_to_typed_action() { + let resp = SocialProfileGateCardResponse { + primary_clicked: true, + action_cache: None, + }; + assert_eq!(resp.action(), Some(GateCardAction::PrimaryClicked)); + assert!(resp.has_changed()); + } + #[test] fn default_response_has_no_clicks() { let resp = SocialProfileGateCardResponse::default(); assert!(!resp.primary_clicked); - assert!(!resp.why_toggled); + assert!(!resp.has_changed()); + assert!(resp.action().is_none()); } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 716926a2a..df8e1cc92 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -632,6 +632,18 @@ impl Typography { FontId::new(Self::SCALE_XS, FontFamily::Proportional) } + /// Font for instructional hint text: the short "what to do / why" line shown + /// directly beneath a primary label (e.g. an onboarding step or an error). + /// + /// Use this — not egui's built-in `RichText::small()` — for that category. + /// `.small()` renders at egui's ~9px default, which is too small to read as + /// guidance; this token pins the size to the centralized scale instead. Do + /// not repurpose it for timestamps, tags, or other incidental small text — + /// `caption()` / `body_small()` cover those. + pub fn hint() -> FontId { + FontId::new(Self::SCALE_SM, FontFamily::Proportional) + } + pub fn monospace() -> FontId { FontId::new(Self::SCALE_BASE, FontFamily::Monospace) } @@ -1214,6 +1226,24 @@ pub fn apply_theme(ctx: &egui::Context, theme_mode: ThemeMode) { mod tests { use super::*; + /// The `hint()` token must be larger than egui's built-in `.small()` (the + /// style that made the instructional subtext too small to read) and pinned + /// to the centralized scale — never a hard-coded ad-hoc size. + #[test] + fn hint_token_is_larger_than_egui_small_and_on_scale() { + let egui_small = egui::TextStyle::Small.resolve(&egui::Style::default()).size; + let hint = Typography::hint().size; + assert!( + hint > egui_small, + "hint() ({hint}) must be larger than egui's default .small() ({egui_small})" + ); + assert_eq!( + hint, + Typography::SCALE_SM, + "hint() must use the SCALE_SM token" + ); + } + #[test] fn theme_detection_failure_logs_once_until_reset() { THEME_DETECTION_FAILURE_LOGGED.store(false, Ordering::Relaxed); diff --git a/src/ui/wallets/wallets_screen/asset_locks.rs b/src/ui/wallets/wallets_screen/asset_locks.rs index 805a700f4..218494630 100644 --- a/src/ui/wallets/wallets_screen/asset_locks.rs +++ b/src/ui/wallets/wallets_screen/asset_locks.rs @@ -1,7 +1,7 @@ use crate::app::AppAction; use crate::model::wallet::DerivationPathHelpers; use crate::ui::ScreenType; -use crate::ui::theme::{DashColors, ResponseExt}; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::wallet_backend::poison::RwLockRecover; use eframe::egui::{self, Ui}; use egui::{Color32, Frame, Margin, RichText}; @@ -62,19 +62,9 @@ impl WalletsBalancesScreen { .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) .show(ui, |ui| { let dark_mode = ui.style().visuals.dark_mode; - ui.horizontal(|ui| { - ui.heading( - RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode)), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Create Asset Lock").clicked() { - app_action = AppAction::AddScreen( - ScreenType::CreateAssetLock(arc_wallet.clone()) - .create_screen(&self.app_context), - ); - } - }); - }); + ui.heading( + RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode)), + ); ui.add_space(10.0); let Some(tracked) = tracked.as_deref() else { @@ -99,6 +89,14 @@ impl WalletsBalancesScreen { } ui.add_space(20.0); }); + + ui.add_space(10.0); + if ComponentStyles::add_primary_button(ui, "Create Asset Lock").clicked() { + app_action = AppAction::AddScreen( + ScreenType::CreateAssetLock(arc_wallet.clone()) + .create_screen(&self.app_context), + ); + } return; }; @@ -185,6 +183,14 @@ impl WalletsBalancesScreen { }); }); } + + ui.add_space(10.0); + if ComponentStyles::add_primary_button(ui, "Create Asset Lock").clicked() { + app_action = AppAction::AddScreen( + ScreenType::CreateAssetLock(arc_wallet.clone()) + .create_screen(&self.app_context), + ); + } }); if retry_clicked { @@ -194,7 +200,6 @@ impl WalletsBalancesScreen { if let Some((out_point, platform_addresses)) = open_fund_dialog_for_op { self.fund_platform_dialog.selected_asset_lock_out_point = Some(out_point); self.fund_platform_dialog.is_open = true; - self.fund_platform_dialog.opening_guard.arm(); self.fund_platform_dialog.platform_addresses = platform_addresses; self.fund_platform_dialog.selected_platform_address = None; self.fund_platform_dialog.status = None; diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index ad0e61b19..75681af91 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -10,7 +10,6 @@ use crate::ui::components::MessageBanner; use crate::ui::components::address_input::AddressInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::helpers::copy_text_to_clipboard; -use crate::ui::helpers::{ModalOpeningGuard, clicked_outside_window_after_open}; use crate::ui::identities::funding_common::generate_qr_code_image; use crate::ui::theme::{ComponentStyles, DashColors}; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; @@ -39,7 +38,6 @@ pub(super) enum ReceiveAddressType { #[derive(Default)] pub(super) struct ReceiveDialogState { pub is_open: bool, - opening_guard: ModalOpeningGuard, /// Selected address type (Core or Platform) pub address_type: ReceiveAddressType, /// Core addresses with balances: (address, balance_duffs) @@ -69,7 +67,6 @@ pub(super) struct ReceiveDialogState { impl ReceiveDialogState { pub(super) fn open(&mut self) { self.is_open = true; - self.opening_guard.arm(); } } @@ -77,7 +74,6 @@ impl ReceiveDialogState { #[derive(Default)] pub(super) struct FundPlatformAddressDialogState { pub is_open: bool, - pub(super) opening_guard: ModalOpeningGuard, /// Outpoint of the upstream-tracked asset lock chosen to fund a Platform /// address. `None` until the user clicks "Fund" on a row in the asset- /// locks table. @@ -98,7 +94,6 @@ pub(super) struct FundPlatformAddressDialogState { #[derive(Default)] pub(super) struct MineDialogState { pub is_open: bool, - opening_guard: ModalOpeningGuard, pub address_input: Option, pub validated_address: Option, pub block_count_str: String, @@ -214,13 +209,14 @@ impl WalletsBalancesScreen { } let mut open = self.receive_dialog.is_open; + let mut close_clicked = false; // Draw dark overlay behind the dialog (only when open) if open { Self::draw_modal_overlay(ctx, "receive_dialog_overlay"); } - let window_response = egui::Window::new("Receive") + egui::Window::new("Receive") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) @@ -512,16 +508,17 @@ impl WalletsBalancesScreen { RichText::new(status).color(DashColors::text_secondary(dark_mode)), ); } + + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { + close_clicked = true; + } + }); }); }); - if let Some(ref resp) = window_response - && clicked_outside_window_after_open( - ctx, - resp.response.rect, - &mut self.receive_dialog.opening_guard, - ) - { + if close_clicked { open = false; } @@ -573,6 +570,23 @@ impl WalletsBalancesScreen { self.receive_dialog.status = Some("Generating a new address…".to_string()); } + /// Opens a funded-address dialog with deterministic inputs for UI tests. + #[cfg(feature = "testing")] + #[doc(hidden)] + pub fn open_fund_platform_dialog_for_test(&mut self, platform_addresses: Vec<(String, u64)>) { + use dash_sdk::dpp::dashcore::hashes::Hash; + + self.fund_platform_dialog = FundPlatformAddressDialogState { + is_open: true, + selected_asset_lock_out_point: Some(dash_sdk::dpp::dashcore::OutPoint::new( + dash_sdk::dpp::dashcore::Txid::from_byte_array([0; 32]), + 0, + )), + platform_addresses, + ..Default::default() + }; + } + /// Render the Fund Platform Address from Asset Lock dialog pub(super) fn render_fund_platform_dialog(&mut self, ctx: &Context) -> AppAction { if !self.fund_platform_dialog.is_open { @@ -586,7 +600,7 @@ impl WalletsBalancesScreen { // Draw dark overlay behind the popup Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); - let window_response = egui::Window::new("Fund Platform Address from Asset Lock") + egui::Window::new("Fund Platform Address from Asset Lock") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) @@ -732,16 +746,6 @@ impl WalletsBalancesScreen { }); }); - if let Some(ref resp) = window_response - && clicked_outside_window_after_open( - ctx, - resp.response.rect, - &mut self.fund_platform_dialog.opening_guard, - ) - { - open = false; - } - // Only update from `open` if we didn't manually close via cancel button if self.fund_platform_dialog.is_open { self.fund_platform_dialog.is_open = open; @@ -1145,7 +1149,6 @@ impl WalletsBalancesScreen { self.mine_dialog = MineDialogState { is_open: true, - opening_guard: ModalOpeningGuard::armed(), address_input: Some(address_input), validated_address: None, block_count_str: "1".to_string(), @@ -1164,7 +1167,7 @@ impl WalletsBalancesScreen { Self::draw_modal_overlay(ctx, "mine_dialog_overlay"); - let window_response = egui::Window::new("Mine Blocks") + egui::Window::new("Mine Blocks") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) @@ -1281,16 +1284,6 @@ impl WalletsBalancesScreen { }); }); - if let Some(ref resp) = window_response - && clicked_outside_window_after_open( - ctx, - resp.response.rect, - &mut self.mine_dialog.opening_guard, - ) - { - open = false; - } - if !open || !self.mine_dialog.is_open { self.mine_dialog = MineDialogState::default(); } diff --git a/tests/backend-e2e/dashpay_tasks.rs b/tests/backend-e2e/dashpay_tasks.rs index 00951d7e9..72b9a88ad 100644 --- a/tests/backend-e2e/dashpay_tasks.rs +++ b/tests/backend-e2e/dashpay_tasks.rs @@ -998,7 +998,10 @@ async fn tc_043_reject_contact_request() { .await .expect("TC-043: DPNS registration for C should succeed"); assert!( - matches!(dpns_result, BackendTaskSuccessResult::RegisteredDpnsName(_)), + matches!( + dpns_result, + BackendTaskSuccessResult::RegisteredDpnsName { .. } + ), "TC-043: expected RegisteredDpnsName for C, got: {:?}", dpns_result ); diff --git a/tests/backend-e2e/framework/fixtures.rs b/tests/backend-e2e/framework/fixtures.rs index 2bd4da220..4444a7a80 100644 --- a/tests/backend-e2e/framework/fixtures.rs +++ b/tests/backend-e2e/framework/fixtures.rs @@ -364,7 +364,7 @@ async fn register_dpns_name( ) }); assert!( - matches!(result, BackendTaskSuccessResult::RegisteredDpnsName(_)), + matches!(result, BackendTaskSuccessResult::RegisteredDpnsName { .. }), "SharedDashPayPair: expected RegisteredDpnsName for {}, got: {:?}", label, result diff --git a/tests/backend-e2e/register_dpns.rs b/tests/backend-e2e/register_dpns.rs index c2ab37dc6..5dab6cca2 100644 --- a/tests/backend-e2e/register_dpns.rs +++ b/tests/backend-e2e/register_dpns.rs @@ -50,7 +50,7 @@ async fn test_register_dpns_name() { .expect("DPNS registration should succeed"); match result { - BackendTaskSuccessResult::RegisteredDpnsName(fee_result) => { + BackendTaskSuccessResult::RegisteredDpnsName { fee_result, .. } => { tracing::info!("DPNS name registered, fee: {:?}", fee_result); } other => panic!("Expected RegisteredDpnsName, got: {:?}", other), diff --git a/tests/kittest/identity_hub_contacts.rs b/tests/kittest/identity_hub_contacts.rs index ed4168f22..5f1c414a1 100644 --- a/tests/kittest/identity_hub_contacts.rs +++ b/tests/kittest/identity_hub_contacts.rs @@ -13,7 +13,7 @@ //! //! The assertions cover the test-spec expectations: //! - Heading `Set up a social profile first.` present. -//! - Primary button `Add a display name` present. +//! - Primary button `Set up your social profile` present. //! - No request cards or active contacts list rendered (the populated-state //! section headings and the search placeholder must be absent). @@ -39,12 +39,19 @@ fn it_contacts_01_gated_renders_when_no_social_profile() { "gated Contacts tab must show the `{GATE_HEADING}` heading" ); - // Primary CTA present. + // Primary CTA present, with the reworded label. + assert_eq!(GATE_PRIMARY, "Set up your social profile"); assert!( harness.query_by_label(GATE_PRIMARY).is_some(), "gated Contacts tab must show the `{GATE_PRIMARY}` primary button" ); + // The dead `Why?` button was removed — it must not render. + assert!( + harness.query_by_label("Why?").is_none(), + "the non-functional `Why?` button must be gone" + ); + // Populated-state copy must NOT appear when gated. assert!( harness.query_by_label(contacts::RECEIVED_HEADING).is_none(), diff --git a/tests/kittest/register_dpns_name_screen.rs b/tests/kittest/register_dpns_name_screen.rs index 1a96ec1c8..49fe8da4e 100644 --- a/tests/kittest/register_dpns_name_screen.rs +++ b/tests/kittest/register_dpns_name_screen.rs @@ -15,6 +15,7 @@ use crate::support::with_isolated_data_dir; use dash_evo_tool::app::AppState; use dash_evo_tool::backend_task::{BackendTaskSuccessResult, FeeResult}; use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::dpns::DpnsRegistrationOutcome; use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use dash_evo_tool::ui::MessageType; @@ -28,6 +29,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use std::collections::BTreeMap; use std::sync::Arc; @@ -70,9 +72,10 @@ fn dpns_success_result_clears_overlay() { screen.raise_progress_overlay_for_test(&ctx); assert!(ProgressOverlay::has_global(&ctx)); - screen.display_task_result(BackendTaskSuccessResult::RegisteredDpnsName( - FeeResult::new(0, 0), - )); + screen.display_task_result(BackendTaskSuccessResult::RegisteredDpnsName { + outcome: DpnsRegistrationOutcome::Registered, + fee_result: FeeResult::new(0, 0), + }); assert!( !ProgressOverlay::has_global(&ctx), "a successful result must tear down the blocking overlay" @@ -80,6 +83,46 @@ fn dpns_success_result_clears_overlay() { }); } +fn assert_registration_outcome_copy(outcome: DpnsRegistrationOutcome, expected: &str, wrong: &str) { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + screen.display_task_result(BackendTaskSuccessResult::RegisteredDpnsName { + outcome, + fee_result: FeeResult::new(0, 0), + }); + + let mut harness = Harness::builder().build_ui(move |ui| { + screen.ui(ui); + }); + harness.run(); + + assert!(harness.query_by_label(expected).is_some()); + assert!(harness.query_by_label("DPNS Name Registered!").is_none()); + assert!(harness.query_by_label(wrong).is_none()); + }); +} + +#[test] +fn dpns_registered_outcome_renders_finalized_copy() { + assert_registration_outcome_copy( + DpnsRegistrationOutcome::Registered, + "Your username is registered. You can use it now.", + "Your username request was submitted. Other people can also request this name, so the community will vote on who receives it. Check the Pending label on your identity for updates.", + ); +} + +#[test] +fn dpns_pending_outcome_renders_voting_copy() { + assert_registration_outcome_copy( + DpnsRegistrationOutcome::PendingCommunityVote, + "Your username request was submitted. Other people can also request this name, so the community will vote on who receives it. Check the Pending label on your identity for updates.", + "Your username is registered. You can use it now.", + ); +} + // ── W2 B2 — app-scoped seeding ─────────────────────────────────────────────── /// Seed a wallet-less identity into the live context (mirrors identity_hub_switcher). diff --git a/tests/kittest/wallets_screen.rs b/tests/kittest/wallets_screen.rs index 31cedbefc..380ede5b2 100644 --- a/tests/kittest/wallets_screen.rs +++ b/tests/kittest/wallets_screen.rs @@ -1,13 +1,29 @@ use crate::support::{fresh_app_context, with_isolated_data_dir}; +#[cfg(feature = "testing")] +use dash_evo_tool::app::AppAction; +#[cfg(feature = "testing")] +use dash_evo_tool::backend_task::BackendTask; +use dash_evo_tool::backend_task::BackendTaskSuccessResult; +#[cfg(feature = "testing")] +use dash_evo_tool::backend_task::wallet::WalletTask; use dash_evo_tool::model::secret::Secret; use dash_evo_tool::model::wallet::Wallet; use dash_evo_tool::model::wallet::birth_height::WalletOrigin; use dash_evo_tool::ui::ScreenLike; use dash_evo_tool::ui::wallets::wallets_screen::WalletsBalancesScreen; +#[cfg(feature = "testing")] +use dash_sdk::dashcore_rpc::dashcore::Network; +#[cfg(feature = "testing")] +use dash_sdk::dpp::address_funds::PlatformAddress; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; +#[cfg(feature = "testing")] +use std::cell::Cell; +#[cfg(feature = "testing")] +use std::rc::Rc; use std::sync::{Arc, RwLock}; use std::time::Duration; +use zeroize::Zeroize; const WALLET_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(5); @@ -31,13 +47,15 @@ fn build_wallet_screen_harness( fn wallet_screen_harness(password: Option<&Secret>) -> Harness<'static, WalletsBalancesScreen> { let (runtime, app_context) = fresh_app_context(); + let mut seed: [u8; 64] = rand::random(); let mut wallet = Wallet::new_from_seed( - [0x42; 64], + seed, app_context.network(), Some("Dialog wallet".to_string()), password, ) .expect("create wallet fixture"); + seed.zeroize(); if password.is_some() { wallet.wallet_seed.close(); } @@ -53,7 +71,7 @@ fn wallet_screen_harness(password: Option<&Secret>) -> Harness<'static, WalletsB fn registered_wallet_screen_harness() -> Harness<'static, WalletsBalancesScreen> { let (runtime, app_context) = fresh_app_context(); - let seed = [0x42; 64]; + let mut seed: [u8; 64] = rand::random(); let wallet = Wallet::new_from_seed( seed, app_context.network(), @@ -67,6 +85,7 @@ fn registered_wallet_screen_harness() -> Harness<'static, WalletsBalancesScreen> .register_wallet(wallet, &seed, WalletOrigin::Imported) .expect("register wallet fixture") }; + seed.zeroize(); let backend = app_context .wallet_backend() .expect("wallet backend must be wired"); @@ -111,6 +130,190 @@ fn click_in_one_frame(harness: &mut Harness<'_, WalletsBalancesScreen>, label: & harness.step(); } +#[cfg(feature = "testing")] +fn press_label(harness: &mut Harness<'_, WalletsBalancesScreen>, label: &str) { + let pos = harness.get_by_label(label).rect().center(); + harness.input_mut().events.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ]); + harness.step(); +} + +#[cfg(feature = "testing")] +fn release_pointer_away(harness: &mut Harness<'_, WalletsBalancesScreen>) { + let pos = egui::pos2(0.0, 0.0); + harness.input_mut().events.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }, + ]); + harness.step(); +} + +#[cfg(feature = "testing")] +fn platform_addresses(count: u8, network: Network) -> Vec<(String, u64)> { + (1..=count) + .map(|byte| { + ( + PlatformAddress::P2pkh([byte; 20]).to_bech32m_string(network), + 0, + ) + }) + .collect() +} + +#[test] +#[cfg(feature = "testing")] +fn fund_platform_dialog_last_popup_row_stays_open_until_fund_is_clicked() { + with_isolated_data_dir(|| { + let (runtime, app_context) = fresh_app_context(); + let mut seed: [u8; 64] = rand::random(); + let wallet = Wallet::new_from_seed( + seed, + app_context.network(), + Some("Dialog wallet".to_string()), + None, + ) + .expect("create wallet fixture"); + seed.zeroize(); + let seed_hash = wallet.seed_hash(); + app_context + .wallets() + .write() + .expect("wallet map") + .insert(seed_hash, Arc::new(RwLock::new(wallet))); + + let addresses = platform_addresses(5, app_context.network()); + let last_address = addresses.last().expect("five addresses").0.clone(); + let selected_text = format!("{}... (0.0000 DASH)", &last_address[..12]); + let mut screen = WalletsBalancesScreen::new(&app_context); + screen.display_task_result(BackendTaskSuccessResult::TrackedAssetLocks { + seed_hash, + locks: Vec::new(), + }); + screen.open_fund_platform_dialog_for_test(addresses); + + let funding_tasks = Rc::new(Cell::new(0)); + let task_counter = funding_tasks.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .build_ui_state( + move |ui, screen: &mut WalletsBalancesScreen| { + let _runtime = &runtime; + if matches!( + screen.ui(ui), + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FundPlatformAddressFromAssetLock { .. } + )) + ) { + task_counter.set(task_counter.get() + 1); + } + }, + screen, + ); + + harness.run(); + assert_eq!(funding_tasks.get(), 0); + + harness.get_by_value("Select an address").click(); + harness.run(); + press_label(&mut harness, &selected_text); + + assert!( + harness + .query_by_label("Fund Platform Address from Asset Lock") + .is_some(), + "selecting the last popup row must leave the dialog open" + ); + assert_eq!( + funding_tasks.get(), + 0, + "pressing an address row must not dispatch the funding task" + ); + + release_pointer_away(&mut harness); + if harness.query_by_label(&selected_text).is_none() { + harness.get_by_value("Select an address").click(); + harness.run(); + } + harness.get_by_label(&selected_text).click(); + harness.run(); + + assert!( + harness.query_by_value(&selected_text).is_some(), + "the fifth Platform address must become the selected value" + ); + assert_eq!( + funding_tasks.get(), + 0, + "selecting an address must not dispatch the funding task" + ); + + harness.get_by_label("Fund Address").click(); + harness.run(); + assert_eq!( + funding_tasks.get(), + 1, + "the funding task must dispatch only after the explicit button click" + ); + }); +} + +#[test] +fn create_asset_lock_button_is_below_empty_state() { + with_isolated_data_dir(|| { + let (runtime, app_context) = fresh_app_context(); + let mut seed: [u8; 64] = rand::random(); + let wallet = Wallet::new_from_seed( + seed, + app_context.network(), + Some("Dialog wallet".to_string()), + None, + ) + .expect("create wallet fixture"); + seed.zeroize(); + let seed_hash = wallet.seed_hash(); + app_context + .wallets() + .write() + .expect("wallet map") + .insert(seed_hash, Arc::new(RwLock::new(wallet))); + + let mut screen = WalletsBalancesScreen::new(&app_context); + screen.display_task_result(BackendTaskSuccessResult::TrackedAssetLocks { + seed_hash, + locks: Vec::new(), + }); + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 1600.0)) + .build_ui_state( + move |ui, screen: &mut WalletsBalancesScreen| { + let _runtime = &runtime; + screen.ui(ui); + }, + screen, + ); + + harness.run(); + let empty_state = harness.get_by_label("No asset locks found").rect(); + let create_button = harness.get_by_label("Create Asset Lock").rect(); + assert!( + create_button.top() > empty_state.bottom(), + "the Create Asset Lock action must render below the asset-lock empty state" + ); + }); +} + #[test] fn receive_dialog_stays_open_on_triggering_click() { with_isolated_data_dir(|| {