From d0b2e3efea1799857fdb0a18595db5bbf4c70795 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 18 Sep 2026 22:47:22 +0200 Subject: [PATCH] feat(join): create a persona without leaving the join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A community that vets is exactly where someone without a persona finds out they need one — applying signs every card with it. The route was blocked with "create one under My Identity", which meant leaving the flow, finding the right pane, minting, and coming back to start the join again, entering the community's DID a second time on the way. The requirements just read were gone by then too. `n` on that page now opens the create-persona overlay over the join, with every option it has: the label, whether the hosting server names the DID's path or the operator does, and which context it is minted into. It is the same overlay rather than a second one. Its keys and its rendering were methods on the main page and are now one module both pages call, taking the overlay state and an action sender — the only two things they ever used. The phases, the keys and the wording therefore cannot fork between the two places it can be opened from. Only the mint differs, and only in how it is run. The runtime loop spawns it through its dispatcher; the join loop has none, so `spawn_persona_mint` splits into `advance_persona_overlay` — everything up to the mint, which is phase-checking against config and needs no I/O — plus the job. The join loop awaits that job raced against the interrupt (R15), draining its progress onto the overlay as it arrives rather than after: minting a DID is not instant, and a frozen "Creating persona…" is indistinguishable from a wedged one. A persona minted here is persisted and its listener started before the overlay closes. The route it unblocks is the one that would otherwise fail on a persona that cannot send, which would be worse than the block. The vetting view is re-derived once the overlay is gone, so the row that was blocked on having no persona unblocks itself rather than waiting for the join to be entered again. The two places that said "create one under My Identity" now name the key that is on the page they are printed on. Signed-off-by: Glenn Gore --- CHANGELOG.md | 21 ++ openvtc/src/state_handler/join_flow.rs | 161 ++++++++- openvtc/src/state_handler/mod.rs | 65 ++-- .../src/ui/pages/create_persona_overlay.rs | 315 ++++++++++++++++++ openvtc/src/ui/pages/join_flow/mod.rs | 24 +- .../pages/join_flow/vetting_requirements.rs | 19 +- openvtc/src/ui/pages/main/mod.rs | 282 +--------------- openvtc/src/ui/pages/mod.rs | 1 + 8 files changed, 577 insertions(+), 311 deletions(-) create mode 100644 openvtc/src/ui/pages/create_persona_overlay.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6e64cf..9913621d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- **A join that needs a persona can make one without leaving the join.** The + vetting route was blocked with "create one under My Identity", which meant + leaving the flow, finding the right pane, minting, and coming back to start + the join again — entering the community's DID a second time on the way. `n` on + that page now opens the same create-persona overlay the main page opens, with + every option it offers: label, whether the host names the DID's path or you + do, and which context it is minted into. + + It is the same overlay, not a second one. Its keys and its rendering moved to + one module both pages call, so the phases and the wording cannot fork; only + the mint differs, because the runtime loop spawns it through its dispatcher + and the join loop awaits it inline — raced against the interrupt, with its + progress streaming onto the overlay as it arrives. + + A persona minted this way is persisted and brought online before the overlay + closes: the route it unblocks is the one that would otherwise fail on a + persona that cannot send. The routes are re-derived once the overlay is gone, + so the row that was blocked on having no persona unblocks itself. + ### Fixed - **Pasting the community's DID on the join entry page works.** `[Ctrl+V]` read diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index f63b0574..8c0969d7 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -242,7 +242,7 @@ fn build_routes( "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." + Press n to create one without leaving this join." .to_string(), ), ), @@ -593,8 +593,8 @@ fn apply_for_vetting( } let Some(persona) = known.personas.get(known.persona_index).cloned() else { return Err( - "Applying needs a persona: every card is signed by the DID you join with. Create one \ - under My Identity, or join anyway (j), which makes one." + "Applying needs a persona: every card is signed by the DID you join with. Press n to \ + create one here, or join anyway (j), which makes one." .to_string(), ); }; @@ -1188,6 +1188,53 @@ impl StateHandler { return Ok(JoinExit::Exit(interrupted)); } } + // The create-persona overlay, hosted here rather than + // on the main page. Everything except the mint is the + // shared pure reducer, so the phases and keys cannot + // drift from the ones the main page shows. + action if super::handle_nav_action(state, &action) => { + // A persona minted behind this page changes what the + // routes say, so they are re-derived once the overlay + // is gone. Cheap, idempotent, and it means the row + // that was blocked on having no persona unblocks + // itself rather than waiting for a re-entry. + if state.join.page == JoinPage::Vetting + && state.main_page.create_persona.is_none() + && let Some(vtc_did) = state.join.pending_vtc.clone() + { + show_vetting(state, config, &vtc_did); + } + } + Action::CreatePersonaSubmit => { + if let Some(interrupted) = self + .mint_persona_inline( + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + messaging, + ) + .await + { + return Ok(JoinExit::Exit(interrupted)); + } + } + Action::CreatePersonaCopy => { + if let Some(did) = state + .main_page + .create_persona + .as_ref() + .and_then(|o| o.did.clone()) + { + let copied = + crate::clipboard::copy_to_clipboard(&did).is_ok(); + if let Some(o) = state.main_page.create_persona.as_mut() { + o.copied = copied; + } + } + } Action::JoinVettingAskAgain => { let Some(vtc_did) = state.join.pending_vtc.clone() else { continue; @@ -1310,6 +1357,99 @@ impl StateHandler { } } + /// Mint a persona from the overlay this flow is hosting, without leaving it. + /// + /// The runtime loop spawns this job through its dispatcher; this loop has + /// none, so it awaits it the way it already awaits the join sequence — + /// raced against the interrupt, so a mint that hangs does not take Ctrl-C + /// with it (R15). Progress lines stream onto the overlay as they arrive + /// rather than after, because minting a DID is not instant and a frozen + /// "Creating persona…" is indistinguishable from a wedged one. + /// + /// On success the persona is persisted and brought online here: a persona + /// the join can see but cannot send as would be worse than none, since the + /// route it unblocks is the one that then fails. + #[allow(clippy::too_many_arguments)] + async fn mint_persona_inline( + &self, + interrupt_rx: &mut broadcast::Receiver, + state: &mut State, + tdk: &TDK, + config: &mut Config, + admin_vta: Option<&VtaClient>, + profile: &str, + messaging: Option<&Messaging>, + ) -> Option { + use crate::state_handler::background_dispatch::{DispatchOutcome, ProgressUpdate}; + + let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel(); + let Some(job) = super::advance_persona_overlay(state, config, tdk, admin_vta, &progress_tx) + else { + // A phase moved on, or refused and said why on the overlay. + let _ = self.state_tx.send(state.clone()); + return None; + }; + drop(progress_tx); + let _ = self.state_tx.send(state.clone()); + + let run = job.run(); + tokio::pin!(run); + let outcome = loop { + tokio::select! { + outcome = &mut run => break outcome, + Some(update) = progress_rx.recv() => { + if let DispatchOutcome::Progress(ProgressUpdate::PersonaMint(line)) = update + && let Some(o) = state.main_page.create_persona.as_mut() + { + o.messages.push(line); + let _ = self.state_tx.send(state.clone()); + } + } + Ok(interrupted) = interrupt_rx.recv() => return Some(interrupted), + } + }; + + if let Some(minted) = outcome.apply(state) { + let did = minted.did.clone(); + match minted.persist(config, tdk, profile).await { + Ok(persona_id) => { + let copied = crate::clipboard::copy_to_clipboard(&did).is_ok(); + if let Some(o) = state.main_page.create_persona.as_mut() { + o.phase = + crate::state_handler::main_page::content::CreatePersonaPhase::Done; + o.did = Some(did.clone()); + o.copied = copied; + o.messages.push("Persona created.".to_string()); + } + state.main_page.sync_from_config(config); + state.main_page.log(format!("Created persona DID {did}")); + // Bring it online now. The join is about to offer it as a + // way in, and a persona that cannot send is not one. A + // no-op in State A, which has no service to install into — + // that loop restarts into the full pipeline after a join + // and brings it up there. + start_persona_listener(self, state, messaging, config, tdk, persona_id, &did) + .await; + } + Err(e) => { + // The DID exists at the VTA but is not in the config. Say so + // plainly: it is not lost, and a retry would mint a second. + if let Some(o) = state.main_page.create_persona.as_mut() { + o.phase = + crate::state_handler::main_page::content::CreatePersonaPhase::Failed; + o.messages + .push(format!("Minted, but could not be saved: {e}")); + } + state + .main_page + .log_error("Persona minted but not saved", &e); + } + } + } + let _ = self.state_tx.send(state.clone()); + None + } + /// Go on with a join from the vetting page, presenting the statements of an /// application that already meets the requirements when there is one. /// @@ -3561,6 +3701,21 @@ mod vetting_tests { ); } + /// The blocked reason names the key that is on the very page it appears on. + /// It used to send people to My Identity, which meant leaving the join and + /// entering the community's DID again on the way back. + #[test] + fn applying_without_a_persona_points_at_the_key_here() { + let routes = build_routes("Kernel", &requirements(None), None, &[], &[]); + let vetting = option_for(&routes, JoinRoute::Vetting).unwrap(); + let why = vetting.blocked.as_deref().unwrap(); + assert!(why.contains("Press n"), "{why}"); + assert!( + !why.contains("My Identity"), + "the join no longer sends people away for this: {why}" + ); + } + /// 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] diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 0a74a9b9..8a4f2137 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -3019,19 +3019,52 @@ fn spawn_persona_mint( tdk: &TDK, admin_vta: Option<&vta_sdk::client::VtaClient>, ) { + let Some(job) = advance_persona_overlay(state, config, tdk, admin_vta, dispatch_tx) else { + return; + }; + let domain = background_dispatch::DispatchDomain::Persona; + if !in_flight.try_begin(domain) { + if let Some(o) = state.main_page.create_persona.as_mut() { + o.phase = main_page::content::CreatePersonaPhase::Path; + o.messages = vec![background_dispatch::InFlight::busy_message(domain)]; + } + return; + } + background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { + background_dispatch::DispatchOutcome::Persona(Box::new(job.run().await)) + }); +} + +/// Advance the create-persona overlay one phase, and return the mint to run +/// when the last one commits. +/// +/// Split out of [`spawn_persona_mint`] so the join flow can host the same +/// overlay. Everything up to the mint is phase-checking against `config` and +/// needs no I/O; the mint itself is a job, which the runtime loop spawns +/// through its dispatcher and the join loop awaits inline — the way that loop +/// already runs the join sequence. `None` means the overlay moved on (or +/// refused) and there is nothing to run. +pub(crate) fn advance_persona_overlay( + state: &mut State, + config: &Config, + tdk: &TDK, + admin_vta: Option<&vta_sdk::client::VtaClient>, + progress_tx: &tokio::sync::mpsc::UnboundedSender, +) -> Option { use main_page::content::CreatePersonaPhase; - fn fail(state: &mut State, msg: &str, terminal: bool) { + /// Report onto the overlay and yield "nothing to run", so the callers + /// below can stay `return fail(…)`. + fn fail(state: &mut State, msg: &str, terminal: bool) -> Option { if let Some(o) = state.main_page.create_persona.as_mut() { if terminal { o.phase = CreatePersonaPhase::Failed; } o.messages = vec![msg.to_string()]; } + None } - let Some(overlay) = state.main_page.create_persona.clone() else { - return; - }; + let overlay = state.main_page.create_persona.clone()?; let label = overlay.label.value().trim().to_string(); match overlay.phase { // The label is checked, then where the DID sits on the host is asked — @@ -3051,7 +3084,7 @@ fn spawn_persona_mint( o.messages.clear(); o.phase = CreatePersonaPhase::Path; } - return; + return None; } // The path is checked against the hosting server's naming rules before // the contexts are offered, so a typo is caught here rather than after @@ -3068,11 +3101,11 @@ fn spawn_persona_mint( o.messages.clear(); o.phase = CreatePersonaPhase::Context; } - return; + return None; } CreatePersonaPhase::Context => {} CreatePersonaPhase::Working | CreatePersonaPhase::Done | CreatePersonaPhase::Failed => { - return; + return None; } } // Re-checked rather than carried from the path phase: Esc goes back to it, @@ -3097,15 +3130,6 @@ fn spawn_persona_mint( true, ); }; - let domain = background_dispatch::DispatchDomain::Persona; - if !in_flight.try_begin(domain) { - return fail( - state, - &background_dispatch::InFlight::busy_message(domain), - false, - ); - } - if let Some(o) = state.main_page.create_persona.as_mut() { o.phase = CreatePersonaPhase::Working; o.messages = vec![format!( @@ -3113,16 +3137,13 @@ fn spawn_persona_mint( )]; } - let job = create_persona::MintJob { + Some(create_persona::MintJob { admin_vta: admin_vta.clone(), tdk: tdk.clone(), inputs: create_persona::MintInputs::from_config(config, context_id, path_mode), label, - progress_tx: dispatch_tx.clone(), - }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Persona(Box::new(job.run().await)) - }); + progress_tx: progress_tx.clone(), + }) } /// Send a document addressed to a community, off the loop. diff --git a/openvtc/src/ui/pages/create_persona_overlay.rs b/openvtc/src/ui/pages/create_persona_overlay.rs new file mode 100644 index 00000000..b2d6417d --- /dev/null +++ b/openvtc/src/ui/pages/create_persona_overlay.rs @@ -0,0 +1,315 @@ +//! The "create a new persona DID" overlay — its keys and its rendering. +//! +//! Lifted off the main page so the join flow can host the same overlay. A join +//! that needs a persona used to say "create one under My Identity", which meant +//! leaving the flow, finding the right pane, and coming back to start the join +//! again — and the community DID had to be entered a second time. The overlay +//! floats over whichever page opened it; nothing about it is page-specific, and +//! the phases, the keys and the wording must not fork between the two callers. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::Layout, + text::{Line, Span}, + widgets::Paragraph, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::colors::{ + COLOR_BORDER, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, + COLOR_WARNING_ACCESSIBLE_RED, +}; + +use crate::state_handler::actions::Action; +use crate::state_handler::main_page::content::CreatePersonaState; + +pub fn handle_key( + overlay: &CreatePersonaState, + key: KeyEvent, + action_tx: &UnboundedSender, +) { + use crate::state_handler::main_page::content::CreatePersonaPhase; + match overlay.phase { + CreatePersonaPhase::Label => match key.code { + KeyCode::Enter => { + let _ = action_tx.send(Action::CreatePersonaSubmit); + } + KeyCode::Esc => { + let _ = action_tx.send(Action::CreatePersonaClose); + } + _ => { + let _ = action_tx.send(Action::CreatePersonaInput(key)); + } + }, + CreatePersonaPhase::Path => { + use crate::state_handler::main_page::content::PersonaPathChoice; + let action = match key.code { + KeyCode::Up => Action::CreatePersonaPathChoice(PersonaPathChoice::Auto), + KeyCode::Down => Action::CreatePersonaPathChoice(PersonaPathChoice::Custom), + KeyCode::Enter => Action::CreatePersonaSubmit, + KeyCode::Esc => Action::CreatePersonaBack, + // Everything else edits the path, which is also how the + // typed row gets chosen — see `CreatePersonaPathInput`. + _ => Action::CreatePersonaPathInput(key), + }; + let _ = action_tx.send(action); + } + CreatePersonaPhase::Context => { + let selected = overlay.context_selected; + let last = overlay.context_options.len().saturating_sub(1); + let new_row = overlay.context_options.get(selected).is_some_and(|o| { + o.kind == openvtc_core::config::community_context::ContextKind::New + }); + let action = match key.code { + KeyCode::Up => Action::CreatePersonaContextSelect(selected.saturating_sub(1)), + KeyCode::Down => Action::CreatePersonaContextSelect((selected + 1).min(last)), + KeyCode::Enter => Action::CreatePersonaSubmit, + KeyCode::Esc => Action::CreatePersonaBack, + KeyCode::Char(c) if new_row => { + Action::CreatePersonaContextSlug(format!("{}{c}", overlay.context_slug)) + } + KeyCode::Backspace if new_row => { + let mut slug = overlay.context_slug.clone(); + slug.pop(); + Action::CreatePersonaContextSlug(slug) + } + _ => return, + }; + let _ = action_tx.send(action); + } + // Mint in progress: lock input (no cancel — the sequence is short and + // persists atomically). + CreatePersonaPhase::Working => {} + CreatePersonaPhase::Done => match key.code { + KeyCode::Char('c') => { + let _ = action_tx.send(Action::CreatePersonaCopy); + } + _ => { + let _ = action_tx.send(Action::CreatePersonaClose); + } + }, + CreatePersonaPhase::Failed => { + let _ = action_tx.send(Action::CreatePersonaClose); + } + } +} + +pub fn render(frame: &mut Frame, overlay: &CreatePersonaState) { + use crate::state_handler::main_page::content::CreatePersonaPhase; + use ratatui::{ + layout::{Constraint, Flex}, + style::Style, + widgets::{Block, Clear, Padding}, + }; + + let area = frame.area(); + // The context and path choices both carry full paths, so they are wider + // and grow with what they list. + let choosing = overlay.phase == CreatePersonaPhase::Context; + let path = overlay.phase == CreatePersonaPhase::Path; + let popup_width = + if choosing || path { 84u16 } else { 64u16 }.min(area.width.saturating_sub(4)); + let popup_height = if choosing { + (overlay.context_options.len() + overlay.messages.len()) as u16 + 9 + } else if path { + // Two rows, two explanatory lines, the charset hint, the key line, + // and whatever the path was refused for. + overlay.messages.len() as u16 + 13 + } else { + 11u16 + } + .min(area.height.saturating_sub(2)) + .max(7); + + let [popup_area] = Layout::vertical([Constraint::Length(popup_height)]) + .flex(Flex::Center) + .areas(area); + let [popup_area] = Layout::horizontal([Constraint::Length(popup_width)]) + .flex(Flex::Center) + .areas(popup_area); + + frame.render_widget(Clear, popup_area); + + let block = Block::bordered() + .title(" Create persona DID ") + .title_style(Style::new().fg(COLOR_ORANGE).bold()) + .border_style(Style::new().fg(COLOR_ORANGE)) + .padding(Padding::uniform(1)); + + let mut lines: Vec = Vec::new(); + match overlay.phase { + CreatePersonaPhase::Label => { + lines.push(Line::from(Span::styled( + "Label for the new persona:", + Style::new().fg(COLOR_TEXT_DEFAULT), + ))); + lines.push(Line::from(Span::styled( + format!("> {}", overlay.label.value()), + Style::new().fg(COLOR_SOFT_PURPLE).bold(), + ))); + lines.push(Line::default()); + for msg in &overlay.messages { + lines.push(Line::from(Span::styled( + msg.clone(), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ))); + } + lines.push(Line::from(Span::styled( + "⏎ next esc cancel", + Style::new().fg(COLOR_BORDER), + ))); + } + CreatePersonaPhase::Path => { + use crate::state_handler::main_page::content::PersonaPathChoice; + let custom = overlay.path_choice == PersonaPathChoice::Custom; + lines.push(Line::from(Span::styled( + "Where should this persona's DID live on the hosting server?", + Style::new().fg(COLOR_TEXT_DEFAULT), + ))); + lines.push(Line::from(Span::styled( + "The path is part of the DID, and cannot be changed afterwards.", + Style::new().fg(COLOR_BORDER), + ))); + lines.push(Line::default()); + + let row = |selected: bool, text: String| { + let style = if selected { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + Style::new().fg(COLOR_TEXT_DEFAULT) + }; + Line::from(Span::styled( + format!("{}{text}", if selected { "▸ " } else { " " }), + style, + )) + }; + lines.push(row( + !custom, + "Server-assigned (a random, unguessable path)".to_string(), + )); + lines.push(row( + custom, + format!( + "My own path: {}{}", + overlay.path.value(), + if custom { "▎" } else { "" } + ), + )); + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + if custom { + "Lowercase letters, digits and hyphens; '/' separates segments." + } else { + "A typed path is public and memorable — and may already be taken." + }, + Style::new().fg(COLOR_BORDER), + ))); + for msg in &overlay.messages { + lines.push(Line::from(Span::styled( + msg.clone(), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ))); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "↑/↓ choose type: name the path ⏎ next esc back", + Style::new().fg(COLOR_BORDER), + ))); + } + CreatePersonaPhase::Context => { + use openvtc_core::config::community_context::ContextKind; + lines.push(Line::from(Span::styled( + "Where should this persona's keys and DID live?", + Style::new().fg(COLOR_TEXT_DEFAULT), + ))); + lines.push(Line::from(Span::styled( + "A persona is presented from the context it is minted in.", + Style::new().fg(COLOR_BORDER), + ))); + lines.push(Line::default()); + for (i, option) in overlay.context_options.iter().enumerate() { + let selected = i == overlay.context_selected; + let text = match option.kind { + ContextKind::New => { + let parent = openvtc_core::config::context_path::parse_sub_context_id( + &option.context_id, + ) + .map_or(option.context_id.as_str(), |(parent, _)| parent); + format!( + "{parent}/{}{} (a context of its own)", + overlay.context_slug, + if selected { "▎" } else { "" } + ) + } + ContextKind::Existing | ContextKind::Top => option.summary(), + }; + let style = if selected { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + Style::new().fg(COLOR_TEXT_DEFAULT) + }; + lines.push(Line::from(Span::styled( + format!("{}{text}", if selected { "▸ " } else { " " }), + style, + ))); + } + for msg in &overlay.messages { + lines.push(Line::from(Span::styled( + msg.clone(), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ))); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "↑/↓ choose type: name the new context ⏎ create esc back", + Style::new().fg(COLOR_BORDER), + ))); + } + CreatePersonaPhase::Working => { + for msg in &overlay.messages { + lines.push(Line::from(Span::styled( + msg.clone(), + Style::new().fg(COLOR_TEXT_DEFAULT), + ))); + } + } + CreatePersonaPhase::Done => { + lines.push(Line::from(Span::styled( + "✓ Persona created", + Style::new().fg(COLOR_SUCCESS).bold(), + ))); + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + overlay.did.clone().unwrap_or_default(), + Style::new().fg(COLOR_SOFT_PURPLE), + ))); + lines.push(Line::default()); + if overlay.copied { + lines.push(Line::from(Span::styled( + "(copied to clipboard)", + Style::new().fg(COLOR_SUCCESS), + ))); + } + lines.push(Line::from(Span::styled( + "c: copy again ⏎/esc close", + Style::new().fg(COLOR_BORDER), + ))); + } + CreatePersonaPhase::Failed => { + for msg in &overlay.messages { + lines.push(Line::from(Span::styled( + msg.clone(), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ))); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "⏎/esc close", + Style::new().fg(COLOR_BORDER), + ))); + } + } + + frame.render_widget(Paragraph::new(lines).block(block), popup_area); +} diff --git a/openvtc/src/ui/pages/join_flow/mod.rs b/openvtc/src/ui/pages/join_flow/mod.rs index 2d135fda..debe19aa 100644 --- a/openvtc/src/ui/pages/join_flow/mod.rs +++ b/openvtc/src/ui/pages/join_flow/mod.rs @@ -13,10 +13,12 @@ use crate::{ state_handler::{ actions::Action, join::{JoinPage, JoinState}, + main_page::content::CreatePersonaState, state::State, }, ui::{ component::{Component, ComponentRender}, + pages::create_persona_overlay, pages::join_flow::{ context_choice::ContextChoice, identity_choice::IdentityChoice, invitation_choice::InvitationChoice, join_progress::JoinProgress, @@ -68,12 +70,22 @@ pub struct JoinFlow { #[derive(Clone)] pub struct Props { pub state: JoinState, + /// The create-persona overlay, while one is open. + /// + /// Page-level state shared with the main page rather than a copy of its + /// own: the join needs a persona at exactly the moment it tells you so, and + /// sending you to My Identity to make one meant leaving the flow and + /// entering the community's DID a second time on the way back. The same + /// overlay, the same phases, the same keys — floated over whichever page + /// asked for it. + pub create_persona: Option, } impl From<&State> for Props { fn from(state: &State) -> Self { Props { state: state.join.clone(), + create_persona: state.main_page.create_persona.clone(), } } } @@ -174,6 +186,13 @@ impl Component for JoinFlow { if key.kind != KeyEventKind::Press { return; } + // An open overlay takes the keys, as it does on the main page: it is + // floating over this page, so the page beneath it must not also act on + // what is typed into it. + if let Some(overlay) = self.props.create_persona.as_ref() { + create_persona_overlay::handle_key(overlay, key, &self.action_tx); + return; + } match self.props.state.page { JoinPage::EnterDid => VtcEnterDid::handle_key_event(self, key), JoinPage::InvitationChoice => InvitationChoice::handle_key_event(self, key), @@ -185,7 +204,7 @@ impl Component for JoinFlow { } fn handle_paste_event(&mut self, text: &str) { - if self.props.state.processing { + if self.props.state.processing || self.props.create_persona.is_some() { return; } let trimmed = text.trim(); @@ -227,6 +246,9 @@ impl ComponentRender<()> for JoinFlow { JoinPage::Progress => self.join_progress.render(&self.props.state, frame), JoinPage::Vetting => self.vetting.render(&self.props.state, frame), } + if let Some(overlay) = self.props.create_persona.as_ref() { + create_persona_overlay::render(frame, overlay); + } } } diff --git a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs index 4103063c..374da260 100644 --- a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs +++ b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs @@ -74,6 +74,11 @@ impl VettingPage { ) if *can_retry => Action::JoinVettingAskAgain, (VettingPhase::Known(_), KeyCode::Enter) => Action::JoinVettingTake, (VettingPhase::Known(_), KeyCode::Char('a' | 'A')) => Action::JoinVettingApply, + // Applying signs cards with a persona, so a community that vets is + // exactly where someone without one finds out they need one. Making + // it here keeps the community — and the requirements just read — + // on screen behind the overlay. + (VettingPhase::Known(_), KeyCode::Char('n' | 'N')) => Action::StartCreatePersona, (VettingPhase::Known(_), KeyCode::Up | KeyCode::BackTab) => { Action::JoinVettingRow(false) } @@ -304,7 +309,7 @@ pub(crate) fn body_lines(state: &JoinState, view: &JoinVettingView) -> Vec Vec match key.code { - KeyCode::Enter => { - let _ = self.action_tx.send(Action::CreatePersonaSubmit); - } - KeyCode::Esc => { - let _ = self.action_tx.send(Action::CreatePersonaClose); - } - _ => { - let _ = self.action_tx.send(Action::CreatePersonaInput(key)); - } - }, - CreatePersonaPhase::Path => { - use crate::state_handler::main_page::content::PersonaPathChoice; - let action = match key.code { - KeyCode::Up => Action::CreatePersonaPathChoice(PersonaPathChoice::Auto), - KeyCode::Down => Action::CreatePersonaPathChoice(PersonaPathChoice::Custom), - KeyCode::Enter => Action::CreatePersonaSubmit, - KeyCode::Esc => Action::CreatePersonaBack, - // Everything else edits the path, which is also how the - // typed row gets chosen — see `CreatePersonaPathInput`. - _ => Action::CreatePersonaPathInput(key), - }; - let _ = self.action_tx.send(action); - } - CreatePersonaPhase::Context => { - let selected = overlay.context_selected; - let last = overlay.context_options.len().saturating_sub(1); - let new_row = overlay.context_options.get(selected).is_some_and(|o| { - o.kind == openvtc_core::config::community_context::ContextKind::New - }); - let action = match key.code { - KeyCode::Up => Action::CreatePersonaContextSelect(selected.saturating_sub(1)), - KeyCode::Down => Action::CreatePersonaContextSelect((selected + 1).min(last)), - KeyCode::Enter => Action::CreatePersonaSubmit, - KeyCode::Esc => Action::CreatePersonaBack, - KeyCode::Char(c) if new_row => { - Action::CreatePersonaContextSlug(format!("{}{c}", overlay.context_slug)) - } - KeyCode::Backspace if new_row => { - let mut slug = overlay.context_slug.clone(); - slug.pop(); - Action::CreatePersonaContextSlug(slug) - } - _ => return, - }; - let _ = self.action_tx.send(action); - } - // Mint in progress: lock input (no cancel — the sequence is short and - // persists atomically). - CreatePersonaPhase::Working => {} - CreatePersonaPhase::Done => match key.code { - KeyCode::Char('c') => { - let _ = self.action_tx.send(Action::CreatePersonaCopy); - } - _ => { - let _ = self.action_tx.send(Action::CreatePersonaClose); - } - }, - CreatePersonaPhase::Failed => { - let _ = self.action_tx.send(Action::CreatePersonaClose); - } - } + crate::ui::pages::create_persona_overlay::handle_key(overlay, key, &self.action_tx); } /// Agent-name manager overlay keys. `Ready` phase: Enter claims the typed @@ -3031,222 +2968,7 @@ impl MainPage { frame: &mut Frame, overlay: &crate::state_handler::main_page::content::CreatePersonaState, ) { - use crate::state_handler::main_page::content::CreatePersonaPhase; - use ratatui::{ - layout::{Constraint, Flex}, - style::Style, - widgets::{Block, Clear, Padding}, - }; - - let area = frame.area(); - // The context and path choices both carry full paths, so they are wider - // and grow with what they list. - let choosing = overlay.phase == CreatePersonaPhase::Context; - let path = overlay.phase == CreatePersonaPhase::Path; - let popup_width = - if choosing || path { 84u16 } else { 64u16 }.min(area.width.saturating_sub(4)); - let popup_height = if choosing { - (overlay.context_options.len() + overlay.messages.len()) as u16 + 9 - } else if path { - // Two rows, two explanatory lines, the charset hint, the key line, - // and whatever the path was refused for. - overlay.messages.len() as u16 + 13 - } else { - 11u16 - } - .min(area.height.saturating_sub(2)) - .max(7); - - let [popup_area] = Layout::vertical([Constraint::Length(popup_height)]) - .flex(Flex::Center) - .areas(area); - let [popup_area] = Layout::horizontal([Constraint::Length(popup_width)]) - .flex(Flex::Center) - .areas(popup_area); - - frame.render_widget(Clear, popup_area); - - let block = Block::bordered() - .title(" Create persona DID ") - .title_style(Style::new().fg(COLOR_ORANGE).bold()) - .border_style(Style::new().fg(COLOR_ORANGE)) - .padding(Padding::uniform(1)); - - let mut lines: Vec = Vec::new(); - match overlay.phase { - CreatePersonaPhase::Label => { - lines.push(Line::from(Span::styled( - "Label for the new persona:", - Style::new().fg(COLOR_TEXT_DEFAULT), - ))); - lines.push(Line::from(Span::styled( - format!("> {}", overlay.label.value()), - Style::new().fg(COLOR_SOFT_PURPLE).bold(), - ))); - lines.push(Line::default()); - for msg in &overlay.messages { - lines.push(Line::from(Span::styled( - msg.clone(), - Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), - ))); - } - lines.push(Line::from(Span::styled( - "⏎ next esc cancel", - Style::new().fg(COLOR_BORDER), - ))); - } - CreatePersonaPhase::Path => { - use crate::state_handler::main_page::content::PersonaPathChoice; - let custom = overlay.path_choice == PersonaPathChoice::Custom; - lines.push(Line::from(Span::styled( - "Where should this persona's DID live on the hosting server?", - Style::new().fg(COLOR_TEXT_DEFAULT), - ))); - lines.push(Line::from(Span::styled( - "The path is part of the DID, and cannot be changed afterwards.", - Style::new().fg(COLOR_BORDER), - ))); - lines.push(Line::default()); - - let row = |selected: bool, text: String| { - let style = if selected { - Style::new().fg(COLOR_SUCCESS).bold() - } else { - Style::new().fg(COLOR_TEXT_DEFAULT) - }; - Line::from(Span::styled( - format!("{}{text}", if selected { "▸ " } else { " " }), - style, - )) - }; - lines.push(row( - !custom, - "Server-assigned (a random, unguessable path)".to_string(), - )); - lines.push(row( - custom, - format!( - "My own path: {}{}", - overlay.path.value(), - if custom { "▎" } else { "" } - ), - )); - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - if custom { - "Lowercase letters, digits and hyphens; '/' separates segments." - } else { - "A typed path is public and memorable — and may already be taken." - }, - Style::new().fg(COLOR_BORDER), - ))); - for msg in &overlay.messages { - lines.push(Line::from(Span::styled( - msg.clone(), - Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), - ))); - } - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "↑/↓ choose type: name the path ⏎ next esc back", - Style::new().fg(COLOR_BORDER), - ))); - } - CreatePersonaPhase::Context => { - use openvtc_core::config::community_context::ContextKind; - lines.push(Line::from(Span::styled( - "Where should this persona's keys and DID live?", - Style::new().fg(COLOR_TEXT_DEFAULT), - ))); - lines.push(Line::from(Span::styled( - "A persona is presented from the context it is minted in.", - Style::new().fg(COLOR_BORDER), - ))); - lines.push(Line::default()); - for (i, option) in overlay.context_options.iter().enumerate() { - let selected = i == overlay.context_selected; - let text = match option.kind { - ContextKind::New => { - let parent = openvtc_core::config::context_path::parse_sub_context_id( - &option.context_id, - ) - .map_or(option.context_id.as_str(), |(parent, _)| parent); - format!( - "{parent}/{}{} (a context of its own)", - overlay.context_slug, - if selected { "▎" } else { "" } - ) - } - ContextKind::Existing | ContextKind::Top => option.summary(), - }; - let style = if selected { - Style::new().fg(COLOR_SUCCESS).bold() - } else { - Style::new().fg(COLOR_TEXT_DEFAULT) - }; - lines.push(Line::from(Span::styled( - format!("{}{text}", if selected { "▸ " } else { " " }), - style, - ))); - } - for msg in &overlay.messages { - lines.push(Line::from(Span::styled( - msg.clone(), - Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), - ))); - } - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "↑/↓ choose type: name the new context ⏎ create esc back", - Style::new().fg(COLOR_BORDER), - ))); - } - CreatePersonaPhase::Working => { - for msg in &overlay.messages { - lines.push(Line::from(Span::styled( - msg.clone(), - Style::new().fg(COLOR_TEXT_DEFAULT), - ))); - } - } - CreatePersonaPhase::Done => { - lines.push(Line::from(Span::styled( - "✓ Persona created", - Style::new().fg(COLOR_SUCCESS).bold(), - ))); - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - overlay.did.clone().unwrap_or_default(), - Style::new().fg(COLOR_SOFT_PURPLE), - ))); - lines.push(Line::default()); - if overlay.copied { - lines.push(Line::from(Span::styled( - "(copied to clipboard)", - Style::new().fg(COLOR_SUCCESS), - ))); - } - lines.push(Line::from(Span::styled( - "c: copy again ⏎/esc close", - Style::new().fg(COLOR_BORDER), - ))); - } - CreatePersonaPhase::Failed => { - for msg in &overlay.messages { - lines.push(Line::from(Span::styled( - msg.clone(), - Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), - ))); - } - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "⏎/esc close", - Style::new().fg(COLOR_BORDER), - ))); - } - } - - frame.render_widget(Paragraph::new(lines).block(block), popup_area); + crate::ui::pages::create_persona_overlay::render(frame, overlay); } /// Render the agent-name manager popup: the persona's registry (served and diff --git a/openvtc/src/ui/pages/mod.rs b/openvtc/src/ui/pages/mod.rs index 7c7fd5f4..5e2ca1e2 100644 --- a/openvtc/src/ui/pages/mod.rs +++ b/openvtc/src/ui/pages/mod.rs @@ -14,6 +14,7 @@ use crossterm::event::KeyEvent; use ratatui::Frame; use tokio::sync::mpsc::UnboundedSender; +pub mod create_persona_overlay; pub mod join_flow; pub mod loading; pub mod main;