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;