From 73a2274e9a3c562093649748cac9cce099994b79 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 18 Sep 2026 08:04:05 +0200 Subject: [PATCH] feat(relationships): explain the community's identifier default on the request form (#241 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #335 made a new relationship's mint default follow the working community's declared `relationshipIdentifierDefault`, but the form's identifier toggle was static ("Pairwise R-DID (recommended)" / "Your persona DID") — so when a community declares `attributed`, the toggle silently started on the persona DID while still labelling pairwise "(recommended)", with no reason given. Carry the declaration into the `NewRequest` form state (`community_default`) and surface it: - the toggle labels the persona-DID option "(your community's default)" and drops "(recommended)" from pairwise when the community prefers attributed (pairwise is then shown as "(more private)"); - a line under the toggle says why the default is where it is — "Your community publishes relationships under members' persona DIDs, so this defaults there" or "Your community recommends a pairwise DID (the default)" — and nothing extra when no community declared one. The lookup that seeded the default is refactored to return the declaration itself (`working_community_relationship_default`) rather than a bare bool, so the form gets both the default and the reason from one place. Behaviour is unchanged when no community declares a preference. Signed-off-by: Glenn Gore --- .../src/state_handler/main_page/content.rs | 8 +- .../src/state_handler/relationship_actions.rs | 104 +++++++++++------- .../main/components/relationships_panel.rs | 71 +++++++++++- openvtc/src/ui/pages/main/mod.rs | 1 + 4 files changed, 140 insertions(+), 44 deletions(-) diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 77389b1..ae73ea9 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dtg_credentials::DTGCredential; use openvtc_core::community_access::{DEFAULT_EXPIRY, DeviceGrant}; -use openvtc_core::config::account::PersonaId; +use openvtc_core::config::account::{PersonaId, RelationshipIdentifierDefault}; use openvtc_core::config::community_context::{ ContextDeletion, ContextDeletionPreview, ContextOption, PersonaTakenAlong, }; @@ -2490,6 +2490,12 @@ pub enum RelationshipsMode { reason_input: String, /// Whether to generate a random relationship DID (privacy) generate_r_did: bool, + /// The working community's declared `relationshipIdentifierDefault`, if + /// any — what seeded [`generate_r_did`](Self::NewRequest::generate_r_did). + /// Carried only so the form can explain *why* the toggle defaulted where + /// it did ("your community prefers …"); `None` when there is no working + /// community or it declared nothing (issue #241 follow-up). + community_default: Option, /// Which form field is currently focused (0=DID, 1=Alias, 2=Reason, 3=R-DID toggle) active_field: usize, }, diff --git a/openvtc/src/state_handler/relationship_actions.rs b/openvtc/src/state_handler/relationship_actions.rs index 8348040..4180961 100644 --- a/openvtc/src/state_handler/relationship_actions.rs +++ b/openvtc/src/state_handler/relationship_actions.rs @@ -1049,7 +1049,10 @@ fn handle_open_detail(state: &mut State, index: usize) { }; } -fn handle_start_new_request(state: &mut State, generate_r_did: bool) { +fn handle_start_new_request( + state: &mut State, + community_default: Option, +) { state.main_page.content_panel.relationships.mode = RelationshipsMode::NewRequest { did_input: String::new(), alias_input: String::new(), @@ -1061,36 +1064,36 @@ fn handle_start_new_request(state: &mut State, generate_r_did: bool) { // it costs. // // A community that declares `relationshipIdentifierDefault: attributed` - // (a public community that wants a legible graph) flips this to `false` - // — see [`default_generate_r_did`]. The user can still toggle it. - generate_r_did, + // (a public community that wants a legible graph) seeds the persona DID + // instead; the form explains that from `community_default`. The user can + // still toggle it. + generate_r_did: !matches!( + community_default, + Some(RelationshipIdentifierDefault::Attributed) + ), + community_default, active_field: 0, }; } -/// The mint default for a new relationship, seeded from the working community's -/// declared `relationshipIdentifierDefault` (issue #241). -/// -/// `Attributed` ⇒ default to the persona DID (`false`); `Pairwise`, undeclared, -/// or no working community ⇒ pairwise (`true`), the codebase-wide default. It is -/// only a *default* — the form's field-3 toggle overrides it either way. The -/// working community is the selected one, falling back to the account's default -/// working membership; in State-A there is none, so pairwise stands. -fn default_generate_r_did(config: &Config, state: &State) -> bool { - let membership = state +/// The working community's declared `relationshipIdentifierDefault`, which seeds +/// the new-relationship form's default and the "your community prefers …" hint +/// (issue #241). The working community is the selected one, falling back to the +/// account's default working membership; `None` in State-A (no working +/// community) or when the community declared nothing — both of which leave the +/// pairwise default standing. +fn working_community_relationship_default( + config: &Config, + state: &State, +) -> Option { + let (vtc, persona) = state .selected_community .clone() - .or_else(|| config.account.default_working_membership()); - let Some((vtc, persona)) = membership else { - return true; - }; - !matches!( - config - .account - .membership(&vtc, persona) - .and_then(|c| c.relationship_identifier_default), - Some(RelationshipIdentifierDefault::Attributed) - ) + .or_else(|| config.account.default_working_membership())?; + config + .account + .membership(&vtc, persona) + .and_then(|c| c.relationship_identifier_default) } fn handle_cancel_or_back(state: &mut State) { @@ -1492,7 +1495,7 @@ pub(crate) async fn dispatch( } RelationshipAction::OpenDetail(index) => handle_open_detail(state, index), RelationshipAction::StartNewRequest => { - handle_start_new_request(state, default_generate_r_did(config, state)) + handle_start_new_request(state, working_community_relationship_default(config, state)) } RelationshipAction::CancelNewRequest | RelationshipAction::Back => { handle_cancel_or_back(state) @@ -1741,30 +1744,46 @@ mod tests { (config, state) }; + // The lookup reports the community's declaration… let (config, state) = seed(Some(RelationshipIdentifierDefault::Attributed)); - assert!( - !default_generate_r_did(&config, &state), - "an attributed community defaults to the persona DID" + assert_eq!( + working_community_relationship_default(&config, &state), + Some(RelationshipIdentifierDefault::Attributed) ); + // …and it seeds the form: attributed ⇒ persona DID, and the form carries + // the declaration so it can explain the choice. + let mut s = state.clone(); + handle_start_new_request( + &mut s, + working_community_relationship_default(&config, &state), + ); + assert!(matches!( + s.main_page.content_panel.relationships.mode, + RelationshipsMode::NewRequest { + generate_r_did: false, + community_default: Some(RelationshipIdentifierDefault::Attributed), + .. + } + )); let (config, state) = seed(Some(RelationshipIdentifierDefault::Pairwise)); - assert!( - default_generate_r_did(&config, &state), - "a pairwise community keeps the pairwise default" + assert_eq!( + working_community_relationship_default(&config, &state), + Some(RelationshipIdentifierDefault::Pairwise) ); let (config, state) = seed(None); - assert!( - default_generate_r_did(&config, &state), - "an undeclared community keeps the pairwise default" + assert_eq!( + working_community_relationship_default(&config, &state), + None ); - // State-A: no working community at all → pairwise. + // State-A: no working community at all → None → pairwise stands. let config = test_config(); let state = State::default(); - assert!( - default_generate_r_did(&config, &state), - "with no working community, pairwise stands" + assert_eq!( + working_community_relationship_default(&config, &state), + None ); } @@ -2156,7 +2175,7 @@ mod tests { #[test] fn start_new_request_and_cancel_back() { let mut state = State::default(); - handle_start_new_request(&mut state, true); + handle_start_new_request(&mut state, None); // Pairwise is the default (#241) — a new request mints an R-DID unless // the operator deliberately turns it off. assert!(matches!( @@ -2175,6 +2194,7 @@ mod tests { alias_input: String::new(), reason_input: String::new(), generate_r_did: true, + community_default: None, active_field: 2, }, RelationshipsMode::Detail { @@ -2233,7 +2253,7 @@ mod tests { ]; for (field, (did, alias, reason)) in cases { let mut state = State::default(); - handle_start_new_request(&mut state, true); + handle_start_new_request(&mut state, None); let value = match field { 0 => "the-did", 1 => "the-alias", @@ -2265,7 +2285,7 @@ mod tests { #[test] fn toggle_r_did_flips_flag() { let mut state = State::default(); - handle_start_new_request(&mut state, true); + handle_start_new_request(&mut state, None); // Starts pairwise (#241), so the first toggle is the opt-out. handle_toggle_r_did(&mut state); assert!(matches!( diff --git a/openvtc/src/ui/pages/main/components/relationships_panel.rs b/openvtc/src/ui/pages/main/components/relationships_panel.rs index 8bbb354..950aff3 100644 --- a/openvtc/src/ui/pages/main/components/relationships_panel.rs +++ b/openvtc/src/ui/pages/main/components/relationships_panel.rs @@ -7,6 +7,7 @@ use crate::state_handler::{ main_page::content::{ContentPanelState, RelationshipsMode, RelationshipsState}, state::ConnectionState, }; +use openvtc_core::config::account::RelationshipIdentifierDefault; use openvtc_core::display::display_identifier; use ratatui::{ style::{Style, Stylize}, @@ -46,12 +47,14 @@ pub fn render(state: &RelationshipsState) -> Vec> { alias_input, reason_input, generate_r_did, + community_default, active_field, } => render_form( did_input, alias_input, reason_input, *generate_r_did, + *community_default, *active_field, ), RelationshipsMode::List => render_list(state), @@ -419,6 +422,7 @@ fn render_form( alias_input: &str, reason_input: &str, generate_r_did: bool, + community_default: Option, active_field: usize, ) -> Vec> { let mut lines = vec![Line::from("")]; @@ -469,8 +473,19 @@ fn render_form( } else { Style::new().fg(COLOR_DARK_GRAY) }; + // "(recommended)" is the general privacy recommendation (pairwise), except + // where the working community declares it wants attributed edges — there the + // persona DID is the community's default, and the hint below says why. + let community_prefers_attributed = + community_default == Some(RelationshipIdentifierDefault::Attributed); let toggle_value = if generate_r_did { - "Pairwise R-DID (recommended)" + if community_prefers_attributed { + "Pairwise R-DID (more private)" + } else { + "Pairwise R-DID (recommended)" + } + } else if community_prefers_attributed { + "Your persona DID (your community's default)" } else { "Your persona DID" }; @@ -480,6 +495,28 @@ fn render_form( Span::styled(toggle_value.to_string(), value_style), ])); + // Explain where the default came from, when the community declared one — so a + // toggle that started on the persona DID does not look like an odd choice + // (issue #241 follow-up). + match community_default { + Some(RelationshipIdentifierDefault::Attributed) => { + lines.push( + Line::from( + " Your community publishes relationships under members' persona DIDs, so \ + this defaults there.", + ) + .fg(COLOR_DARK_GRAY), + ); + } + Some(RelationshipIdentifierDefault::Pairwise) => { + lines.push( + Line::from(" Your community recommends a pairwise DID (the default).") + .fg(COLOR_DARK_GRAY), + ); + } + None => {} + } + // Spell out the trade-off at the point of choice — the cost of reusing the // persona DID is invisible at the moment the decision is made (#241). lines.push(Line::from("")); @@ -542,6 +579,38 @@ mod tests { .collect() } + /// The new-relationship form explains where its identifier default came + /// from when the working community declared one (issue #241 follow-up), and + /// says nothing extra when it did not. + #[test] + fn the_form_explains_a_community_relationship_default() { + let rendered = |cd: Option| { + let generate_r_did = !matches!(cd, Some(RelationshipIdentifierDefault::Attributed)); + text(&render_form("", "", "", generate_r_did, cd, 0)).join("\n") + }; + + let attributed = rendered(Some(RelationshipIdentifierDefault::Attributed)); + assert!( + attributed.contains("Your persona DID (your community's default)"), + "{attributed}" + ); + assert!( + attributed.contains("publishes relationships under members' persona DIDs"), + "{attributed}" + ); + + let pairwise = rendered(Some(RelationshipIdentifierDefault::Pairwise)); + assert!( + pairwise.contains("Your community recommends a pairwise DID"), + "{pairwise}" + ); + + let none = rendered(None); + assert!(none.contains("Pairwise R-DID (recommended)"), "{none}"); + assert!(!none.contains("your community's default"), "{none}"); + assert!(!none.contains("Your community"), "{none}"); + } + fn raw() -> RawCredential { RawCredential::Value(Arc::new(serde_json::json!({}))) } diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 3d68a67..30784c7 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -1510,6 +1510,7 @@ impl MainPage { reason_input, generate_r_did, active_field, + .. } => { // Form input handling let active_field = *active_field;