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")); } }