bada72f87e
Track each authenticated user's last-viewed route + light view state server-side, and on next sign-in redirect them to that state, falling back to the empty-state home only when there's no recorded state. Backend: - migration 022_user_session_state.sql: one row per user (user_id PK/FK, last_route, last_route_state JSON-as-TEXT, resume_enabled default 1, last_updated_at). - PUT /api/me/last-state (require_user): upserts route + light state; no-ops when resume_enabled=0. Localized in the /me region. - /api/auth/me payload now carries resume_enabled + last_route + decoded last_route_state (no extra round-trip). - test_session_resume_vertical.py: auth-required, upsert/read-back, per-user isolation, resume_enabled=0 disable. Frontend: - lib/useLastState.js: debounced (~1s) route-change PUT for authenticated users; one-time resume redirect on sign-in, gated on identify having fired (preserves #21 Part C identify-then-track). - api.js: putLastState() client call. - App.jsx: import + call the hook; set identifyReady after identify. Header region untouched. Per-user (not per-device); profile-settings opt-out toggle UI deferred (column + default-on behavior ship now). Stored state is route + light view state ONLY, never draft buffers — documented in SPEC §6.8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
458 lines
20 KiB
React
458 lines
20 KiB
React
import { useEffect, useRef, useState } from 'react'
|
|
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
|
import { getMe, subscribeToNotifications } from './api'
|
|
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
|
import { useLastState } from './lib/useLastState'
|
|
import Catalog from './components/Catalog.jsx'
|
|
import Inbox from './components/Inbox.jsx'
|
|
import RFCView from './components/RFCView.jsx'
|
|
import PRView from './components/PRView.jsx'
|
|
import ProposalView from './components/ProposalView.jsx'
|
|
import ProposeModal from './components/ProposeModal.jsx'
|
|
import Landing from './components/Landing.jsx'
|
|
import Login from './components/Login.jsx'
|
|
import BetaPending from './components/BetaPending.jsx'
|
|
import Philosophy from './components/Philosophy.jsx'
|
|
import DocsLayout from './components/DocsLayout.jsx'
|
|
import DocsUserGuide from './components/DocsUserGuide.jsx'
|
|
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
|
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
|
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
|
import DocsSpec from './components/DocsSpec.jsx'
|
|
import DocsSpecsIndex from './components/DocsSpecsIndex.jsx'
|
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
|
import Admin from './components/Admin.jsx'
|
|
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
|
import InviteClaim from './components/InviteClaim.jsx'
|
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
|
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
|
import Privacy from './pages/Privacy.jsx'
|
|
import Cookies from './pages/Cookies.jsx'
|
|
import './App.css'
|
|
|
|
export default function App() {
|
|
const [me, setMe] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [proposeOpen, setProposeOpen] = useState(false)
|
|
const [catalogVersion, setCatalogVersion] = useState(0)
|
|
const [inboxOpen, setInboxOpen] = useState(false)
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
const [inboxTick, setInboxTick] = useState(0)
|
|
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
|
|
// re-open even if the user has already made a choice. The settings
|
|
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
|
// event that bumps this.
|
|
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
|
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
|
|
// has fired (or once we've confirmed there's no authenticated user to
|
|
// identify). useLastState gates its resume redirect on this so the
|
|
// redirect always happens AFTER identify, preserving identify-then-
|
|
// track ordering.
|
|
const [identifyReady, setIdentifyReady] = useState(false)
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
|
// route change; the analytics wrapper itself decides whether
|
|
// anything ships out (consent + key check). The first fire is
|
|
// also covered because `location` is set on mount.
|
|
const lastPathRef = useRef(null)
|
|
useEffect(() => {
|
|
const path = location.pathname + (location.search || '')
|
|
if (lastPathRef.current === path) return
|
|
lastPathRef.current = path
|
|
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
|
|
}, [location.pathname, location.search])
|
|
|
|
// v0.15.0 + #21 Part C — bind the authenticated user id AND
|
|
// durable user properties to the analytics session when sign-in
|
|
// lands; reset on sign-out (viewer flips to null). The wrapper
|
|
// queues these calls until consent + init resolve, so the order
|
|
// is safe even on a cold load.
|
|
//
|
|
// Property bag passed to identify (set vs setOnce per #21 Part C):
|
|
// set: role, permission_state, passcode_set, device_trusted
|
|
// (these can change mid-account-life — refresh each sign-in)
|
|
// setOnce: first_sign_in_at, account_created_at
|
|
// (immutable user-history markers — set on the first
|
|
// sign-in that observes them, never overwritten)
|
|
//
|
|
// PII discipline: NO email, NO display_name, NO gitea_login passed
|
|
// through — Amplitude only sees opaque ids + enums + timestamps +
|
|
// booleans.
|
|
const lastUserIdRef = useRef(null)
|
|
useEffect(() => {
|
|
const uid = me?.authenticated ? me.user?.id : null
|
|
const viewer = me?.authenticated ? me.user : null
|
|
if (uid != null && lastUserIdRef.current !== uid) {
|
|
lastUserIdRef.current = uid
|
|
const props = {}
|
|
if (viewer?.role != null) props.role = viewer.role
|
|
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
|
|
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
|
|
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
|
|
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
|
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
|
identify({ user_id: String(uid), properties: props })
|
|
// v0.23.0 / item #29 — identify has now fired for this sign-in;
|
|
// release useLastState's resume redirect (it waits on this).
|
|
setIdentifyReady(true)
|
|
} else if (uid == null && lastUserIdRef.current != null) {
|
|
// Sign-out edge — App-level reset is handled separately by the
|
|
// sign-out gesture that fires User Signed Out. Clear our local
|
|
// memo so a fresh sign-in re-fires identify.
|
|
lastUserIdRef.current = null
|
|
}
|
|
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
|
|
|
// v0.23.0 / item #29 — once `me` has resolved, if there's no
|
|
// authenticated user there is nothing to identify, so release the
|
|
// resume gate immediately (anonymous boots have no resume to do, but
|
|
// the hook still needs the gate resolved to be a clean no-op).
|
|
useEffect(() => {
|
|
if (me != null && !me.authenticated) setIdentifyReady(true)
|
|
}, [me])
|
|
|
|
useEffect(() => {
|
|
const handler = () => setConsentReopenTick(t => t + 1)
|
|
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
|
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
getMe()
|
|
.then(setMe)
|
|
.catch(() => setMe({ authenticated: false }))
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
|
|
// debounce-posts the current route for authenticated users and, once
|
|
// identify has fired, redirects a fresh sign-in (which hard-lands on
|
|
// "/") to the user's stored last route. Anonymous users: no-op.
|
|
useLastState({
|
|
authenticated: !!me?.authenticated,
|
|
pathname: location.pathname,
|
|
identifyReady,
|
|
lastRoute: me?.authenticated ? me.user?.last_route : null,
|
|
navigate,
|
|
})
|
|
|
|
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
|
// so the badge counter and the toast surface stay in lockstep with
|
|
// the inbox. Tabs that miss an event because they were closed pick
|
|
// it up on next sign-in via the snapshot frame.
|
|
useEffect(() => {
|
|
if (!me?.authenticated) return undefined
|
|
const close = subscribeToNotifications({
|
|
onSnapshot: payload => setUnreadCount(payload.unread_count || 0),
|
|
onNotification: payload => {
|
|
setUnreadCount(c => c + 1)
|
|
setInboxTick(t => t + 1)
|
|
// §15.3: personal-direct events get a toast even when the user
|
|
// isn't on the relevant view — they're the named subject.
|
|
// Churn never toasts; structural toasts only when it lands on
|
|
// a slug the user is currently viewing (URL match).
|
|
const isPersonal = payload.category === 'personal-direct'
|
|
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
|
|
if (isPersonal || onCurrentSlug) {
|
|
showToast({
|
|
summary: payload.summary,
|
|
category: payload.category,
|
|
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
|
|
})
|
|
}
|
|
},
|
|
onRead: () => {
|
|
setUnreadCount(c => Math.max(0, c - 1))
|
|
setInboxTick(t => t + 1)
|
|
},
|
|
})
|
|
return close
|
|
}, [me?.authenticated])
|
|
|
|
if (loading) {
|
|
return <div className="boot">Loading…</div>
|
|
}
|
|
|
|
// The deployment is in private beta: anonymous visitors get the full
|
|
// app in read-only mode (viewer = null is passed through to every
|
|
// component), and write affordances are hidden at the component
|
|
// level. v0.8.0 (§6.1 / item #6): authenticated users with
|
|
// `permission_state='pending'` also pass through as `viewer` with
|
|
// their state attached — every write-gated affordance reads the
|
|
// state and treats pending the same as anonymous, while reads
|
|
// remain open. The /beta-pending page is the home root for a
|
|
// pending user.
|
|
const viewer = me?.authenticated ? me.user : null
|
|
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
|
const isPending = viewer && viewer.permission_state === 'pending'
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="app-header">
|
|
<div className="app-brand">
|
|
<Link to="/">{import.meta.env.VITE_APP_NAME}</Link>
|
|
</div>
|
|
<div className="header-right">
|
|
{/* §14.3: the persistent About link. One word, no badge, no
|
|
state — visible from every screen so a viewer mid-PR who
|
|
wonders why a conversation is public can reach the answer
|
|
in two clicks. Anonymous viewers see it too. */}
|
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
|
Philosophy
|
|
</Link>
|
|
<Link to="/docs" className="header-about" title="User guide">
|
|
Docs
|
|
</Link>
|
|
{viewer && (
|
|
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
|
Settings
|
|
</Link>
|
|
)}
|
|
{isAdmin && (
|
|
<Link to="/admin" className="header-admin" title="Admin home base">
|
|
Admin
|
|
</Link>
|
|
)}
|
|
{viewer && (
|
|
<button
|
|
className="inbox-trigger"
|
|
onClick={() => setInboxOpen(o => !o)}
|
|
aria-label="Inbox"
|
|
title="Inbox (§15.2)"
|
|
>
|
|
<svg
|
|
width="18" height="18" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" strokeWidth="1.75"
|
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
|
>
|
|
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
|
<path d="m3.5 6.5 8.5 6 8.5-6" />
|
|
</svg>
|
|
{unreadCount > 0 && (
|
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
{viewer ? (
|
|
<>
|
|
<span className="user-name">{viewer.display_name}</span>
|
|
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
|
|
<a
|
|
className="btn-link"
|
|
href="/auth/logout"
|
|
onClick={() => {
|
|
// v0.15.0 — fire the sign-out event before the
|
|
// hard nav. The wrapper's track() is sync-enqueue;
|
|
// the underlying SDK flush is best-effort across
|
|
// navigation. anonymize() clears the user binding
|
|
// so any post-nav anonymous events on the next
|
|
// page aren't attributed to the prior user.
|
|
track(EVENTS.USER_SIGNED_OUT)
|
|
anonymize()
|
|
}}
|
|
>Sign out</a>
|
|
</>
|
|
) : (
|
|
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
|
Sign in <span className="beta-chip">Beta</span>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</header>
|
|
{isPending && <PendingAccessBanner />}
|
|
<div className="app-body">
|
|
<Routes>
|
|
<Route path="/welcome" element={<Landing />} />
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
|
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
|
|
Anonymous viewers see a sign-in prompt; signed-in users
|
|
see the preview + accept gesture. */}
|
|
<Route path="/invitations/accept" element={
|
|
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
|
|
} />
|
|
{/* v0.17.0 — roadmap item #16. The claim landing page for
|
|
admin-issued invites. Anonymous-reachable; the call
|
|
itself establishes the session on success. */}
|
|
<Route path="/invites/claim" element={<InviteClaim />} />
|
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
|
{/* v0.19.0 / roadmap item #30 — /docs/* is a hub with sub-nav.
|
|
The bare /docs path redirects to the user guide; sessions
|
|
browser lives at /docs/sessions/*. See DocsLayout.jsx
|
|
for the flyout shape and CHANGELOG v0.19.0 for the
|
|
upgrade path. */}
|
|
<Route path="/docs" element={<Navigate to="/docs/user-guide" replace />} />
|
|
<Route path="/docs/*" element={<DocsWithSidebar viewer={viewer} />} />
|
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
|
Available to anonymous and authenticated viewers alike. */}
|
|
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
|
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
|
|
{viewer && (
|
|
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
|
)}
|
|
{isAdmin && (
|
|
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
|
|
)}
|
|
<Route path="*" element={
|
|
<>
|
|
<Catalog
|
|
viewer={viewer}
|
|
onProposeRFC={() => setProposeOpen(true)}
|
|
version={catalogVersion}
|
|
/>
|
|
<main className="main-pane">
|
|
<Routes>
|
|
<Route path="/" element={<Welcome viewer={viewer} />} />
|
|
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
|
|
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
|
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
|
</Routes>
|
|
</main>
|
|
</>
|
|
} />
|
|
</Routes>
|
|
</div>
|
|
{proposeOpen && viewer && (
|
|
<ProposeModal
|
|
viewer={viewer}
|
|
onClose={() => setProposeOpen(false)}
|
|
onSubmitted={({ pr_number }) => {
|
|
setProposeOpen(false)
|
|
setCatalogVersion(v => v + 1)
|
|
navigate(`/proposals/${pr_number}`)
|
|
}}
|
|
/>
|
|
)}
|
|
{inboxOpen && viewer && (
|
|
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
|
)}
|
|
<ToastHost />
|
|
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PolicyShell({ children }) {
|
|
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
|
|
// render full-width without the catalog rail. The components inside
|
|
// carry their own back affordance per Philosophy.jsx's pattern.
|
|
return <main className="chrome-pane">{children}</main>
|
|
}
|
|
|
|
function PhilosophyWithSidebar({ viewer }) {
|
|
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
|
// all use the full app body — no catalog left pane, no propose modal.
|
|
// The header carries the navigation back; the body is a single
|
|
// reading surface.
|
|
return (
|
|
<main className="chrome-pane">
|
|
<Philosophy authenticated={!!viewer} />
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function DocsWithSidebar({ viewer }) {
|
|
// v0.19.0 / roadmap item #30 — the `/docs/*` surface is a flyout
|
|
// shell with sub-routes. The shell (sidebar + content area) is the
|
|
// DocsLayout outlet host; the sub-routes mount their respective
|
|
// pages into the outlet. Bare `/docs/sessions` redirects to the
|
|
// sessions about page so deep-linkers and the flyout's "Sessions"
|
|
// header both land somewhere coherent.
|
|
return (
|
|
<main className="chrome-pane">
|
|
<Routes>
|
|
<Route element={<DocsLayout authenticated={!!viewer} />}>
|
|
<Route index element={<Navigate to="user-guide" replace />} />
|
|
<Route path="user-guide" element={<DocsUserGuide />} />
|
|
<Route path="sessions" element={<Navigate to="about" replace />} />
|
|
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
|
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
|
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
|
{/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec
|
|
at runtime via gitea raw). Bare /docs/specs lands on the
|
|
client-side redirect to the first configured spec. */}
|
|
<Route path="specs" element={<DocsSpecsIndex />} />
|
|
<Route path="specs/:name" element={<DocsSpec />} />
|
|
</Route>
|
|
</Routes>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function NotificationSettingsWithSidebar({ viewer }) {
|
|
return (
|
|
<main className="chrome-pane">
|
|
<NotificationSettings viewer={viewer} />
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function AdminWithSidebar({ viewer }) {
|
|
return (
|
|
<main className="chrome-pane">
|
|
<Admin viewer={viewer} />
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function PendingAccessBanner() {
|
|
// v0.8.0 — thin banner shown on every page (other than /beta-pending
|
|
// itself, which carries the same message in larger form) when the
|
|
// signed-in user's `permission_state='pending'`. Sign-out works
|
|
// normally via the header affordance.
|
|
return (
|
|
<div className="pending-access-banner">
|
|
Your beta access request is in review.{' '}
|
|
<Link to="/beta-pending">Learn more →</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Welcome({ viewer }) {
|
|
// v0.8.0 — a pending user landing on "/" gets the same page they'd
|
|
// see at /beta-pending, inline. This is the post-OTC home root for
|
|
// a user awaiting admin grant.
|
|
if (viewer && viewer.permission_state === 'pending') {
|
|
return <BetaPending viewer={viewer} />
|
|
}
|
|
if (!viewer) {
|
|
return (
|
|
<div className="welcome">
|
|
<h1>Welcome.</h1>
|
|
<p>
|
|
The catalog on the left lists every super-draft and active RFC in the
|
|
framework. Open one to read the canonical body and the public
|
|
conversation behind each definition.
|
|
</p>
|
|
<p>
|
|
Discussion and contribution are in private <strong>Beta</strong> —
|
|
read freely, and <Link to="/login">sign in</Link> if your email has
|
|
been invited.
|
|
</p>
|
|
<p>
|
|
Wondering why a conversation is public, why graduation costs what it
|
|
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
|
philosophy</Link>.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div className="welcome">
|
|
<h1>Welcome, {viewer.display_name}.</h1>
|
|
<p>
|
|
The catalog on the left lists every super-draft and active RFC in the
|
|
framework. Open one to read the canonical body, or use{' '}
|
|
<strong>Propose New RFC</strong> at the bottom of the catalog to open an
|
|
idea PR against the meta repository.
|
|
</p>
|
|
<p>
|
|
Wondering why a conversation is public, why graduation costs what it
|
|
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
|
philosophy</Link>.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|