Question nav: offer followups by reach, not by carrying any chain
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.
The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.
Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
bd12dd4e99
commit
74bba2e9b1
+38
-19
@@ -213,7 +213,10 @@ fn PortalShell() -> impl IntoView {
|
||||
|(path, chain)| get_question(path, chain),
|
||||
);
|
||||
let user = Resource::new(|| (), |_| current_user());
|
||||
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
|
||||
let nav = Resource::new(
|
||||
move || (path.get(), chain.get()),
|
||||
|(path, chain)| list_qualifying_questions(path, chain),
|
||||
);
|
||||
let announcements = Resource::new(|| (), |_| list_announcements());
|
||||
provide_context(QuestionRes(question));
|
||||
provide_context(UserRes(user));
|
||||
@@ -289,7 +292,6 @@ fn QuestionView(
|
||||
site: SiteConfig,
|
||||
) -> impl IntoView {
|
||||
let question_id = question.id.clone();
|
||||
let has_chain = parent_hash.is_some();
|
||||
|
||||
// The provenance counterpart to the qualifies gate below: a
|
||||
// requires_chain page whose visitor holds no verifiable lineage to
|
||||
@@ -318,7 +320,7 @@ fn QuestionView(
|
||||
</section>
|
||||
</div>
|
||||
// A gate is never a dead end: the same nav as every page.
|
||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
||||
<QuestionNav current_id=question_id/>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
@@ -355,7 +357,7 @@ fn QuestionView(
|
||||
}}
|
||||
</section>
|
||||
</div>
|
||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
||||
<QuestionNav current_id=question_id/>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
@@ -418,7 +420,7 @@ fn QuestionView(
|
||||
.map(|r| {
|
||||
view! { <ResponsibleNote responsible=r/> }
|
||||
})}
|
||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
||||
<QuestionNav current_id=question_id/>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
@@ -427,8 +429,7 @@ fn QuestionView(
|
||||
/// small nav - how a concern reaches the people it speaks to without
|
||||
/// claiming front-page space (an owner also sees the gated desks here).
|
||||
#[component]
|
||||
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
||||
let _ = has_chain; // keyed into the shared resource by PortalShell
|
||||
fn QuestionNav(current_id: String) -> impl IntoView {
|
||||
let NavRes(nav) = expect_context();
|
||||
view! {
|
||||
<Suspense fallback=|| ()>
|
||||
@@ -2029,16 +2030,21 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
||||
Ok(state.site.load().as_ref().clone())
|
||||
}
|
||||
|
||||
/// Every question the current visitor qualifies for, as (id, name) -
|
||||
/// the site nav's data. Context-dependent: an owner session sees the
|
||||
/// gated pages too, and `followup` pages only appear once the visitor
|
||||
/// carries an answer chain.
|
||||
/// Every question the visitor may be offered from `current`, as (id,
|
||||
/// name) - the question nav's data. Front-door pages the visitor
|
||||
/// qualifies for are always in (an owner session sees the gated desks
|
||||
/// too); `followup` pages and ended events only when they are on the
|
||||
/// visitor's path - the page their verified `?chain=` answered - or
|
||||
/// further down from `current` (see `content::outcomes_reachable_from`).
|
||||
/// A chain unlocks the followup it came from, never every followup on
|
||||
/// the site; an unverifiable hash unlocks nothing.
|
||||
#[server(endpoint = "list_qualifying_questions")]
|
||||
pub async fn list_qualifying_questions(
|
||||
has_chain: bool,
|
||||
current: String,
|
||||
chain: Option<String>,
|
||||
) -> Result<Vec<(String, String)>, ServerFnError> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::content::is_qualified;
|
||||
use crate::content::{is_qualified, outcomes_reachable_from, Question};
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
@@ -2049,14 +2055,27 @@ pub async fn list_qualifying_questions(
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mut out: Vec<(String, String)> = state
|
||||
.questions
|
||||
.load()
|
||||
let questions = state.questions.load();
|
||||
// The one page the chain verifiably answered - the same lookup a
|
||||
// `requires_chain` gate does, so a forged or foreign hash resolves
|
||||
// to nothing relevant.
|
||||
let answered: Option<String> = match &chain {
|
||||
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
|
||||
.await
|
||||
.map(|node| node.question_id),
|
||||
None => None,
|
||||
};
|
||||
// An ended event page is a followup from then on.
|
||||
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
|
||||
let reach = outcomes_reachable_from(&questions, ¤t, &is_outcome);
|
||||
let mut out: Vec<(String, String)> = questions
|
||||
.values()
|
||||
.filter(|q| is_qualified(user.as_ref(), q))
|
||||
// An ended event page is a followup from then on - only a
|
||||
// visitor carrying a chain (they answered it) still sees it.
|
||||
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
|
||||
.filter(|q| {
|
||||
!is_outcome(q)
|
||||
|| answered.as_deref() == Some(q.id.as_str())
|
||||
|| reach.contains(&q.id)
|
||||
})
|
||||
// A live event is announced in the header instead.
|
||||
.filter(|q| !q.event_live_at(now_ms))
|
||||
// A dynamic page has no URL of its own to link to.
|
||||
|
||||
Reference in New Issue
Block a user