From 66c80e4beffcd24d3ac5ebae46abe512745bd124 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 18 Sep 2026 10:01:46 +0200 Subject: [PATCH] fix(vetting): take up the join from the application it was made for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing to be vetted from the join flow abandoned the join. Starting an application returned to the Vetting page with nothing carried over, so once the statements were in the community had to be found and entered a second time. That is why vetting read as something to go and do *before* joining, rather than as one of the ways of joining. `j` on an application now takes up that join. The community is the one the application names, so `JoinEntry::ForCommunity` opens the flow where the DID entry page would have led — the same discovery, the same routes, no DID to find again. Nothing new is persisted to make this work: an application already records its community, so it *is* the parked join. That matters more than a field would, because gathering statements takes days and a transient handle would not survive the restarts in between. The submit arm's body moves into `enter_community` so both ways in share one path. The degraded loop's `StartJoin` arm takes both verbs, keeping the listener invariant documented there over the pair rather than over one of them, and the two "next step" lines stop telling people to go to Communities for a key that now works where they are standing. Signed-off-by: Glenn Gore --- CHANGELOG.md | 12 + openvtc/src/state_handler/actions/mod.rs | 6 + openvtc/src/state_handler/join_flow.rs | 251 +++++++++++------- openvtc/src/state_handler/mod.rs | 31 ++- openvtc/src/state_handler/runtime_actions.rs | 11 +- openvtc/src/state_handler/vetting_actions.rs | 4 +- .../ui/pages/main/components/vetting_panel.rs | 3 + openvtc/src/ui/pages/main/mod.rs | 63 +++++ 8 files changed, 277 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d29800..67067fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **Choosing to be vetted no longer abandons the join.** Starting an application + from the join flow left it for the Vetting page, and the community then had to + be found and entered a second time when the statements were in — which is why + vetting read as something to go and do *before* joining rather than a way of + joining. `j` on an application now takes up that join: the community is the one + the application names, so the flow opens where the DID entry page would have + led, with the same routes and the same discovery. The application is the parked + join, so it survives restarts — which matters, because gathering statements + takes days. + ### Changed - **Joining a community that vets now offers every way in at once.** The join diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 4d226ff..dfe906d 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -442,6 +442,12 @@ pub enum Action { /// both the degraded loop (State-A first join) and the runtime select loop. StartJoin, + /// Open the join flow for a community already chosen elsewhere — `j` on a + /// vetting application, which is the join that application was made for. + /// Skips the DID entry page: the community is the one the application + /// names, not one to be found and retyped. + StartJoinFor(String), + /// Submit the entered community VTC DID. With existing personas this opens /// the identity-choice page (R-B-3); with none it kicks off the mint+join /// sequence directly. diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index 97da9ee..631e21e 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -62,6 +62,14 @@ pub(crate) enum JoinEntry { /// loop reads inbound messages: only then can the flow ask a community for /// its requirements and hand the wait for the answer to that loop. Fresh { hears_replies: bool }, + /// For a community already chosen elsewhere — `j` on a vetting application, + /// which is the join that application was made for. The DID entry page is + /// skipped: an application names its community, and making someone find and + /// retype that DID is what turned "go and get vetted" into a dead end. + ForCommunity { + vtc_did: String, + hears_replies: bool, + }, /// Back from waiting for a community's requirements. Resume { vtc_did: String, @@ -527,7 +535,8 @@ fn apply_for_vetting( }; if let Some(app) = &known.application { let message = format!( - "Your application to {name}, as {}. Next: {}", + "Your application to {name}, as {}. Next: {}. Press j here when you are ready to \ + join — the join picks up where you left it.", app.persona_label, app.next_step ); vetting_actions::focus_application(state, config, &app.id, message); @@ -568,7 +577,8 @@ fn apply_for_vetting( config, &id, format!( - "Application to {name} started as {}. Next: {next}", + "Application to {name} started as {}. Next: {next}. The join to {name} is not lost — \ + press j here to take it up again, without finding that DID a second time.", persona.label ), ); @@ -628,35 +638,61 @@ impl StateHandler { state.join.has_invitation = state.invitation_credential.is_some(); state.active_page = ActivePage::Join; let hears_replies = match &entry { - JoinEntry::Fresh { hears_replies } => *hears_replies, + JoinEntry::Fresh { hears_replies } | JoinEntry::ForCommunity { hears_replies, .. } => { + *hears_replies + } 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, true); + match entry { + JoinEntry::Fresh { .. } => {} + // The community is already chosen, so the flow opens where the DID + // entry page would have led. + JoinEntry::ForCommunity { vtc_did, .. } => { + if let Some(exit) = self + .enter_community( + vtc_did, + hears_replies, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + { + let _ = self.state_tx.send(state.clone()); + return Ok(exit); } - outcome => { - let shown = matches!(outcome, RequirementsOutcome::Learned) - && show_vetting(state, config, &vtc_did); - if !shown - && let Some(interrupted) = self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); + } + JoinEntry::Resume { vtc_did, outcome } => { + // `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, true); + } + outcome => { + let shown = matches!(outcome, RequirementsOutcome::Learned) + && show_vetting(state, config, &vtc_did); + if !shown + && let Some(interrupted) = self + .continue_join( + vtc_did, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + { + return Ok(JoinExit::Exit(interrupted)); + } } } } @@ -766,70 +802,22 @@ 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 - // decides at once. Otherwise the community is asked, and - // the wait happens in the caller's loop — the one that - // hears the answer — which enters this flow again. - let knowledge = match config.private.vetting.knowledge(&vtc_did) { - Knowledge::Vetting(_) => Some(true), - Knowledge::NoVetting => Some(false), - Knowledge::Unknown => None, - }; - match knowledge { - Some(true) => { - show_vetting(state, config, &vtc_did); - } - None if hears_replies => { - if let Ok(awaiting) = - ask_requirements(state, config, tdk, messaging, &vtc_did) - .await - { - let _ = self.state_tx.send(state.clone()); - 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, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } + if let Some(exit) = self + .enter_community( + vtc_did, + hears_replies, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + { + let _ = self.state_tx.send(state.clone()); + return Ok(exit); } } Action::JoinIdentitySelect(i) => { @@ -1180,6 +1168,85 @@ impl StateHandler { } } + /// Enter `vtc_did` into the flow: learn what it asks of the people who join, + /// and put up the page that answers it. + /// + /// `Some` means leave the flow with that exit; `None` means a page is now up + /// and the loop carries on. Shared by the DID entry page and by a join + /// started for a community already chosen (a vetting application's), so both + /// get the same discovery rather than the entry page's alone. + #[allow(clippy::too_many_arguments)] + async fn enter_community( + &self, + vtc_did: String, + hears_replies: bool, + interrupt_rx: &mut broadcast::Receiver, + state: &mut State, + tdk: &TDK, + config: &mut Config, + admin_vta: Option<&VtaClient>, + profile: &str, + messaging: Option<&Messaging>, + ) -> Option { + // 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 decides at once. Otherwise the community + // is asked, and the wait happens in the caller's loop — the one that + // hears the answer — which enters this flow again. + let knowledge = match config.private.vetting.knowledge(&vtc_did) { + Knowledge::Vetting(_) => Some(true), + Knowledge::NoVetting => Some(false), + Knowledge::Unknown => None, + }; + match knowledge { + Some(true) => { + show_vetting(state, config, &vtc_did); + None + } + None if hears_replies => { + match ask_requirements(state, config, tdk, messaging, &vtc_did).await { + Ok(awaiting) => Some(JoinExit::AwaitRequirements(awaiting)), + // The page now says why the question could not be sent. + Err(()) => None, + } + } + 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, + ); + None + } + Some(false) => self + .continue_join( + vtc_did, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + .map(JoinExit::Exit), + } + } + /// Go on with a join from the vetting page, presenting the statements of an /// application that already meets the requirements when there is one. /// diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 7a25b24..2c0c34b 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1189,6 +1189,15 @@ impl StateHandler { // entered again. join_entry = Some(join_flow::JoinEntry::Fresh { hears_replies: true }); }, + // The same join, for a community already chosen: `j` on a + // vetting application resumes the join that application was + // made for, without going back for its DID. + Action::StartJoinFor(vtc_did) => { + join_entry = Some(join_flow::JoinEntry::ForCommunity { + vtc_did, + hears_replies: true, + }); + }, // A join waiting on a community's requirements keeps its screen // up while this loop hears the answer, so the two keys that // page offers land here rather than in the join flow. @@ -2258,7 +2267,22 @@ impl StateHandler { } } } - Action::StartJoin => { + // `StartJoinFor` carries a community already chosen — `j` on a + // vetting application. Both enter the same flow; only the + // page it opens on differs, so they share this arm and its + // listener invariant below. + Action::StartJoin | Action::StartJoinFor(_) => { + let entry = match &action { + Action::StartJoinFor(vtc_did) => join_flow::JoinEntry::ForCommunity { + vtc_did: vtc_did.clone(), + // This loop has no inbound arm, so the flow + // cannot wait here for a community's answer. + hears_replies: false, + }, + _ => join_flow::JoinEntry::Fresh { + hears_replies: false, + }, + }; // Set when a join succeeded: the loop then breaks `Joined` // so `run()` can start messaging. let mut joined_a_community = false; @@ -2279,10 +2303,7 @@ impl StateHandler { // the early load-failure callers, which // cannot reach a join anyway. messaging, - // This loop has no inbound arm, so the flow - // cannot wait here for a community's - // requirements; it uses what the book knows. - join_flow::JoinEntry::Fresh { hears_replies: false }, + entry, ) .await { diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index fd3b962..c6832da 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -1189,11 +1189,12 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha | Action::TokenWriteKeys(..) => {} // Owned by the loop: `Exit` and `UXError` signal the terminator and - // break with its outcome type, and `StartJoin` drives the join flow's - // own action loop using the receiver this one selects on. Listed so the - // match stays exhaustive — the guarantee is that a NEW variant cannot be - // added without a decision here, and these three already have one. - Action::Exit | Action::UXError(..) | Action::StartJoin => { + // break with its outcome type, and the two `StartJoin` verbs drive the + // join flow's own action loop using the receiver this one selects on. + // Listed so the match stays exhaustive — the guarantee is that a NEW + // variant cannot be added without a decision here, and these already + // have one. + Action::Exit | Action::UXError(..) | Action::StartJoin | Action::StartJoinFor(..) => { debug_assert!(false, "handled by the loop, not the handler"); } } diff --git a/openvtc/src/state_handler/vetting_actions.rs b/openvtc/src/state_handler/vetting_actions.rs index 7252f16..1742e2e 100644 --- a/openvtc/src/state_handler/vetting_actions.rs +++ b/openvtc/src/state_handler/vetting_actions.rs @@ -496,7 +496,7 @@ pub(crate) fn next_step_words(step: &NextStep) -> String { "c — a vetter opened a session: read the code together, then send your card" } NextStep::LearnRequirements => "m — ask the community what it requires", - NextStep::Join => "join from Communities (j) — your statements go with the request", + NextStep::Join => "j — join now; your statements go with the request", NextStep::ChooseFace => "f — choose the face vetters are shown, then ask a vetter", NextStep::AskVetter => "r — ask a vetter with their ticket, or v to find one", NextStep::WaitForVetters => "wait for your vetters — you are told when one answers", @@ -545,7 +545,7 @@ fn requirements_line(r: &VettingRequirements) -> String { pub(crate) fn progress_line(evaluation: &Evaluation) -> String { if evaluation.satisfied() { - return "meets the published requirements — join from Communities".to_string(); + return "meets the published requirements — press j to join".to_string(); } let needs: Vec = evaluation .needs diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index 94c1276..cca2b79 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -619,6 +619,9 @@ fn applications(lines: &mut Vec>, v: &VettingState) { lines.push(hint( "n: new f: face r: ask a vetter v: find vetters c: send card m: refresh requirements", )); + lines.push(hint( + "j: join this community — the join this application was made for", + )); lines.push(hint("Tab: your vetting desk")); } diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 30784c7..e27ab88 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -2557,6 +2557,19 @@ impl MainPage { (VettingTab::Applications, KeyCode::Char('m')) => V::RefreshRequirements, (VettingTab::Applications, KeyCode::Char('v')) => V::FindVetters, (VettingTab::Applications, KeyCode::Char('c') | KeyCode::Enter) => V::ReviewCard, + // The join this application was made for. It leaves the panel + // rather than sending an action, because the join flow owns the + // screen — and it carries the community, so the DID an application + // already names is never asked for again. + (VettingTab::Applications, KeyCode::Char('j')) => { + let Some(application) = vetting.applications.get(selected) else { + return false; + }; + let _ = self + .action_tx + .send(Action::StartJoinFor(application.community.clone())); + return true; + } // Anywhere on the desk: the profile the communities hold, and // asking one to reissue a grant that has lapsed. (VettingTab::Desk, KeyCode::Char('p')) => V::EditProfile, @@ -3770,6 +3783,56 @@ mod key_handler_tests { assert!(matches!(vetting_action(&mut rx), V::SwitchTab)); } + /// `j` on an application is the join that application was made for. It + /// carries the community, because being told "go and get vetted" and then + /// having to find the community's DID again is what made applying feel like + /// abandoning the join. + #[test] + fn j_on_an_application_joins_the_community_it_names() { + use crate::state_handler::main_page::content::{ApplicationRow, VettingTab}; + + let row = |community: &str| ApplicationRow { + id: "a1".into(), + community: community.into(), + community_name: None, + accent: None, + next_step: None, + join_did: "did:key:zA".into(), + requirements: None, + progress: None, + satisfied: false, + identity: Vec::new(), + requests: Vec::new(), + statements: 0, + }; + let (mut page, mut rx) = page_for(MainMenu::Vetting, |s| { + let vetting = &mut s.main_page.content_panel.vetting; + vetting.applications = vec![row("did:web:kernel"), row("did:web:other")].into(); + vetting.selected = 1; + }); + page.handle_key_event(press(KeyCode::Char('j'))); + assert!(matches!( + rx.try_recv(), + Ok(Action::StartJoinFor(did)) if did == "did:web:other" + )); + + // With no application under the cursor there is no join to take up, so + // the key does nothing rather than starting one for a community it + // would have to invent. + let (mut page, mut rx) = page_for(MainMenu::Vetting, |_| {}); + page.handle_key_event(press(KeyCode::Char('j'))); + assert!(rx.try_recv().is_err()); + + // The desk is the other side of vetting; `j` is not its key. + let (mut page, mut rx) = page_for(MainMenu::Vetting, |s| { + let vetting = &mut s.main_page.content_panel.vetting; + vetting.applications = vec![row("did:web:kernel")].into(); + vetting.tab = VettingTab::Desk; + }); + page.handle_key_event(press(KeyCode::Char('j'))); + assert!(rx.try_recv().is_err()); + } + #[test] fn vetting_forms_take_text_cycle_and_tick() { use crate::state_handler::actions::VettingAction as V;