From 7ee5697dcd10159e7ba0c34e593a060965a52e5c Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 18 Sep 2026 09:52:45 +0200 Subject: [PATCH] feat(join): offer every way in to a vetting community at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The join flow already asked a community what it required before anything about the applicant was sent. What it did with the answer was print the requirements and leave the applicant to work out what to do with them — so "this community vets" read as "go and get vetted", even to someone holding an invitation that would have admitted them without it. The vetting page now lists the ways in as a chooser: present an invitation, be vetted, or send an open request. Each row says whether it can be taken and, when it cannot, why — a blocked route keeps its place and reads dim with the reason where its detail would be, because *why not* is the question the page exists to answer. Taking a blocked route says the same thing rather than doing nothing (issue #29's rule). Which way in is open depends on the community's manifest *and* on what this account holds, so the community's usable invitations are collected before the routes are drawn rather than two steps later on the identity step. `ensure_invitations` does it once per community, keyed on a marker rather than on the list being empty — holding none is an answer. Two shapes the manifest distinguishes and the page now does too: an invitation marked `required` is asked for on top of the statements, not instead of them, so it is left to the requirement bullets and the vetting row's detail rather than offered as a row that reads like a way around vetting; and a community that admits nobody by invitation says so, even to someone holding one. Also: a first join could not ask the community anything at all. The State-A loop has no inbound arm, so there was no reply to hear — and the flow fell through to an open request in silence, which a vetting community refers to its moderators. It now says the community was not asked and why, and withholds "ask again" there rather than binding a key that could only ever fail. Signed-off-by: Glenn Gore --- CHANGELOG.md | 38 ++ openvtc/src/state_handler/actions/mod.rs | 10 +- openvtc/src/state_handler/join.rs | 106 +++- openvtc/src/state_handler/join_flow.rs | 581 ++++++++++++++++-- openvtc/src/state_handler/mod.rs | 5 +- openvtc/src/state_handler/runtime_actions.rs | 3 +- .../pages/join_flow/vetting_requirements.rs | 379 +++++++----- 7 files changed, 919 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f230b..8d29800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Joining a community that vets now offers every way in at once.** The join + flow already asked the community what it required before anything about the + applicant was sent; what it did with the answer was print the requirements + and leave the applicant to work out what to do with them. It now lists the + ways in — present an invitation, be vetted, or send an open request — each + said to be available or not, and why, with the cursor on the first one that + can actually be taken. + + Which way in is open is a fact about the community *and* about this account, + so the invitations held for the community are collected before the page is + drawn rather than two steps later on the identity step. Someone holding an + invitation used to have no way of learning, on the screen that told them the + community vets, that they did not need to be vetted at all. + + A route that cannot be taken keeps its row and reads dim with the reason in + place of its detail — "you hold none for this community", "this community + admits nobody by invitation" — because *why not* is the question the page + exists to answer. Taking one says the same thing rather than doing nothing. + + An invitation the community marks `required` is not offered as its own row: + it is asked for on top of the statements, not instead of them, so it stays + with the requirements and in the vetting row's detail. An open request admits + that gathered statements ride along with it, which the submit does anyway. + +### Fixed + +- **A first join no longer sends an open request without asking the community + anything.** Before a community is joined there is no inbound arm to hear a + reply on, so the manifest request the join flow makes from the Communities + panel could not be made at all — and the flow fell through to an open + request in silence. A community that vets refers such a request to its + moderators, and the applicant never learned there was a way in they could + have taken. The flow now says the community was not asked and why, and + offers to join anyway; "ask again" is withheld there rather than offered as + a key that could only ever fail. + +### Changed + - **The vetting wire types are the published, generated ones.** `vta-sdk` 0.37 deleted its hand-written copies of the peer-vetting payloads and re-exports the generated `trust_tasks_rs::specs` types in their place, so what this diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index bf1efa4..4d226ff 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -479,6 +479,9 @@ pub enum Action { /// Commit the highlighted context and launch the join in it. JoinContextChoose, + /// Vetting page: take the highlighted way in. A route the page drew as + /// blocked answers with why instead. + JoinVettingTake, /// Vetting page: start the application to the community (or continue the /// one under way) and go to it on the Vetting page. JoinVettingApply, @@ -488,9 +491,10 @@ pub enum Action { JoinVettingJoin, /// Vetting page: ask the community for its requirements again. JoinVettingAskAgain, - /// Vetting page: move the focus (`true` = next field). - JoinVettingField(bool), - /// Vetting page: cycle the focused choice (`true` = forwards). + /// Vetting page: move the highlight down the rows (`true` = next). + JoinVettingRow(bool), + /// Vetting page: cycle the focused "applying as" choice (`true` = + /// forwards). Inert while the highlight is on a route. JoinVettingCycle(bool), /// Issue this Active membership's reciprocal VMC (member → community) and diff --git a/openvtc/src/state_handler/join.rs b/openvtc/src/state_handler/join.rs index 53a736f..435339b 100644 --- a/openvtc/src/state_handler/join.rs +++ b/openvtc/src/state_handler/join.rs @@ -36,9 +36,9 @@ pub enum JoinPage { /// Automated mint + join sequence progress / result. Progress, /// A community that vets its members: what it requires, in plain words, - /// before anything about the applicant is sent — with the persona's - /// application if there is one, and the ways on (apply, join anyway, - /// cancel). Also the page shown while the community is being asked. + /// before anything about the applicant is sent — and the ways in it leaves + /// open, each said to be available or not and why. Also the page shown + /// while the community is being asked. Vetting, } @@ -61,12 +61,58 @@ pub enum VettingPhase { /// answer, draws the page while it waits. Asking, /// Its requirements could not be learned; why. - Unknown { reason: String }, + Unknown { + reason: String, + /// Whether asking again can work from here. False in the State-A + /// degraded loop, which has no inbound arm to hear an answer on — there + /// the offer would be a key that can only ever fail. + can_retry: bool, + }, /// It vets, and this is what it asks. Known(Box), } -/// A vetting community's requirements and where this persona stands. +/// A way in to a community that vets. +/// +/// The join flow offers all of them at once, available or not, rather than +/// walking one path and leaving the others to be discovered: which way in is +/// open depends on the community's manifest *and* on what this account already +/// holds, and neither is knowable before the community has been asked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JoinRoute { + /// Present an invitation (VIC) this account already holds. + Invitation, + /// Be vetted: start an application, carry one on, or present the statements + /// of one that already meets the requirements. + Vetting, + /// Submit with neither; the community's moderators decide. + OpenRequest, +} + +/// One way in, as the routes list shows it. +#[derive(Clone, Debug)] +pub struct RouteOption { + pub route: JoinRoute, + /// The row's label. + pub label: String, + /// What taking it does, or where it stands, in a few words. + pub detail: String, + /// `None` when it can be taken now; otherwise why it cannot. A blocked + /// route is still listed — "you cannot use an invitation because you hold + /// none" is the answer to "what are my options", and hiding the row leaves + /// the question open. + pub blocked: Option, +} + +impl RouteOption { + #[must_use] + pub fn available(&self) -> bool { + self.blocked.is_none() + } +} + +/// A vetting community's requirements, the ways in, and where this persona +/// stands. #[derive(Clone, Debug, Default)] pub struct KnownVetting { /// What it requires, one sentence each. @@ -75,14 +121,48 @@ pub struct KnownVetting { pub governance_url: Option, /// Our application to it, when there is one. pub application: Option, + /// The ways in, in the order they are offered. + pub routes: Vec, /// Personas a new application can be made as. pub personas: Vec, pub persona_index: usize, /// Where a new application's face is worn. pub context_options: Vec, pub context_index: usize, - /// 0 = persona, 1 = context. - pub field: usize, + /// The highlighted row: a route, then the two "applying as" selectors when + /// they are shown. See [`selector`](Self::selector). + pub row: usize, +} + +impl KnownVetting { + /// Whether the "Apply as" / "Context" selectors are shown. They choose what + /// a *new* application is made as, so an application already under way has + /// answered them — its persona is fixed for its whole life. + #[must_use] + pub fn shows_selectors(&self) -> bool { + self.application.is_none() + } + + /// Total rows the cursor moves over. + #[must_use] + pub fn row_count(&self) -> usize { + self.routes.len() + usize::from(self.shows_selectors()) * 2 + } + + /// The highlighted route, when the cursor is on one. + #[must_use] + pub fn selected_route(&self) -> Option<&RouteOption> { + self.routes.get(self.row) + } + + /// Which selector the cursor is on: `0` the persona, `1` the context. + #[must_use] + pub fn selector(&self) -> Option { + if !self.shows_selectors() { + return None; + } + self.row.checked_sub(self.routes.len()).filter(|i| *i < 2) + } } /// A persona an application can be made as. @@ -226,9 +306,17 @@ pub struct JoinState { /// had one; re-pasting a VIC (`JoinPasteVic`) flips it back to `false`. pub vic_cleared: bool, /// All valid invitations (VICs) for the community being joined, across - /// personas — collected once after the VTC DID is entered and used to badge - /// each persona with its count on the identity step. + /// personas — collected once the VTC DID is known, and used to badge each + /// persona with its count on the identity step *and* to say on the vetting + /// page whether an invitation is one of this account's ways in. pub available_vics: Vec, + /// The community [`available_vics`](Self::available_vics) was collected + /// for. The collection is a vault listing plus a fetch per descriptor, and + /// several paths now need it (the vetting page before the routes are drawn, + /// the identity step after), so it is done once per community rather than + /// once per caller. An empty result is a legitimate answer, so the marker — + /// not the emptiness of the list — is what says it has been done. + pub invitations_for: Option, /// The chosen persona's invitations, listed on the /// [`InvitationChoice`](JoinPage::InvitationChoice) page (a subset of /// [`available_vics`](Self::available_vics) bound to that persona). diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index 2cd9663..97da9ee 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -32,7 +32,11 @@ use openvtc_core::vetting::queries::{CommunityAnswer, CommunityQuery, QueryKind} use openvtc_core::vetting::wire; use tokio::sync::{broadcast, mpsc::UnboundedReceiver}; use tracing::debug; -use vta_sdk::{client::VtaClient, protocols::did_management::create::WebvhPathMode}; +use vta_sdk::{ + client::VtaClient, + protocols::did_management::create::WebvhPathMode, + protocols::vetting::{VettingRequirements, VettingRequirementsInvitation}, +}; use crate::{ Interrupted, @@ -40,8 +44,9 @@ use crate::{ StateHandler, actions::Action, join::{ - ApplyAs, AvailableVic, IdentityPick, JoinApplication, JoinPage, JoinState, - JoinVettingView, KnownVetting, PersonaOption, PresentedInvitation, VettingPhase, + ApplyAs, AvailableVic, IdentityPick, JoinApplication, JoinPage, JoinRoute, JoinState, + JoinVettingView, KnownVetting, PersonaOption, PresentedInvitation, RouteOption, + VettingPhase, }, main_page::content::{VicLifecycle, VicSummary}, main_page::{sanitize_display, shorten_did}, @@ -134,10 +139,145 @@ fn application_contexts( community_context::context_options(&config.account, record, &suggested_context(config, vtc_did)) } +/// The invitation row's detail: how many are held, and when the longest-lived +/// one runs out. Dates are shown as the credential writes them — an invitation +/// is someone else's document, and reformatting its `validUntil` would put a +/// date on screen that is not in the thing being presented. +fn invitations_held(invitations: &[AvailableVic]) -> String { + let n = invitations.len(); + let latest = invitations + .iter() + .map(|v| v.valid_until.as_str()) + .filter(|u| !u.is_empty()) + .max(); + match latest { + Some(until) => format!("{n} held, valid until {}", sanitize_display(until, 40)), + None => format!("{n} held"), + } +} + +/// The ways in to `vtc_did`, in the order they are offered. +/// +/// The order is "what actually admits you soonest": an invitation in hand +/// short-circuits vetting, a satisfied application is next, and an open request +/// — which only puts the applicant in front of the community's moderators — is +/// always last. Blocked routes keep their place rather than sinking to the +/// bottom, so the list reads the same way twice running. +fn build_routes( + community: &str, + requirements: &VettingRequirements, + application: Option<&JoinApplication>, + personas: &[ApplyAs], + invitations: &[AvailableVic], +) -> Vec { + let held = invitations.len(); + let mut routes = Vec::new(); + + // An invitation the community requires is not an alternative to being + // vetted — it is a second thing asked for on top. Offering it as its own + // row would read as a way around the statements, so it is left to the + // requirement bullets (`describe_requirements` already names it) and to the + // vetting row's detail. + let invitation_required = matches!( + requirements.invitation, + Some(VettingRequirementsInvitation::Required) + ); + if !invitation_required { + let refuses = matches!( + requirements.invitation, + Some(VettingRequirementsInvitation::None) + ); + routes.push(RouteOption { + route: JoinRoute::Invitation, + label: "Use an invitation".to_string(), + detail: if held == 0 { + "admits you without being vetted".to_string() + } else { + invitations_held(invitations) + }, + blocked: if refuses { + Some(format!("{community} does not admit anyone by invitation")) + } else if held == 0 { + Some( + "none held for this community — paste one on the next step if you have one \ + the vault has not seen" + .to_string(), + ) + } else { + None + }, + }); + } + + let (detail, blocked) = match application { + Some(app) if app.satisfied => ( + format!( + "{} statement{} ready to present", + app.statements, + if app.statements == 1 { "" } else { "s" } + ), + None, + ), + Some(app) => ( + app.progress + .clone() + .unwrap_or_else(|| format!("under way as {}", app.persona_label)), + None, + ), + None if personas.is_empty() => ( + "no application yet".to_string(), + Some( + "applying needs a persona — every card is signed by the DID you join with. \ + Create one under My Identity." + .to_string(), + ), + ), + None => ("no application yet".to_string(), None), + }; + routes.push(RouteOption { + route: JoinRoute::Vetting, + label: match application { + Some(app) if app.satisfied => "Present your vetting statements".to_string(), + Some(_) => "Carry on with your application".to_string(), + None => "Apply for vetting".to_string(), + }, + detail: if invitation_required { + format!( + "{detail} — {community} also asks for an invitation ({} held)", + held + ) + } else { + detail + }, + blocked, + }); + + routes.push(RouteOption { + route: JoinRoute::OpenRequest, + label: "Send an open request".to_string(), + // Statements ride with the request whenever the persona joining has + // gathered any — the submit attaches them without asking. Saying so + // here keeps this row from reading as a way to hold them back. + detail: match application.filter(|a| a.statements > 0) { + Some(_) => { + format!("{community} refers it to its moderators — your statements go with it") + } + None => format!("{community} refers it to its moderators to decide"), + }, + blocked: None, + }); + routes +} + /// What the join flow shows for `vtc_did`, when the book knows it vets. +/// +/// `invitations` are this community's usable VICs, already collected — the +/// routes cannot be drawn without them, because whether an invitation is a way +/// in is a fact about this account, not about the community. pub(crate) fn vetting_view( config: &Config, vtc_did: &str, + invitations: &[AvailableVic], now: chrono::DateTime, ) -> Option { let book = &config.private.vetting; @@ -180,9 +320,20 @@ pub(crate) fn vetting_view( personas.sort_by(|a, b| a.label.cmp(&b.label)); let context_options = application_contexts(config, vtc_did, personas.first().map(|p| p.persona)); + let name = vetting_actions::community_display(config, vtc_did); + let routes = build_routes( + &name, + &criterion.requirements, + application.as_ref(), + &personas, + invitations, + ); + // Open on the first route that can actually be taken, so Enter on arrival + // does the most useful thing rather than landing on a row that refuses. + let row = routes.iter().position(RouteOption::available).unwrap_or(0); Some(JoinVettingView { community: vtc_did.to_string(), - name: vetting_actions::community_display(config, vtc_did), + name, accent: book.branding(vtc_did).and_then(|b| b.accent_rgb()), phase: VettingPhase::Known(Box::new(KnownVetting { requirements: describe_requirements(&criterion.requirements) @@ -195,11 +346,12 @@ pub(crate) fn vetting_view( .as_deref() .map(|u| sanitize_display(u, 300)), application, + routes, personas, persona_index: 0, context_options, context_index: 0, - field: 0, + row, })), }) } @@ -207,7 +359,7 @@ pub(crate) fn vetting_view( /// Open the vetting page for `vtc_did` when the book knows it vets. Returns /// whether it did. fn show_vetting(state: &mut State, config: &Config, vtc_did: &str) -> bool { - let Some(view) = vetting_view(config, vtc_did, Utc::now()) else { + let Some(view) = vetting_view(config, vtc_did, &state.join.available_vics, Utc::now()) else { return false; }; state.join.pending_vtc = Some(vtc_did.to_string()); @@ -218,7 +370,17 @@ fn show_vetting(state: &mut State, config: &Config, vtc_did: &str) -> bool { } /// Open the vetting page saying the requirements could not be learned. -fn show_unknown(state: &mut State, config: &Config, vtc_did: &str, reason: String) { +/// +/// `can_retry` is whether the calling loop can hear an answer at all: the +/// State-A loop cannot, and offering it "ask again" there would be a key that +/// can only ever fail. +fn show_unknown( + state: &mut State, + config: &Config, + vtc_did: &str, + reason: String, + can_retry: bool, +) { state.join.pending_vtc = Some(vtc_did.to_string()); state.join.vetting = Some(JoinVettingView { community: vtc_did.to_string(), @@ -230,6 +392,7 @@ fn show_unknown(state: &mut State, config: &Config, vtc_did: &str, reason: Strin .and_then(|b| b.accent_rgb()), phase: VettingPhase::Unknown { reason: sanitize_display(&reason, 300), + can_retry, }, }); state.join.page = JoinPage::Vetting; @@ -295,7 +458,7 @@ async fn ask_requirements( }) } Err(reason) => { - show_unknown(state, config, vtc_did, reason); + show_unknown(state, config, vtc_did, reason, true); Err(()) } } @@ -320,22 +483,29 @@ fn satisfied_application_persona(state: &State) -> Option { } } +/// Cycle the value of the "applying as" selector the cursor is on. A no-op +/// while the cursor is on a route — ←/→ there would silently change a choice +/// that is not on screen. fn cycle_vetting_choice(config: &Config, vtc_did: &str, known: &mut KnownVetting, forward: bool) { let turn = |i: usize, n: usize| match n { 0 => 0, n if forward => (i + 1) % n, n => (i + n - 1) % n, }; - if known.field == 0 { - known.persona_index = turn(known.persona_index, known.personas.len()); - known.context_options = application_contexts( - config, - vtc_did, - known.personas.get(known.persona_index).map(|p| p.persona), - ); - known.context_index = 0; - } else { - known.context_index = turn(known.context_index, known.context_options.len()); + match known.selector() { + Some(0) => { + known.persona_index = turn(known.persona_index, known.personas.len()); + known.context_options = application_contexts( + config, + vtc_did, + known.personas.get(known.persona_index).map(|p| p.persona), + ); + known.context_index = 0; + } + Some(_) => { + known.context_index = turn(known.context_index, known.context_options.len()); + } + None => {} } } @@ -462,9 +632,12 @@ impl StateHandler { JoinEntry::Resume { .. } => true, }; if let JoinEntry::Resume { vtc_did, outcome } = entry { + // `reset` above cleared what the asking pass collected, and the + // routes cannot be drawn without it. + ensure_invitations(state, admin_vta, &vtc_did).await; match outcome { RequirementsOutcome::Unanswered(reason) => { - show_unknown(state, config, &vtc_did, reason); + show_unknown(state, config, &vtc_did, reason, true); } outcome => { let shown = matches!(outcome, RequirementsOutcome::Learned) @@ -593,6 +766,11 @@ impl StateHandler { } else { vtc_did }; + // The invitations before the routes: whether presenting + // one is a way in to this community is a fact about + // this account, and the vetting page cannot say what + // the options are without it. + ensure_invitations(state, admin_vta, &vtc_did).await; // Peer identity vetting: a community that vets says what // it requires before anything about the applicant is sent // (vetting-process.md §6.1). What the book already knows @@ -617,7 +795,25 @@ impl StateHandler { return Ok(JoinExit::AwaitRequirements(awaiting)); } } - _ => { + None => { + // Nothing is known and this loop cannot hear an + // answer, so the community is not asked. Say so + // rather than sending an open request on the + // quiet: a community that vets would refer that + // request to its moderators, and the applicant + // would never learn there was a way in they + // could have taken. + show_unknown( + state, + config, + &vtc_did, + "OpenVTC cannot hear its answer until you belong to a \ + community, so it was not asked — this is your first join" + .to_string(), + false, + ); + } + Some(false) => { if let Some(interrupted) = self .continue_join( vtc_did, @@ -824,13 +1020,13 @@ impl StateHandler { return Ok(JoinExit::Exit(interrupted)); } } - Action::JoinVettingField(forward) => { + Action::JoinVettingRow(forward) => { if let Some(known) = known_vetting(state) { - let fields = if known.application.is_some() { 1 } else { 2 }; - known.field = if forward { - (known.field + 1) % fields + let rows = known.row_count().max(1); + known.row = if forward { + (known.row + 1) % rows } else { - (known.field + fields - 1) % fields + (known.row + rows - 1) % rows }; } } @@ -840,33 +1036,41 @@ impl StateHandler { cycle_vetting_choice(config, &vtc_did, known, forward); } } - Action::JoinVettingApply => { - let Some(vtc_did) = state.join.pending_vtc.clone() else { + Action::JoinVettingTake => { + // Enter on the routes list. A route the page drew as + // blocked must say why rather than do nothing — a + // keypress that cannot proceed reads as a frozen + // screen (issue #29). The two "applying as" selectors + // belong to the vetting route, so Enter on one takes + // that route. + let Some(known) = known_vetting(state) else { continue; }; - state.join.messages.clear(); - match apply_for_vetting(state, config, profile, &vtc_did) { - Ok(()) => { - state.active_page = ActivePage::Main; - return Ok(JoinExit::Returned(None)); + let route = match known.selected_route() { + Some(option) if !option.available() => { + let why = option.blocked.clone().unwrap_or_default(); + let label = option.label.clone(); + state.join.messages.clear(); + state.join.messages.push(MessageType::Error(format!( + "{label}: {why}" + ))); + let _ = self.state_tx.send(state.clone()); + continue; } - Err(why) => state.join.messages.push(MessageType::Error(why)), - } - } - Action::JoinVettingJoin => { + Some(option) => option.route, + // On a selector. + None => JoinRoute::Vetting, + }; let Some(vtc_did) = state.join.pending_vtc.clone() else { continue; }; state.join.messages.clear(); - // An application that meets the requirements joins as - // its own persona, so its statements are presented. - match satisfied_application_persona(state) { - Some(persona_id) => { - state.join.available_vics = - collect_available_vics(state, admin_vta, &vtc_did).await; - open_invitation_choice(state, config, persona_id); - } - None => { + match route { + // The invitation is chosen further on, once the + // persona it is bound to is known: the invitation + // step lists this community's VICs for that + // persona and is where one is picked. + JoinRoute::Invitation => { if let Some(interrupted) = self .continue_join( vtc_did, @@ -883,6 +1087,74 @@ impl StateHandler { return Ok(JoinExit::Exit(interrupted)); } } + // An application that already meets the + // requirements is taken by joining — that is what + // presents its statements. One that does not is + // taken by working on it. + JoinRoute::Vetting + if satisfied_application_persona(state).is_none() => + { + match apply_for_vetting(state, config, profile, &vtc_did) { + Ok(()) => { + state.active_page = ActivePage::Main; + return Ok(JoinExit::Returned(None)); + } + Err(why) => { + state.join.messages.push(MessageType::Error(why)); + } + } + } + JoinRoute::Vetting | JoinRoute::OpenRequest => { + if let Some(interrupted) = self + .take_join_route( + vtc_did, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + { + return Ok(JoinExit::Exit(interrupted)); + } + } + } + } + Action::JoinVettingApply => { + let Some(vtc_did) = state.join.pending_vtc.clone() else { + continue; + }; + state.join.messages.clear(); + match apply_for_vetting(state, config, profile, &vtc_did) { + Ok(()) => { + state.active_page = ActivePage::Main; + return Ok(JoinExit::Returned(None)); + } + Err(why) => state.join.messages.push(MessageType::Error(why)), + } + } + Action::JoinVettingJoin => { + let Some(vtc_did) = state.join.pending_vtc.clone() else { + continue; + }; + state.join.messages.clear(); + if let Some(interrupted) = self + .take_join_route( + vtc_did, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + { + return Ok(JoinExit::Exit(interrupted)); } } Action::JoinVettingAskAgain => { @@ -908,6 +1180,47 @@ impl StateHandler { } } + /// Go on with a join from the vetting page, presenting the statements of an + /// application that already meets the requirements when there is one. + /// + /// Such an application fixes the persona — its statements name the DID they + /// were gathered for — so the identity step has nothing left to ask and the + /// flow goes straight to the invitation choice. Anything else falls through + /// to the ordinary [`continue_join`](Self::continue_join). + #[allow(clippy::too_many_arguments)] + async fn take_join_route( + &self, + vtc_did: String, + interrupt_rx: &mut broadcast::Receiver, + state: &mut State, + tdk: &TDK, + config: &mut Config, + admin_vta: Option<&VtaClient>, + profile: &str, + messaging: Option<&Messaging>, + ) -> Option { + match satisfied_application_persona(state) { + Some(persona_id) => { + ensure_invitations(state, admin_vta, &vtc_did).await; + open_invitation_choice(state, config, persona_id); + None + } + None => { + self.continue_join( + vtc_did, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + } + } + } + /// Go on with a join once any vetting has been dealt with: collect the /// community's invitations, then choose the identity to present, or mint /// one on a first join. `Some` when the person cancelled mid-sequence. @@ -933,7 +1246,7 @@ impl StateHandler { // usable-invitation count, then let the operator pick the // identity to present (the invitation choice, if any, // follows for the chosen persona). - state.join.available_vics = collect_available_vics(state, admin_vta, &vtc_did).await; + ensure_invitations(state, admin_vta, &vtc_did).await; let options = build_persona_options(config, &state.join.available_vics); if options.is_empty() { // First join — nothing to reuse; mint a fresh identity. @@ -1236,6 +1549,22 @@ fn build_persona_options(config: &Config, vics: &[AvailableVic]) -> Vec, vtc_did: &str) { + if state.join.invitations_for.as_deref() == Some(vtc_did) { + return; + } + state.join.available_vics = collect_available_vics(state, admin_vta, vtc_did).await; + state.join.invitations_for = Some(vtc_did.to_string()); +} + async fn collect_available_vics( state: &State, admin_vta: Option<&VtaClient>, @@ -2934,19 +3263,25 @@ mod vetting_tests { fn a_vetting_community_is_explained_before_joining() { let mut config = test_config(); let now = Utc::now(); - assert!(vetting_view(&config, VTC, now).is_none(), "not known yet"); + assert!( + vetting_view(&config, VTC, &[], now).is_none(), + "not known yet" + ); config .private .vetting .learn_manifest(VTC, &manifest(false), now); - assert!(vetting_view(&config, VTC, now).is_none(), "does not vet"); + assert!( + vetting_view(&config, VTC, &[], now).is_none(), + "does not vet" + ); config .private .vetting .learn_manifest(VTC, &manifest(true), now); - let view = vetting_view(&config, VTC, now).unwrap(); + let view = vetting_view(&config, VTC, &[], now).unwrap(); let VettingPhase::Known(known) = &view.phase else { panic!("known"); }; @@ -2963,7 +3298,7 @@ mod vetting_tests { .id .clone(); config.private.vetting.adopt_known_requirements(&id); - let view = vetting_view(&config, VTC, now).unwrap(); + let view = vetting_view(&config, VTC, &[], now).unwrap(); let VettingPhase::Known(known) = &view.phase else { panic!("known"); }; @@ -2975,4 +3310,150 @@ mod vetting_tests { assert!(!app.satisfied); assert!(app.next_step.starts_with("f —"), "{}", app.next_step); } + + fn requirements(invitation: Option<&str>) -> VettingRequirements { + let mut value = serde_json::json!({ + "version": "0.1", + "statementType": vta_sdk::protocols::vetting::IDENTITY_VETTING_ENDORSEMENT_TYPE, + "minStatements": 2, + "acceptedMethods": ["inPerson"], + "eligibleVetters": { "role": "vetter" } + }); + if let Some(invitation) = invitation { + value["invitation"] = serde_json::json!(invitation); + } + serde_json::from_value(value).unwrap() + } + + fn a_persona() -> ApplyAs { + ApplyAs { + persona: PersonaId::new(), + label: "alice".into(), + did: "did:webvh:alice".into(), + } + } + + fn a_vic() -> AvailableVic { + AvailableVic { + id: "urn:uuid:vic".into(), + subject: None, + valid_from: String::new(), + valid_until: "2026-12-01T00:00:00Z".into(), + body: serde_json::Value::Null, + } + } + + fn option_for(routes: &[RouteOption], route: JoinRoute) -> Option<&RouteOption> { + routes.iter().find(|r| r.route == route) + } + + /// The whole point of the routes list: an invitation in hand is a way in + /// that does not go through vetting, and it is offered first. + #[test] + fn an_invitation_in_hand_is_the_first_way_in() { + let personas = [a_persona()]; + let held = [a_vic()]; + let routes = build_routes("Kernel", &requirements(None), None, &personas, &held); + let known = KnownVetting { + routes: routes.clone(), + ..KnownVetting::default() + }; + assert_eq!( + known.selected_route().map(|r| r.route), + Some(JoinRoute::Invitation) + ); + let invitation = option_for(&routes, JoinRoute::Invitation).unwrap(); + assert!(invitation.available()); + assert!(invitation.detail.contains("1 held")); + assert!(invitation.detail.contains("2026-12-01")); + } + + /// Holding none is an answer, not a reason to drop the row — "why can I + /// not use an invitation?" is what the page exists to settle. + #[test] + fn holding_no_invitation_keeps_the_row_and_gives_the_reason() { + let routes = build_routes("Kernel", &requirements(None), None, &[a_persona()], &[]); + let invitation = option_for(&routes, JoinRoute::Invitation).unwrap(); + assert!(!invitation.available()); + assert!(invitation.blocked.as_deref().unwrap().contains("none held")); + } + + /// An invitation the community *requires* is asked for on top of the + /// statements, not instead of them. Offering it as its own row would read + /// as a way around being vetted. + #[test] + fn a_required_invitation_is_not_offered_as_a_way_around_vetting() { + let routes = build_routes( + "Kernel", + &requirements(Some("required")), + None, + &[a_persona()], + &[a_vic()], + ); + assert!(option_for(&routes, JoinRoute::Invitation).is_none()); + let vetting = option_for(&routes, JoinRoute::Vetting).unwrap(); + assert!(vetting.detail.contains("also asks for an invitation")); + assert!(vetting.detail.contains("1 held")); + } + + /// A community that admits nobody by invitation says so, even to someone + /// holding one — otherwise the row is an offer that cannot be taken. + #[test] + fn a_community_that_takes_no_invitations_says_so() { + let routes = build_routes( + "Kernel", + &requirements(Some("none")), + None, + &[a_persona()], + &[a_vic()], + ); + let invitation = option_for(&routes, JoinRoute::Invitation).unwrap(); + assert!(!invitation.available()); + assert!( + invitation + .blocked + .as_deref() + .unwrap() + .contains("does not admit anyone by invitation") + ); + } + + /// Applying signs cards with a persona, so with none there is nothing to + /// apply as. The open request still stands: it mints one on the way. + #[test] + fn applying_needs_a_persona_but_an_open_request_does_not() { + let routes = build_routes("Kernel", &requirements(None), None, &[], &[]); + assert!(!option_for(&routes, JoinRoute::Vetting).unwrap().available()); + assert!( + option_for(&routes, JoinRoute::OpenRequest) + .unwrap() + .available() + ); + } + + /// Statements ride with an open request whenever the joining persona has + /// gathered any, so the row must not read as a way to hold them back. + #[test] + fn an_open_request_admits_that_statements_go_with_it() { + let application = JoinApplication { + id: "a1".into(), + persona: PersonaId::new(), + persona_label: "alice".into(), + statements: 2, + progress: Some("2 counted".into()), + next_step: "join".into(), + satisfied: true, + }; + let routes = build_routes( + "Kernel", + &requirements(None), + Some(&application), + &[a_persona()], + &[], + ); + let open = option_for(&routes, JoinRoute::OpenRequest).unwrap(); + assert!(open.detail.contains("your statements go with it")); + let vetting = option_for(&routes, JoinRoute::Vetting).unwrap(); + assert_eq!(vetting.label, "Present your vetting statements"); + } } diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 3ab6cf0..7a25b24 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -2633,8 +2633,9 @@ impl StateHandler { Action::JoinReuseConfirm | Action::JoinReuseCancel | Action::JoinInvitationSelect(..) | Action::JoinInvitationChoose | Action::JoinContextSelect(..) | Action::JoinContextSlug(..) | - Action::JoinContextChoose | Action::JoinVettingApply | Action::JoinVettingJoin | - Action::JoinVettingAskAgain | Action::JoinVettingField(..) | + Action::JoinContextChoose | Action::JoinVettingTake | Action::JoinVettingApply | + Action::JoinVettingJoin | + Action::JoinVettingAskAgain | Action::JoinVettingRow(..) | Action::JoinVettingCycle(..) | Action::JoinCancel | Action::JoinPasteVic(..) | Action::JoinPasteFromClipboard | Action::JoinClearVic | Action::ImportConfig(..) | Action::SetProtection(..) | diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index b66549d..fd3b962 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -1164,10 +1164,11 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha | Action::JoinContextSelect(..) | Action::JoinContextSlug(..) | Action::JoinContextChoose + | Action::JoinVettingTake | Action::JoinVettingApply | Action::JoinVettingJoin | Action::JoinVettingAskAgain - | Action::JoinVettingField(..) + | Action::JoinVettingRow(..) | Action::JoinVettingCycle(..) | Action::JoinCancel | Action::JoinPasteVic(..) diff --git a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs index 07b106f..d4bec63 100644 --- a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs +++ b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs @@ -1,10 +1,16 @@ -//! Join flow — what a vetting community asks of the people who join. +//! Join flow — what a vetting community asks, and the ways in it leaves open. //! //! Shown after the community's DID when that community vets its members //! (`docs/design/vetting-process.md` §6.1, §12.3). It says in plain words what -//! the community requires before anything about the applicant is sent. It -//! shows this persona's application if there is one, and offers the ways on: -//! apply (or continue), join anyway, or cancel. +//! the community requires before anything about the applicant is sent, and +//! then lists every way in at once — present an invitation, be vetted, or send +//! an open request — each said to be available or not, and why. +//! +//! Listing them together is the point. Which way in is open depends on the +//! community's manifest *and* on what this account already holds, and neither +//! is knowable before the community has been asked; walking one path and +//! leaving the rest to be found later is how an applicant ends up sending an +//! open request while holding an invitation that would have admitted them. //! //! While the community is still being asked, this page is drawn by the runtime //! loop rather than the join flow, because that loop is the one that hears the @@ -31,12 +37,16 @@ use ratatui::{ use crate::state_handler::{ actions::Action, - join::{JoinState, JoinVettingView, VettingPhase}, + join::{JoinState, JoinVettingView, KnownVetting, VettingPhase}, setup_sequence::MessageType, }; use crate::ui::pages::join_flow::JoinFlow; use crate::ui::pages::main::components::vetting_panel::accent_swatch; +/// Width of the routes list's label column, so the details line up under each +/// other rather than under whichever label happened to be longest. +const LABEL_WIDTH: usize = 30; + #[derive(Clone, Debug, Default)] pub struct VettingPage; @@ -51,27 +61,23 @@ impl VettingPage { } return; }; - let satisfied = matches!( - &view.phase, - VettingPhase::Known(known) if known.application.as_ref().is_some_and(|a| a.satisfied) - ); let action = match (&view.phase, key.code) { (_, KeyCode::F(10)) => Action::Exit, (_, KeyCode::Esc) => Action::JoinCancel, (_, KeyCode::Char('j' | 'J')) => Action::JoinVettingJoin, - (VettingPhase::Unknown { .. }, KeyCode::Enter | KeyCode::Char('r' | 'R')) => { - Action::JoinVettingAskAgain - } - (VettingPhase::Known(_), KeyCode::Enter) if satisfied => Action::JoinVettingJoin, - (VettingPhase::Known(_), KeyCode::Enter | KeyCode::Char('a' | 'A')) => { - Action::JoinVettingApply - } + // Asking again needs a loop that can hear the answer. The State-A + // loop cannot, so there the key is not offered at all rather than + // offered and silently ineffective. + ( + VettingPhase::Unknown { can_retry, .. }, + KeyCode::Enter | KeyCode::Char('r' | 'R'), + ) if *can_retry => Action::JoinVettingAskAgain, + (VettingPhase::Known(_), KeyCode::Enter) => Action::JoinVettingTake, + (VettingPhase::Known(_), KeyCode::Char('a' | 'A')) => Action::JoinVettingApply, (VettingPhase::Known(_), KeyCode::Up | KeyCode::BackTab) => { - Action::JoinVettingField(false) - } - (VettingPhase::Known(_), KeyCode::Down | KeyCode::Tab) => { - Action::JoinVettingField(true) + Action::JoinVettingRow(false) } + (VettingPhase::Known(_), KeyCode::Down | KeyCode::Tab) => Action::JoinVettingRow(true), (VettingPhase::Known(_), KeyCode::Left) => Action::JoinVettingCycle(false), (VettingPhase::Known(_), KeyCode::Right) => Action::JoinVettingCycle(true), _ => return, @@ -127,18 +133,52 @@ fn keys(pairs: &[(&str, &str)]) -> Line<'static> { Line::from(spans) } +fn cursor(focused: bool) -> Span<'static> { + Span::styled( + if focused { "▸ " } else { " " }, + Style::new().fg(COLOR_SUCCESS).bold(), + ) +} + fn choice(name: &str, shown: String, focused: bool) -> Line<'static> { Line::from(vec![ - Span::styled( - if focused { "▸ " } else { " " }, - Style::new().fg(COLOR_SUCCESS).bold(), - ), + cursor(focused), Span::styled(format!("{name:<10}"), text()), Span::styled(shown, Style::new().fg(COLOR_SOFT_PURPLE)), Span::styled(if focused { " ←/→" } else { "" }, dim()), ]) } +/// The routes list: every way in, available or not. +/// +/// A blocked route keeps its row and reads dim, with the reason where its +/// detail would be. Dropping it would leave "why can I not use my invitation?" +/// unanswered — and the answer ("you hold none", "this community admits nobody +/// that way") is exactly what the page exists to give. +fn route_lines(known: &KnownVetting) -> Vec> { + known + .routes + .iter() + .enumerate() + .map(|(i, option)| { + let focused = known.row == i; + let (label_style, detail_style, detail) = match &option.blocked { + Some(why) => (dim(), dim(), why.clone()), + None => ( + text().bold(), + Style::new().fg(COLOR_SOFT_PURPLE), + option.detail.clone(), + ), + }; + Line::from(vec![ + cursor(focused), + Span::styled(format!("{: Vec> { let mut lines = Vec::new(); @@ -160,7 +200,7 @@ pub(crate) fn body_lines(state: &JoinState, view: &JoinVettingView) -> Vec { + VettingPhase::Unknown { reason, can_retry } => { lines.push(Line::from(vec![ accent_swatch(view.accent), Span::styled( @@ -177,12 +217,28 @@ pub(crate) fn body_lines(state: &JoinState, view: &JoinVettingView) -> Vec 0 { + lines.push(Line::styled( + format!( + "You hold {held} invitation{} from this community; joining now offers \ + {} to it.", + if held == 1 { "" } else { "s" }, + if held == 1 { "it" } else { "one" }, + ), + Style::new().fg(COLOR_SUCCESS), + )); + } lines.push(Line::default()); - lines.push(keys(&[ - ("ENTER/R", "ask again"), - ("J", "join anyway"), - ("ESC", "cancel"), - ])); + let mut offered = Vec::new(); + if *can_retry { + offered.push(("ENTER/R", "ask again")); + } + offered.push(("J", "join anyway")); + offered.push(("ESC", "cancel")); + lines.push(keys(&offered)); } VettingPhase::Known(known) => { lines.push(Line::from(vec![ @@ -201,6 +257,9 @@ pub(crate) fn body_lines(state: &JoinState, view: &JoinVettingView) -> Vec Vec { - lines.push(Line::styled( - format!("Your application, as {}", app.persona_label), - Style::new().fg(COLOR_SUCCESS).bold(), - )); - lines.push(Line::styled( - format!( - " {} statement{} held{}", - app.statements, - if app.statements == 1 { "" } else { "s" }, - app.progress - .as_ref() - .map(|p| format!(" — {p}")) - .unwrap_or_default() - ), - text(), - )); - lines.push(Line::from(vec![ - Span::styled(" Next: ", text()), - Span::styled(app.next_step.clone(), Style::new().fg(COLOR_SUCCESS).bold()), - ])); - lines.push(Line::default()); - if app.satisfied { - lines.push(Line::styled( - format!( - "Joining now presents your {} vetting statement{} to {}.", - app.statements, - if app.statements == 1 { "" } else { "s" }, - view.name - ), - Style::new().fg(COLOR_SUCCESS), - )); - lines.push(Line::default()); - lines.push(keys(&[ - ("ENTER", "join and present your statements"), - ("A", "open the application"), - ("ESC", "cancel"), - ])); - } else { - lines.push(keys(&[ - ("ENTER", "continue on the Vetting page"), - ( - "J", - "join anyway — the community refers the request to its moderators", - ), - ("ESC", "cancel"), - ])); - } - } - None => { - lines.push(Line::styled( - "Start an application", - Style::new().fg(COLOR_BORDER).bold(), - )); - let persona = known.personas.get(known.persona_index).map_or_else( - || "no persona yet — create one under My Identity".to_string(), - |p| format!("{} ({})", p.label, p.did), - ); - lines.push(choice("Apply as", persona, known.field == 0)); - let context = known - .context_options - .get(known.context_index) - .map_or_else(|| "—".to_string(), ContextOption::summary); - lines.push(choice("Context", context, known.field == 1)); - lines.push(Line::styled( - "The persona is fixed for the whole application: every card is signed by \ - it, and it is the DID the community admits.", - dim(), - )); - lines.push(Line::default()); - lines.push(keys(&[ - ("ENTER", "start the application"), - ("↑/↓", "field"), - ("J", "join anyway — the community refers the request"), - ("ESC", "cancel"), - ])); - } + lines.push(Line::styled( + "Your ways in", + Style::new().fg(COLOR_BORDER).bold(), + )); + lines.extend(route_lines(known)); + + if known.shows_selectors() { + lines.push(Line::default()); + lines.push(Line::styled( + "Applying as", + Style::new().fg(COLOR_BORDER).bold(), + )); + let persona = known.personas.get(known.persona_index).map_or_else( + || "no persona yet — create one under My Identity".to_string(), + |p| format!("{} ({})", p.label, p.did), + ); + lines.push(choice("Apply as", persona, known.selector() == Some(0))); + let context = known + .context_options + .get(known.context_index) + .map_or_else(|| "—".to_string(), ContextOption::summary); + lines.push(choice("Context", context, known.selector() == Some(1))); + lines.push(Line::styled( + "The persona is fixed for the whole application: every card is signed by it, \ + and it is the DID the community admits.", + dim(), + )); + } else if let Some(app) = &known.application { + lines.push(Line::default()); + lines.push(Line::styled( + format!( + "Your application, as {} — next: {}", + app.persona_label, app.next_step + ), + Style::new().fg(COLOR_SUCCESS), + )); } + lines.push(Line::default()); + lines.push(keys(&[ + ("↑/↓", "choose"), + ("ENTER", "take it"), + ("A", "open the application"), + ("J", "join now"), + ("ESC", "cancel"), + ])); } } // The DID under the name, so the community being joined is never only a @@ -321,7 +343,9 @@ pub(crate) fn body_lines(state: &JoinState, view: &JoinVettingView) -> Vec) -> RouteOption { + RouteOption { + route, + label: label.into(), + detail: "detail".into(), + blocked: blocked.map(Into::into), + } + } + + fn routes() -> Vec { + vec![ + route( + JoinRoute::Invitation, + "Use an invitation", + Some("none held"), + ), + route(JoinRoute::Vetting, "Apply for vetting", None), + route(JoinRoute::OpenRequest, "Send an open request", None), + ] + } + fn known(satisfied: Option) -> VettingPhase { VettingPhase::Known(Box::new(KnownVetting { requirements: vec!["2 vetting statements".into()], + routes: routes(), + row: 1, application: satisfied.map(|satisfied| JoinApplication { id: "a1".into(), persona: PersonaId::new(), @@ -365,21 +412,37 @@ mod tests { VettingPage::handle_key_event(flow, KeyEvent::new(code, KeyModifiers::NONE)); } + fn text_of(lines: &[Line<'_>]) -> String { + lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + #[test] - fn enter_starts_an_application_or_joins_one_that_is_ready() { + fn enter_takes_the_highlighted_route_whatever_it_is() { + // One key for the list, rather than a key whose meaning depends on + // whether an application happens to be satisfied. + for satisfied in [None, Some(false), Some(true)] { + let (mut f, mut rx) = flow(known(satisfied)); + press(&mut f, KeyCode::Enter); + assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingTake))); + } let (mut f, mut rx) = flow(known(None)); - press(&mut f, KeyCode::Enter); - assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingApply))); + press(&mut f, KeyCode::Down); + assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingRow(true)))); press(&mut f, KeyCode::Right); assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingCycle(true)))); - press(&mut f, KeyCode::Char('j')); - assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingJoin))); - - let (mut f, mut rx) = flow(known(Some(true))); - press(&mut f, KeyCode::Enter); - assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingJoin))); press(&mut f, KeyCode::Char('a')); assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingApply))); + press(&mut f, KeyCode::Char('j')); + assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingJoin))); } #[test] @@ -394,35 +457,75 @@ mod tests { let (mut f, mut rx) = flow(VettingPhase::Unknown { reason: "no answer".into(), + can_retry: true, }); press(&mut f, KeyCode::Char('r')); assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingAskAgain))); } - fn text_of(lines: &[Line<'_>]) -> String { - lines - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n") + /// The State-A loop cannot hear an answer, so "ask again" is neither drawn + /// nor bound — a key that can only ever fail is worse than no key. + #[test] + fn asking_again_is_withheld_when_no_answer_could_be_heard() { + let phase = || VettingPhase::Unknown { + reason: "it was not asked".into(), + can_retry: false, + }; + let (mut f, mut rx) = flow(phase()); + press(&mut f, KeyCode::Char('r')); + assert!(rx.try_recv().is_err()); + press(&mut f, KeyCode::Enter); + assert!(rx.try_recv().is_err()); + press(&mut f, KeyCode::Char('j')); + assert!(matches!(rx.try_recv(), Ok(Action::JoinVettingJoin))); + + let shown = text_of(&body_lines(&JoinState::default(), &view(phase()))); + assert!(!shown.contains("ask again")); + assert!(shown.contains("join anyway")); } #[test] - fn the_page_says_what_is_required_and_that_statements_go_with_the_join() { + fn the_page_says_what_is_required_and_lists_every_way_in() { let state = JoinState::default(); - let lines = body_lines(&state, &view(known(Some(true)))); - let shown = text_of(&lines); + let shown = text_of(&body_lines(&state, &view(known(Some(true))))); assert!(shown.contains("Kernel vets the people who join.")); assert!(shown.contains("• 2 vetting statements")); - assert!(shown.contains("Joining now presents your 2 vetting statements to Kernel.")); assert!(shown.contains("Nothing about you has been sent")); + assert!(shown.contains("Your ways in")); + assert!(shown.contains("Apply for vetting")); + assert!(shown.contains("Send an open request")); + } + + /// A route that cannot be taken is still listed, with the reason in place + /// of its detail: "why not?" is the question the page answers. + #[test] + fn a_blocked_route_keeps_its_row_and_says_why() { + let shown = text_of(&body_lines(&JoinState::default(), &view(known(None)))); + assert!(shown.contains("Use an invitation")); + assert!(shown.contains("none held")); + } - let lines = body_lines(&state, &view(known(Some(false)))); - assert!(text_of(&lines).contains("refers the request to its moderators")); + /// Held invitations are a fact about this account, so they are worth saying + /// even when the community itself could not be reached. + #[test] + fn invitations_held_are_named_even_when_the_community_is_unknown() { + let state = JoinState { + available_vics: vec![AvailableVic { + id: "urn:uuid:a".into(), + subject: None, + valid_from: String::new(), + valid_until: String::new(), + body: serde_json::Value::Null, + }], + ..JoinState::default() + }; + let shown = text_of(&body_lines( + &state, + &view(VettingPhase::Unknown { + reason: "no answer".into(), + can_retry: true, + }), + )); + assert!(shown.contains("You hold 1 invitation from this community")); } }