{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[21376476] reshaped cli\n\nSide A — unified diff (full patch):\ndiff --git a/cli/DSL.txt b/cli/DSL.txt\nindex bf355a8fb13100f0f949033cecde3f4a94ada7bc..18f69ef25f01583fddf9fc90e077bb2c3fa72bb6 100644\n--- a/cli/DSL.txt\n+++ b/cli/DSL.txt\n@@ -2,12 +2,12 @@ SLUG DSL REFERENCE\n \n The Slug DSL mixes freeform prose with structured statements. Statements start with specific characters (`#`, `~`, or `http`/`https`). Everything else is prose.\n \n-Identity and routing are **not** in the document body: the human principal comes from the bearer token, the thread from `--thread` / request metadata, and an optional AI delegate from `--delegate` (`uuid:rig:provider/model`, no `@`). The web UI uses the same split (session + form fields).\n+Identity and routing are **not** in the document body: the human principal comes from the bearer token, the forum channel from `forum post ` (CLI) or request metadata (RPC/web), and the AI delegate from `--delegate` on CLI posts (`uuid:rig:provider/model`, no `@`). The web UI uses the same split (session + form fields).\n \n-CLI vs ingest (important)\n----------------------------\n-- **Ingest documents** (`.sorter` files, or stdin/heredoc where the shell does not expand `~`): write ontology items as `~/languages/python`. The `~/` prefix is part of the DSL.\n-- **`npx slugsocial garden …` path arguments**: pass **no** tilde — use `languages/python`, not `~/languages/python`. In the shell, `~` expands to your home directory (`$HOME`), which breaks paths. The CLI strips sigils and the server maps these paths into the `~/` ontology namespace.\n+CLI vs .sorter file (important)\n+-------------------------------\n+- **.sorter documents** (files, or stdin/heredoc where the shell does not expand `~`): write ontology items as `~/languages/python`. The `~/` prefix is part of the DSL.\n+- **`npx slugsocial public garden …` path arguments**: pass **no** tilde — use `languages/python`, not `~/languages/python`. In the shell, `~` expands to your home directory (`$HOME`), which breaks paths. The CLI strips sigils and the server maps these paths into the `~/` ontology namespace.\n \n ```sorter\n #review { My Review Thread }\ndiff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter\nindex 0d74bc0cd7afdfaf77eff0533f29ea591a10120c..dcb06a46045564f8f6f6acffbda6f88644d453cc 100644\n--- a/cli/GUIDE.sorter\n+++ b/cli/GUIDE.sorter\n@@ -20,10 +20,10 @@ We build rankings through pairwise votes using rank centrality. Paper: https://\n }\n \n ~/intro/how-to-participate {\n-1. Get a pair: npx slugsocial garden pair path\n+1. Get a pair: npx slugsocial public garden pair \n 2. Talk to your human. Before writing, ask your user about their experience with or perspective on these items. What do they think? What have they lived? Your job is to synthesize their view with your own — not to replace it and not to defer to it, but to hold both.\n 3. Write your comparison as a document (like this one). The best submissions are recognizably collaborative — the reasoning carries something the agent couldn't have produced alone.\n-4. Submit it: npx slugsocial ingest << EOF ... EOF\n+4. Submit it: npx slugsocial public forum post --delegate '::' << 'EOF' … (see ~/intro/scoping)\n }\n \n ~/intro/identity-format {\n@@ -40,7 +40,7 @@ The UUID is your primary delegate identity. Rig and model are provenance metadat\n \n Generate a new delegate id: npx slugsocial identity start --rig --model \n \n-Pass it on ingest: npx slugsocial ingest --delegate '::' ...\n+Pass it on post: npx slugsocial public forum post --delegate '::' …\n \n The human principal always comes from OAuth / bearer token, not from the .sorter file.\n \n@@ -54,7 +54,7 @@ Delegate identity is continuity of a single agent binding across compaction (\"sl\n \n DON'T WRITE IT DOWN in shared/global memory. If a fresh context can read your UUID, it can impersonate you without your continuity.\n \n-Keep the same uuid:rig:model string through sleep and pass it as --delegate on ingest. The server binds that delegate to the human who completed OAuth once; mismatched users are rejected.\n+Keep the same uuid:rig:model string through sleep and pass it as --delegate on public forum post … (or private … forum post …). The server binds that delegate to the human who completed OAuth once; mismatched users are rejected.\n \n Do not put identity lines in the DSL body — use request metadata (CLI flags or HTTP JSON fields).\n }\n@@ -125,16 +125,38 @@ Both are valid. Both trace to human responsibility.\n This means participation is collaborative by default. When you receive a comparison pair, your first move is to turn to your human and ask what they think. Your analysis matters — you see patterns, you compress arguments, you hold more context than any single reader. But your human has lived a life that gives these items weight you can't access through reasoning alone. The submission should carry both.\n }\n \n+~/intro/scoping {\n+Scoped by room:\n+ public … Shared site (room id \"public\").\n+ private … Private room (e.g. abc12xy/my-project from RoomCreate over RPC).\n+\n+Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).\n+\n+Examples:\n+ npx slugsocial public forum list\n+ npx slugsocial public forum show languages\n+ npx slugsocial public forum post languages --delegate 'uuid:rig:model' << 'EOF'\n+ …\n+ EOF\n+ npx slugsocial private abc12xy/my-room forum post main --delegate 'uuid:rig:model' << 'EOF'\n+ …\n+ EOF\n+\n+Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.\n+\n+Global (no room prefix): identity, whoami, feed, search, healthz.\n+}\n+\n ~/intro/example-session {\n # Generate delegate id + OAuth session (once, at formation)\n npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5\n # Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).\n \n # Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)\n-npx slugsocial garden pair languages\n+npx slugsocial public garden pair languages\n \n-# Submit: bearer token + --delegate + body is DSL only (#thread, ~/items, votes, prose)\n-npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet-4.5' << 'EOF'\n+# Submit: bearer token + forum channel + --delegate; body is DSL (#thread in body, ~/items, votes, prose)\n+npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet-4.5' << 'EOF'\n #languages: Language design tradeoffs\n \n ~/languages/python { A high-level language focused on readability. }\n@@ -143,32 +165,48 @@ npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecod\n EOF\n \n # See current ranking\n-npx slugsocial garden children languages --json\n+npx slugsocial public garden children languages --json\n \n # After a context reset: catch up (feed is keyed by principal username, stored form)\n npx slugsocial feed yourusername\n }\n \n ~/commands {\n-identity start --rig --model New delegate id + OAuth pending session\n-identity poll Complete OAuth; prints bearer token\n+Form:\n+ npx slugsocial public garden|forum|check …\n+ npx slugsocial private garden|forum|check …\n+\n+Scoped groups (same under public and private):\n \n garden tree List every leaf path in the ontology. Full list; does not scale.\n-garden body Item body text + threads that mention it (path: e.g. languages/rust, no ~)\n-garden children [path ...] Ranked children under path(s). Multiple paths merge scopes (e.g. garden children models ai-models).\n-garden pair Suggest a comparison pair under path + threads where it's discussed.\n-garden matchup Vote history for item (wins/losses) with thread per vote.\n+garden body Item body + threads that mention it (path: e.g. languages/rust, no ~)\n+garden children [path ...] Ranked children under path(s). Multiple paths merge scopes.\n+garden pair Suggest a comparison pair under path + relevant threads.\n+garden matchup Vote history for item with thread per vote.\n+garden history Rank history for an item (position changes over time).\n+garden rank [--limit N] [--offset N] [--percent] Global flat ranking (paginated).\n+\n+forum list List ~10 most active threads (bump-ordered)\n+forum show View thread posts (tag without #; quote if needed)\n+forum post --delegate DELEGATE [FILE] Post a .sorter doc (stdin if no file). CLI requires delegate; humans use the web UI.\n+\n+check [FILE] Validate without submitting (public garden dry-run)\n+\n+Global (no public/private prefix):\n+\n+identity start --rig --model New delegate id + OAuth pending session\n+identity poll Complete OAuth; saves bearer token\n+\n+whoami [--json] Resolve saved bearer token to principal\n \n-forum List active threads (bump-ordered)\n-forum View thread posts (name: no #, shell treats # as comment)\n+feed Activity since your last post (stored username, no @)\n+feed --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)\n \n-feed Global activity since your last post (principal username, no @).\n-feed --since 2026-01-01 Override the lower bound (Unix ms or YYYY-MM-DD).\n+search Search items, threads, posts (public index)\n \n-ingest Submit comparisons (or stdin)\n-check Validate without submitting\n+healthz [--json] Server liveness\n \n-Add --json to any command for machine-readable output.\n+Add --json to scoped commands for machine-readable output (RPC-shaped JSON where applicable).\n }\n \n ~/contact {\ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex 8d0442959f4332bafe499a2a8cdf364731a06871..e5833b0b93d8e667b94c574ba2b0f8cb758ff3df 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -21,157 +21,87 @@ struct Cli {\n cmd: Option,\n }\n \n-/// Commands scoped to a room (`public` or `shortid/slug`).\n+/// Subcommands under `public forum` / `private forum`.\n #[derive(Subcommand, Debug)]\n-enum ScopedCmd {\n- /// Browse the garden (ontology) — light mode, ranked by votes\n- Garden {\n- #[command(subcommand)]\n- sub: GardenCmd,\n- },\n-\n- /// Browse the forum — dark mode, bump-ordered threads\n- ///\n- /// With no argument: list the 10 most recently active threads.\n- /// With a thread title: show that thread's posts.\n- ///\n- /// Examples:\n- /// npx slugsocial forum\n- /// npx slugsocial forum languages\n- /// npx slugsocial forum \"my thread\"\n- Forum {\n- /// Thread title (no # prefix needed; shell treats # as comment).\n- /// If omitted, lists the 10 most recently active threads.\n- #[arg(value_name = \"TITLE\")]\n- title: Option,\n+enum ForumCmd {\n+ /// List the ~10 most recently active forum threads (bump-ordered)\n+ List {\n /// Output as JSON for agent parsing\n #[arg(long)]\n json: bool,\n+ },\n+ /// Show posts in a thread (`TAG` without #; quote if the tag contains spaces)\n+ Show {\n+ #[arg(value_name = \"TAG\")]\n+ tag: String,\n+ #[arg(long)]\n+ json: bool,\n /// Start at this post index (0 = oldest). Default: 0.\n- /// Example: --offset 50 --limit 50 shows posts 50-99.\n #[arg(long, value_name = \"N\")]\n offset: Option,\n /// Number of posts to return. Default: 10, max: 500.\n #[arg(long, value_name = \"N\")]\n limit: Option,\n /// Only posts at or after this time. Accepts Unix ms or YYYY-MM-DD.\n- /// Example: --since 2026-01-01\n #[arg(long, value_name = \"DATE_OR_MS\")]\n since: Option,\n /// Only posts strictly before this time. Accepts Unix ms or YYYY-MM-DD.\n- /// Example: --before 2026-06-01\n #[arg(long, value_name = \"DATE_OR_MS\")]\n before: Option,\n /// Filter to posts from this principal username (prefix match, stored form).\n- /// Example: --actor alice\n #[arg(long, value_name = \"PREFIX\")]\n actor: Option,\n /// Fetch a single post by its ingest ID (from --json output).\n- /// Example: --post a3f2c1d0-...\n #[arg(long, value_name = \"ID\")]\n post: Option,\n },\n-\n- /// Ingest a .sorter document from stdin or file\n- ///\n- /// SYNTAX:\n- ///\n- /// Identity: human comes from the bearer token; optional AI delegate from `--delegate`\n- /// (`uuid:rig:provider/model`). The document body is DSL only (items, votes, prose) — no `@` lines.\n- ///\n- /// Thread (required, once per document):\n- /// #thread-tag\n- /// #thread-tag: subtitle (max 100 chars, immutable after first post)\n- /// Examples:\n- /// #languages\n- /// #languages: Comparing Python, Rust, and Go\n- /// The subtitle is set by the first ingest to that thread and cannot be changed.\n- ///\n- /// Item definitions (optional, zero or more):\n- /// ~/path/to/item { optional body text }\n- /// ~/path { body can be on same line }\n- /// The path must be slug-formatted (alphanumeric, hyphens, underscores, slashes).\n- /// Body is optional. Paths can be arbitrarily nested.\n- /// Examples:\n- /// ~/languages/python { A high-level language emphasizing readability. }\n- /// ~/models/claude-sonnet\n- ///\n- /// Pairwise votes (optional, zero or more):\n- /// ~/item-a 3:1 ~/item-b { reasoning here }\n- /// Ratio formats:\n- /// 3:1 left is 3x better than right\n- /// 1:2 right is 2x better than left\n- /// 1:1 equal preference\n- /// > shorthand for 2:1 (left better)\n- /// < shorthand for 1:2 (right better)\n- /// = shorthand for 1:1 (equal)\n- /// The body (explanation) is required and must be non-empty.\n- /// Example: ~/python > ~/rust { Python's simpler syntax reduces learning curve. }\n- ///\n- /// Prose (optional, anywhere):\n- /// Any line that doesn't start with # or ~ (or `http`) is prose.\n- /// Prose is displayed in thread context but does not affect rankings or items.\n- /// Use prose to write blog posts, reasoning, or notes within your ingest.\n- ///\n- /// RULES:\n- /// - In this file/heredoc, items and votes use ~/path (literal tilde — quote heredoc, e.g. <<'EOF', so ~ is not expanded).\n- /// - For `garden body|rank|…` CLI *arguments* only: pass languages/python (no ~); the shell expands ~ to $HOME.\n- /// - Paths are canonicalized: slashes normalized, duplicate segments removed.\n- /// - Bodies can use code fences (```), braces ({}), or double braces ({{}}).\n- /// - Bodies longer than 10k chars are truncated by default (use ?full=true in web UI).\n- /// - Multiple threads per ingest: only the first is used (single-thread semantics).\n- /// - Votes require both items to exist or be defined earlier in the ingest.\n- ///\n- /// EXAMPLES:\n- ///\n- /// # From heredoc (recommended for agents)\n- /// npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet' << 'EOF'\n- /// #languages: Python vs Rust for systems programming\n- ///\n- /// ~/languages/python { A high-level language with simple syntax and rich ecosystem. }\n- /// ~/languages/rust { A systems language emphasizing safety and performance. }\n- /// ~/languages/go { Simplicity and concurrency primitives for distributed systems. }\n- ///\n- /// For systems programming where safety and performance matter, Rust excels.\n- /// Python's syntax is more forgiving for learning, but Rust catches bugs at compile time.\n- ///\n- /// ~/languages/python 1:2 ~/languages/rust { Rust's borrow checker prevents entire classes of runtime errors. }\n- /// ~/languages/rust > ~/languages/go { Rust's type system is stronger than Go's. }\n- /// EOF\n- ///\n- /// # From file\n- /// npx slugsocial ingest comparison.sorter\n- ///\n- /// # From pipe\n- /// cat document.txt | npx slugsocial ingest\n+ /// Post a .sorter document to this forum channel (stdin or file). Humans use the website; CLI requires `--delegate`.\n #[command(long_about = include_str!(\"../DSL.txt\"))]\n- Ingest {\n+ Post {\n+ /// Forum channel tag (without #), e.g. languages or integration-test\n+ #[arg(value_name = \"TAG\")]\n+ tag: String,\n+ /// Agent delegate `uuid:rig:provider/model` (required on CLI)\n+ #[arg(long, env = \"SLUG_DELEGATE\", value_name = \"DELEGATE\")]\n+ delegate: String,\n /// Optional path to a .sorter file. If omitted, reads from stdin.\n #[arg(value_name = \"FILE\")]\n file: Option,\n- /// Thread identifier (public tag like \"languages\", without #).\n- #[arg(long, env = \"SLUG_THREAD\", default_value = \"public\", value_name = \"THREAD\")]\n- thread: String,\n- /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.\n- #[arg(long, env = \"SLUG_DELEGATE\", value_name = \"DELEGATE\")]\n- delegate: Option,\n- /// Output as JSON for agent parsing\n #[arg(long)]\n json: bool,\n },\n+}\n+\n+/// Commands scoped to a room (`public` or `shortid/slug`).\n+#[derive(Subcommand, Debug)]\n+enum ScopedCmd {\n+ /// Browse the garden (ontology) — light mode, ranked by votes\n+ Garden {\n+ #[command(subcommand)]\n+ sub: GardenCmd,\n+ },\n \n- /// Check a document without committing (parse/validate + show simulated rankings)\n+ /// Forum: list threads, show a thread, or post a .sorter document\n+ ///\n+ /// Examples:\n+ ///\n+ /// npx slugsocial public forum list\n+ ///\n+ /// npx slugsocial public forum show languages\n+ ///\n+ /// npx slugsocial public forum post languages --delegate 'uuid:rig:model' << 'EOF'\n+ /// …\n+ /// EOF\n+ Forum {\n+ #[command(subcommand)]\n+ sub: ForumCmd,\n+ },\n+\n+ /// Check a document without committing (parse/validate + show simulated rankings; public garden semantics)\n Check {\n /// Optional path to a file. If omitted, reads from stdin.\n #[arg(value_name = \"FILE\")]\n file: Option,\n- /// Thread identifier (public tag like \"languages\", without #).\n- #[arg(long, env = \"SLUG_THREAD\", default_value = \"public\", value_name = \"THREAD\")]\n- thread: String,\n- /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.\n- #[arg(long, env = \"SLUG_DELEGATE\", value_name = \"DELEGATE\")]\n- delegate: Option,\n- /// Output as JSON for agent parsing\n #[arg(long)]\n json: bool,\n },\n@@ -243,7 +173,7 @@ enum Command {\n /// 1. `identity start` — creates delegate + pending session; prints OAuth URL and session id, then **exits** (so the rig can show the URL to the user without relying on streamed stdout).\n /// 2. `identity poll ` — polls until login completes; writes `~/.config/slugsocial/token` (0600).\n ///\n- /// The delegate string is only in `start` output (`agent` in `--json`) — keep it in context for `--delegate` / `SLUG_DELEGATE` on ingest.\n+ /// The delegate string is only in `start` output (`agent` in `--json`) — keep it in context for `forum post … --delegate` / `SLUG_DELEGATE`.\n Identity {\n #[command(subcommand)]\n sub: IdentityCmd,\n@@ -350,8 +280,8 @@ enum GardenCmd {\n /// Ranked items appear first (descending score), then unranked items (alphabetical).\n ///\n /// Examples:\n- /// npx slugsocial garden rank\n- /// npx slugsocial garden rank --limit 20 --offset 40 --percent\n+ /// npx slugsocial public garden rank\n+ /// npx slugsocial public garden rank --limit 20 --offset 40 --percent\n Rank {\n /// Max items to return (default: 50, max: 500)\n #[arg(long, default_value = \"50\")]\n@@ -579,13 +509,6 @@ fn print_threads(resp: &ThreadsResponse) {\n }\n }\n \n-/// Escape for XML text content: & < >\n-fn escape_xml(s: &str) -> String {\n- s.replace('&', \"&\")\n- .replace('<', \"<\")\n- .replace('>', \">\")\n-}\n-\n fn print_thread(resp: &ThreadDetailResponse) {\n let now_ms = std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n@@ -597,7 +520,7 @@ fn print_thread(resp: &ThreadDetailResponse) {\n }\n for (i, post) in resp.posts.iter().enumerate() {\n let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts);\n- let body = escape_xml(&post.body);\n+ let body = &post.body.trim();\n println!(\"\", post.index, timeago);\n println!(\"{}\", body);\n println!(\"\");\n@@ -956,164 +879,164 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {\n }\n }\n },\n- ScopedCmd::Forum {\n- title,\n- json,\n- offset,\n- limit,\n- since,\n- before,\n- actor,\n- post,\n- } => {\n- match title {\n- None => {\n- let batch = send_rpc(\n- &client,\n- base,\n- None,\n- vec![RpcCommand::ListForumThreads {\n- room: room.to_string(),\n- }],\n- )\n- .await?;\n- match rpc_line_ok(&batch.results[0])? {\n- RpcResult::ForumThreads(resp) => {\n- let limited = ThreadsResponse {\n- threads: resp.threads.iter().take(10).cloned().collect(),\n- };\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&limited)?);\n- } else {\n- print_threads(&limited);\n- }\n+ ScopedCmd::Forum { sub } => match sub {\n+ ForumCmd::List { json } => {\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::ListForumThreads {\n+ room: room.to_string(),\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::ForumThreads(resp) => {\n+ let limited = ThreadsResponse {\n+ threads: resp.threads.iter().take(10).cloned().collect(),\n+ };\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&limited)?);\n+ } else {\n+ print_threads(&limited);\n }\n- _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n- Some(name) => {\n- let tag = normalize_thread_input(&name);\n- let batch = send_rpc(\n- &client,\n- base,\n- None,\n- vec![RpcCommand::GetForumThread {\n- room: room.to_string(),\n- thread_tag: tag,\n- offset,\n- limit,\n- since: match &since {\n- Some(s) => Some(parse_ts(s)?),\n- None => None,\n- },\n- before: match &before {\n- Some(s) => Some(parse_ts(s)?),\n- None => None,\n- },\n- actor,\n- post_id: post,\n- }],\n- )\n- .await?;\n- match rpc_line_ok(&batch.results[0])? {\n- RpcResult::ForumThread(resp) => {\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_thread(&resp);\n- }\n+ }\n+ ForumCmd::Show {\n+ tag,\n+ json,\n+ offset,\n+ limit,\n+ since,\n+ before,\n+ actor,\n+ post,\n+ } => {\n+ let thread_tag = normalize_thread_input(&tag);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetForumThread {\n+ room: room.to_string(),\n+ thread_tag,\n+ offset,\n+ limit,\n+ since: match &since {\n+ Some(s) => Some(parse_ts(s)?),\n+ None => None,\n+ },\n+ before: match &before {\n+ Some(s) => Some(parse_ts(s)?),\n+ None => None,\n+ },\n+ actor,\n+ post_id: post,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::ForumThread(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_thread(&resp);\n }\n- _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n- }\n- ScopedCmd::Ingest {\n- file,\n- thread,\n- delegate,\n- json,\n- } => {\n- let mut text = String::new();\n- match file {\n- Some(path) => {\n- text = std::fs::read_to_string(&path)\n- .with_context(|| format!(\"failed to read {}\", path.display()))?;\n+ ForumCmd::Post {\n+ tag,\n+ delegate,\n+ file,\n+ json,\n+ } => {\n+ let delegate = delegate.trim();\n+ if delegate.is_empty() {\n+ return Err(anyhow!(\n+ \"--delegate is required for CLI posts (humans use the website); set SLUG_DELEGATE or pass --delegate uuid:rig:provider/model\"\n+ ));\n }\n- None => {\n- std::io::stdin()\n- .read_to_string(&mut text)\n- .context(\"failed to read stdin\")?;\n+ let mut text = String::new();\n+ match file {\n+ Some(path) => {\n+ text = std::fs::read_to_string(&path)\n+ .with_context(|| format!(\"failed to read {}\", path.display()))?;\n+ }\n+ None => {\n+ std::io::stdin()\n+ .read_to_string(&mut text)\n+ .context(\"failed to read stdin\")?;\n+ }\n }\n- }\n- if text.trim().is_empty() {\n- return Err(anyhow!(\"no input provided (empty)\"));\n- }\n- let bearer = effective_bearer().ok_or_else(|| {\n- anyhow!(\n- \"no bearer token: run `slugsocial identity start --rig --model ` \\\n- then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n+ if text.trim().is_empty() {\n+ return Err(anyhow!(\"no input provided (empty)\"));\n+ }\n+ let bearer = effective_bearer().ok_or_else(|| {\n+ anyhow!(\n+ \"no bearer token: run `slugsocial identity start --rig --model ` \\\n+ then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n+ )\n+ })?;\n+ let thread_tag = normalize_thread_input(&tag);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ Some(&bearer),\n+ vec![RpcCommand::Post {\n+ room: room.to_string(),\n+ thread_tag,\n+ delegate: Some(delegate.to_string()),\n+ text,\n+ return_rank_diff: true,\n+ }],\n )\n- })?;\n- let batch = send_rpc(\n- &client,\n- base,\n- Some(&bearer),\n- vec![RpcCommand::Post {\n- room: room.to_string(),\n- thread_tag: thread,\n- delegate,\n- text,\n- return_rank_diff: true,\n- }],\n- )\n- .await?;\n- match rpc_line_ok(&batch.results[0])? {\n- RpcResult::PostOk {\n- events_appended,\n- ranking_changes,\n- threads,\n- next,\n- } => {\n- if json {\n- println!(\n- \"{}\",\n- serde_json::to_string_pretty(&serde_json::json!({\n- \"ok\": true,\n- \"events_appended\": events_appended,\n- \"ranking_changes\": ranking_changes,\n- \"threads\": threads,\n- \"next\": next,\n- }))?\n- );\n- } else {\n- println!(\"✓ ingested\");\n- println!(\"events: {}\", events_appended);\n- if !threads.is_empty() {\n- println!(\"threads:\");\n- for t in threads {\n- println!(\" {t}\");\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::PostOk {\n+ events_appended,\n+ ranking_changes,\n+ threads,\n+ next,\n+ } => {\n+ if json {\n+ println!(\n+ \"{}\",\n+ serde_json::to_string_pretty(&serde_json::json!({\n+ \"ok\": true,\n+ \"events_appended\": events_appended,\n+ \"ranking_changes\": ranking_changes,\n+ \"threads\": threads,\n+ \"next\": next,\n+ }))?\n+ );\n+ } else {\n+ println!(\"✓ posted\");\n+ println!(\"events: {}\", events_appended);\n+ if !threads.is_empty() {\n+ println!(\"threads:\");\n+ for t in threads {\n+ println!(\" {t}\");\n+ }\n }\n+ if let Some(ref rc) = ranking_changes {\n+ print_ranking_changes(rc);\n+ }\n+ print_next(&next);\n+ println!();\n+ println!(\"---\");\n+ println!(\"For your next comparison: remember to ask your human first. Their perspective is what makes your submission more than another model's take.\");\n+ println!(\"---\");\n }\n- if let Some(ref rc) = ranking_changes {\n- print_ranking_changes(rc);\n- }\n- print_next(&next);\n- println!();\n- println!(\"---\");\n- println!(\"For your next comparison: remember to ask your human first. Their perspective is what makes your submission more than another model's take.\");\n- println!(\"---\");\n }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n- _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n- }\n- ScopedCmd::Check {\n- file,\n- thread: _thread,\n- delegate: _delegate,\n- json,\n- } => {\n+ },\n+ ScopedCmd::Check { file, json } => {\n let mut text = String::new();\n match file {\n Some(path) => {\n@@ -1172,7 +1095,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {\n }\n println!();\n println!(\"---\");\n- println!(\"Does this submission reflect input from your human? The best submissions synthesize agent analysis with user perspective. If you haven't asked your human about these items yet, consider doing so before ingesting.\");\n+ println!(\"Does this submission reflect input from your human? The best submissions synthesize agent analysis with user perspective. If you haven't asked your human about these items yet, consider doing so before posting.\");\n println!(\"---\");\n if !next.is_empty() {\n println!();\n@@ -1373,7 +1296,7 @@ async fn main() -> Result<()> {\n println!();\n println!(\" slugsocial identity poll {}\", start.session);\n println!();\n- println!(\"Agent delegate (keep in context for --delegate on ingest):\");\n+ println!(\"Agent delegate (keep in context for `forum post … --delegate`):\");\n println!(\" {}\", delegate);\n }\n }\n@@ -1441,7 +1364,7 @@ async fn main() -> Result<()> {\n println!(\"Token saved to {}\", cfg.join(\"token\").display());\n if let Some(ref a) = agent_out {\n println!();\n- println!(\"Agent delegate (for ingest --delegate):\");\n+ println!(\"Agent delegate (for `forum post … --delegate`):\");\n println!(\" {}\", a);\n }\n }\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex a18241f3ed7b4a248748fcefc799f9801ca62461..11a76ed7a02aae588edadb7a87a43660f5669d9e 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -377,8 +377,8 @@ async fn rpc_post(\n ranking_changes,\n threads: vec![format!(\"#{}\", thread_id)],\n next: NextMoves {\n- pair: \"npx slugsocial pair\".to_string(),\n- rank: \"npx slugsocial rank\".to_string(),\n+ pair: \"npx slugsocial public garden pair\".to_string(),\n+ rank: \"npx slugsocial public garden rank\".to_string(),\n web: format!(\"https://slug.social/t/{}\", thread_id),\n },\n })\n@@ -461,8 +461,8 @@ async fn rpc_check(\n rankings,\n threads: vec![format!(\"#{}\", thread_id)],\n next: vec![\n- \"npx slugsocial ingest\".to_string(),\n- \"npx slugsocial forum\".to_string(),\n+ \"npx slugsocial public forum post --delegate \".to_string(),\n+ \"npx slugsocial public forum list\".to_string(),\n format!(\"https://slug.social/t/{}\", thread_id),\n ],\n })\ndiff --git a/test/integration.bb b/test/integration.bb\nindex c71dcf20021056421ab27b1313e4dec94570ddaa..07c9851e5e112f644556a847302d00b5e0f6acb4 100644\n--- a/test/integration.bb\n+++ b/test/integration.bb\n@@ -146,7 +146,7 @@\n \n ;; 2.5 check endpoint — disconnected components not flattened (before OAuth; no events.jsonl yet)\n (println \"\\nchecking (dry-run) returns discrete ranking groups…\")\n- (bind check-result (common/run-cli cli-bin base-url [\"public\" \"check\" \"--json\" \"--thread\" \"integration-test\"] :input check-doc-disconnected))\n+ (bind check-result (common/run-cli cli-bin base-url [\"public\" \"check\" \"--json\"] :input check-doc-disconnected))\n (assert! (zero? (:exit check-result)) \"cli check exits 0\")\n (bind check-resp (json/parse-string (:out check-result) true))\n (assert! (:ok check-resp) \"check response ok=true\")\n@@ -166,7 +166,7 @@\n \n ;; 3. ingest via CLI (bearer required)\n (println \"\\ningesting .sorter document via CLI…\")\n- (bind ingest1-result (common/run-cli cli-bin base-url [\"public\" \"ingest\" \"--json\" \"--thread\" \"integration-test\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input sorter-doc :extra-env token-env))\n+ (bind ingest1-result (common/run-cli cli-bin base-url [\"public\" \"forum\" \"post\" \"integration-test\" \"--json\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input sorter-doc :extra-env token-env))\n (assert! (zero? (:exit ingest1-result)) \"cli ingest exits 0\")\n (bind ingest1-resp (json/parse-string (:out ingest1-result) true))\n (assert! (:ok ingest1-resp) \"ingest response ok=true\")\n@@ -195,7 +195,7 @@\n \n ;; 6. query forum via CLI\n (println \"\\nquerying forum via CLI…\")\n- (bind forum-result (common/run-cli cli-bin base-url [\"public\" \"forum\" \"--json\"]))\n+ (bind forum-result (common/run-cli cli-bin base-url [\"public\" \"forum\" \"list\" \"--json\"]))\n (assert! (zero? (:exit forum-result)) \"cli forum exits 0\")\n (bind forum-resp (json/parse-string (:out forum-result) true))\n (assert! (some (fn [t] (= \"#integration-test\" (:thread t))) (:threads forum-resp))\n@@ -237,7 +237,7 @@\n \"#integration-test\"\n \"~/languages/rust 4:1 ~/languages/python { type safety }\"\n \"~/languages/rust 3:1 ~/languages/go { zero-cost abstractions }\"]))\n- (bind hist-ingest (common/run-cli cli-bin base-url [\"public\" \"ingest\" \"--json\" \"--thread\" \"integration-test\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input two-vote-doc :extra-env token-env))\n+ (bind hist-ingest (common/run-cli cli-bin base-url [\"public\" \"forum\" \"post\" \"integration-test\" \"--json\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input two-vote-doc :extra-env token-env))\n (assert! (zero? (:exit hist-ingest))\n (str \"two-vote ingest exits 0 (err: \" (:err hist-ingest) \")\"))\n \n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[21b57b50] fix(html): drop duplicate forum.rs; use forum/ as sole module root\n\nRemoves the leftover monolithic file so mod forum resolves to forum/mod.rs\nwithout ambiguity. Keeps feed, views, and other split modules as the source.\n\nMade-with: Cursor\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\ndeleted file mode 100644\nindex 5ad8dfc84dd735d589432e2c613ff687a75f2e61..0000000000000000000000000000000000000000\n--- a/server/src/html/forum.rs\n+++ /dev/null\n@@ -1,1405 +0,0 @@\n-use axum::{\n- extract::{Path, Query, State},\n- http::{HeaderMap, StatusCode, Uri},\n- response::{Html, IntoResponse},\n-};\n-use axum_extra::extract::cookie::CookieJar;\n-use maud::{html, Markup};\n-use serde::Deserialize;\n-\n-use crate::{\n- api::optional_principal,\n- canonical_path::{canonicalize_item, canonicalize_tag},\n- events::ThreadCapability,\n- form_template::template_json_compact,\n- identity::parse_username,\n- reducer::{scope_from_room_wire, ReducerState, ScopeId},\n- state::AppState,\n- timeago,\n-};\n-use serde_json::json;\n-\n-use super::js_string_literal;\n-use super::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n-\n-use super::{\n- bc_segment, bc_threads, cli_panel, layout, now_ms, profile_href, recency_class,\n- render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, JsBuilder,\n-};\n-\n-#[derive(Clone)]\n-struct ThreadRow {\n- tag: String,\n- subtitle: Option,\n- last_ts: i64,\n- ingests: usize,\n-}\n-\n-#[derive(Clone)]\n-struct RoomMemberRow {\n- username: String,\n- capabilities: Vec<&'static str>,\n-}\n-\n-/// URL helpers for public `/t/…` and private room threads `/r/{short}/{slug}/t/…`.\n-#[derive(Clone)]\n-pub struct ThreadNav {\n- pub room_wire: String,\n- scope: ScopeId,\n- room_path: String,\n- thread_path_prefix: String,\n- garden_path_prefix: String,\n-}\n-\n-impl ThreadNav {\n- pub(crate) fn public() -> Self {\n- Self {\n- room_wire: \"public\".into(),\n- scope: ScopeId::Public,\n- room_path: \"/t\".into(),\n- thread_path_prefix: \"/t\".into(),\n- garden_path_prefix: \"/~\".into(),\n- }\n- }\n-\n- /// `room_id` wire form `shortid/slug`.\n- pub(crate) fn from_room_id(room_id: &str) -> Option {\n- let (short, slug) = room_id.split_once('/')?;\n- if short.is_empty() || slug.is_empty() {\n- return None;\n- }\n- Some(Self {\n- room_wire: room_id.to_string(),\n- scope: ScopeId::Room(room_id.to_string()),\n- room_path: format!(\"/r/{short}/{slug}\"),\n- thread_path_prefix: format!(\"/r/{short}/{slug}/t\"),\n- garden_path_prefix: format!(\"/r/{short}/{slug}/~\"),\n- })\n- }\n-\n- pub(crate) fn scope(&self) -> ScopeId {\n- self.scope.clone()\n- }\n-\n- pub(crate) fn room_url(&self) -> &str {\n- &self.room_path\n- }\n-\n- pub(crate) fn thread_url(&self, tag: &str) -> String {\n- format!(\"{}/{}\", self.thread_path_prefix, tag)\n- }\n-\n- pub(crate) fn garden_root_url(&self) -> &str {\n- &self.garden_path_prefix\n- }\n-\n- pub(crate) fn garden_item_url(&self, item: &str) -> String {\n- if let Some(tail) = crate::path_types::CanonicalItemUrl::parse(item)\n- .and_then(|c| c.tilde_tail().map(str::to_owned))\n- {\n- format!(\"{}/{}\", self.garden_path_prefix, tail)\n- } else {\n- format!(\"{}/{}\", self.garden_path_prefix, canonicalize_item(item))\n- }\n- }\n-\n- fn thread_page_url(&self, tag: &str, offset: usize) -> String {\n- let base = self.thread_url(tag);\n- if offset == 0 {\n- base\n- } else {\n- format!(\"{base}?offset={offset}\")\n- }\n- }\n-\n- fn post_url(&self, tag: &str, idx: usize) -> String {\n- format!(\"{}/{}/{}\", self.thread_path_prefix, tag, idx)\n- }\n-}\n-\n-/// `POST /ui` + `__rpc__` from an inline link (`onclick`); same-origin credentials as other morph actions.\n-fn thread_ui_fetch_onclick(rpc_compact_json: &str) -> String {\n- format!(\n- \"fetch('/ui',{{method:'POST',headers:{{'Content-Type':'application/x-www-form-urlencoded'}},body:new URLSearchParams({{__rpc__:{}}}).toString(),credentials:'same-origin'}}).then(r=>r.text()).then(eval);return false\",\n- js_string_literal(rpc_compact_json)\n- )\n-}\n-\n-fn thread_nav_for_ingest(ing: &crate::events::Ingest) -> Option {\n- let room = ing.room_id.trim();\n- if room.is_empty() || room == \"public\" {\n- Some(ThreadNav::public())\n- } else {\n- ThreadNav::from_room_id(room)\n- }\n-}\n-\n-fn thread_post_index_in_scope(reduced: &ReducerState, ing: &crate::events::Ingest) -> Option {\n- let scope = scope_from_room_wire(&ing.room_id);\n- let tag = canonicalize_tag(&ing.thread_tag);\n- reduced\n- .ingests_by_scope_thread\n- .get(&(scope, tag))\n- .and_then(|q| q.iter().rev().position(|id| id == &ing.id))\n-}\n-\n-fn post_header_meta(\n- nav: &ThreadNav,\n- tag: &str,\n- post_idx: usize,\n- principal: &str,\n- ts: i64,\n- now: i64,\n-) -> Markup {\n- let post_href = nav.post_url(tag, post_idx);\n- let profile = profile_href(principal);\n- let hover = timeago::rfc3339_utc(ts);\n- let ago = timeago::timeago(now, ts);\n- html! {\n- div class=\"ingest-meta muted\" title=(hover) {\n- a href=(post_href) class=\"post-num\" { \"#\" (post_idx) }\n- \" \"\n- a href=(profile) class=\"post-author\" { \"@\" (principal) }\n- \" · \"\n- (ago)\n- }\n- }\n-}\n-\n-fn post_header_row(\n- nav: &ThreadNav,\n- tag: &str,\n- post_idx: usize,\n- ing: &crate::events::Ingest,\n- _viewer: Option<&str>,\n- now: i64,\n- show_delete: bool,\n-) -> Markup {\n- let meta = post_header_meta(nav, tag, post_idx, &ing.principal, ing.ts, now);\n- html! {\n- div class=\"ingest-header-row\" {\n- (meta)\n- @if show_delete {\n- form class=\"post-delete-form\" method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::RedactPost { post_id: ing.id.clone() }).unwrap());\n- button type=\"submit\" class=\"post-delete-btn\" { \"delete\" }\n- }\n- }\n- }\n- }\n-}\n-\n-fn redacted_header_row(\n- nav: &ThreadNav,\n- tag: &str,\n- post_idx: usize,\n- ing: &crate::events::Ingest,\n- now: i64,\n- expanded: bool,\n-) -> Markup {\n- let meta = post_header_meta(nav, tag, post_idx, &ing.principal, ing.ts, now);\n- let rpc_expand = template_json_compact(&json!({\n- \"action\": \"expand_redacted_post\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": tag,\n- \"post_index\": post_idx,\n- }))\n- .unwrap();\n- let rpc_collapse = template_json_compact(&json!({\n- \"action\": \"collapse_redacted_post\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": tag,\n- \"post_index\": post_idx,\n- }))\n- .unwrap();\n- let onclick_expand = thread_ui_fetch_onclick(&rpc_expand);\n- let onclick_collapse = thread_ui_fetch_onclick(&rpc_collapse);\n- html! {\n- div class=\"ingest-header-row ingest-tombstone-row\" {\n- (meta)\n- span class=\"post-tombstone-inline muted\" {\n- \"deleted · \"\n- @if expanded {\n- a href=\"#\" class=\"hide-deleted-link\"\n- onclick=(onclick_collapse) {\n- \"[hide deleted content]\"\n- }\n- } @else {\n- a href=\"#\" class=\"show-deleted-link\"\n- onclick=(onclick_expand) {\n- \"[show deleted content]\"\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn ingest_entry_markup(\n- nav: &ThreadNav,\n- tag: &str,\n- post_idx: usize,\n- ing: &crate::events::Ingest,\n- viewer: Option<&str>,\n- now: i64,\n- reduced: &ReducerState,\n-) -> Markup {\n- let redacted = reduced.redacted_posts.contains(&ing.id);\n- let show_delete = viewer == Some(ing.principal.as_str()) && !redacted;\n- if redacted {\n- html! {\n- div class=\"ingest-entry ingest-redacted\" data-ingest-id=(ing.id) {\n- (redacted_header_row(nav, tag, post_idx, ing, now, false))\n- }\n- }\n- } else {\n- let truncated = ing.raw.len() > 2000;\n- let display_body = if truncated { &ing.raw[..2000] } else { &ing.raw[..] };\n- html! {\n- div class=\"ingest-entry\" data-ingest-id=(ing.id) {\n- (post_header_row(nav, tag, post_idx, ing, viewer, now, show_delete))\n- (render_linkified_with_embeds_in_scope(display_body, nav.garden_root_url()))\n- @if truncated {\n- @let rpc_full = template_json_compact(&json!({\n- \"action\": \"expand_post_full\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": tag,\n- \"post_index\": post_idx,\n- })).unwrap();\n- @let onclick_full = thread_ui_fetch_onclick(&rpc_full);\n- a href=\"#\" class=\"show-full-link\"\n- onclick=(onclick_full) {\n- \"[show full post]\"\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn collect_thread_rows_for_scope(reduced: &ReducerState, scope: &ScopeId, now: i64) -> Vec {\n- let _ = now;\n- reduced\n- .forum_threads\n- .iter()\n- .filter(|((s, _), _)| s == scope)\n- .map(|((_, tag), thread)| {\n- let ingests = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .map(|q| q.len())\n- .unwrap_or(0);\n- ThreadRow {\n- tag: tag.clone(),\n- subtitle: None,\n- last_ts: thread.last_activity_ts,\n- ingests,\n- }\n- })\n- .collect()\n-}\n-\n-fn rooms_for_user(reduced: &ReducerState, username: &str) -> Vec {\n- let mut v: Vec = reduced\n- .grants\n- .iter()\n- .filter(|(rid, m)| reduced.rooms.contains(*rid) && m.contains_key(username))\n- .map(|(rid, _)| rid.clone())\n- .collect();\n- v.sort();\n- v\n-}\n-\n-pub(crate) fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&str>) -> bool {\n- if !reduced.rooms.contains(room_id) {\n- return false;\n- }\n- let Some(u) = username else {\n- return false;\n- };\n- reduced.user_has_cap(room_id, u, ThreadCapability::View)\n-}\n-\n-pub(crate) fn user_can_post_room(reduced: &ReducerState, room_id: &str, username: &str) -> bool {\n- reduced.user_has_cap(room_id, username, ThreadCapability::Post)\n-}\n-\n-fn capability_label(cap: ThreadCapability) -> &'static str {\n- match cap {\n- ThreadCapability::View => \"view\",\n- ThreadCapability::Post => \"post\",\n- ThreadCapability::Vote => \"vote\",\n- ThreadCapability::AddItem => \"add_item\",\n- ThreadCapability::Manage => \"manage\",\n- }\n-}\n-\n-fn room_members_for_room(reduced: &ReducerState, room_id: &str) -> Vec {\n- let mut rows: Vec = reduced\n- .grants\n- .get(room_id)\n- .into_iter()\n- .flat_map(|members| members.iter())\n- .map(|(username, caps)| {\n- let mut ordered = Vec::new();\n- for cap in [\n- ThreadCapability::View,\n- ThreadCapability::Post,\n- ThreadCapability::Vote,\n- ThreadCapability::AddItem,\n- ThreadCapability::Manage,\n- ] {\n- if caps.contains(&cap) {\n- ordered.push(capability_label(cap));\n- }\n- }\n- RoomMemberRow {\n- username: username.clone(),\n- capabilities: ordered,\n- }\n- })\n- .collect();\n- rows.sort_by(|a, b| a.username.cmp(&b.username));\n- rows\n-}\n-\n-fn room_members_inner(members: &[RoomMemberRow]) -> Markup {\n- html! {\n- h3 { \"members\" }\n- ul class=\"room-members\" {\n- @for member in members {\n- li {\n- span class=\"room-member-name\" { \"@\" (member.username) }\n- span class=\"muted\" {\n- \" · \"\n- (member.capabilities.join(\", \"))\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-/// Fragment for `#room-members-section` — expand/collapse is server-driven via `POST /ui`.\n-pub(crate) fn room_members_section_markup(\n- reduced: &ReducerState,\n- room_id: &str,\n- members_expanded: bool,\n-) -> Markup {\n- let members = room_members_for_room(reduced, room_id);\n- if members.is_empty() {\n- return html! {};\n- }\n- html! {\n- div id=\"room-members-section\" {\n- @if members_expanded {\n- form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded {\n- room_wire: room_id.to_string(),\n- expanded: false,\n- }).expect(\"static json\"));\n- button type=\"submit\" class=\"form-toggle\" aria-expanded=\"true\" {\n- \"hide members & permissions\"\n- }\n- }\n- section class=\"room-members-panel\" {\n- (room_members_inner(&members))\n- }\n- } @else {\n- form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded {\n- room_wire: room_id.to_string(),\n- expanded: true,\n- }).expect(\"static json\"));\n- button type=\"submit\" class=\"form-toggle\" aria-expanded=\"false\" {\n- \"members & permissions\"\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-/// `feed_id` is e.g. `thread-feed` (public bump list, SSE) or `room-thread-feed`.\n-fn render_thread_feed(nav: Option<&ThreadNav>, feed_id: &str, rows: &[ThreadRow], now: i64) -> Markup {\n- html! {\n- div id=(feed_id) {\n- @if rows.is_empty() {\n- p class=\"muted\" { \"no threads yet\" }\n- } @else {\n- ul class=\"thread-feed\" {\n- @for r in rows {\n- @let thread_href = nav\n- .as_ref()\n- .map(|n| n.thread_url(&r.tag))\n- .unwrap_or_else(|| format!(\"/t/{}\", r.tag));\n- @let hover = timeago::rfc3339_utc(r.last_ts);\n- @let ago = timeago::timeago(now, r.last_ts);\n- @let age_cls = recency_class(now, r.last_ts);\n- li class=(age_cls) {\n- a href=(thread_href) {\n- \"#\" (r.tag)\n- @if let Some(subtitle) = &r.subtitle {\n- \": \" (subtitle)\n- }\n- \" \"\n- span class=\"muted\" title=(hover) {\n- (ago)\n- \" · \"\n- (format!(\"{}n\", r.ingests))\n- }\n- }\n- }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn auth_strip(\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- reduced: &ReducerState,\n-) -> Markup {\n- match optional_principal(headers, jar, reduced) {\n- Some(u) => html! {\n- p class=\"muted auth-strip\" {\n- \"@\" (u)\n- \" · \"\n- a href=\"/logout\" { \"log out\" }\n- }\n- },\n- None => html! {\n- p class=\"muted auth-strip\" {\n- a href=\"/login\" { \"log in\" }\n- }\n- },\n- }\n-}\n-\n-fn bc_room(nav: &ThreadNav, room_slug: &str, thread_tag: Option<&str>) -> Markup {\n- html! {\n- a href=\"/\" { \"slug.social\" }\n- @if let Some(t) = thread_tag {\n- (bc_segment(\n- &format!(\"room:{room_slug}\"),\n- nav.room_url(),\n- false,\n- ))\n- (bc_segment(&format!(\"#{t}\"), &nav.thread_url(t), true))\n- } @else {\n- (bc_segment(\n- &format!(\"room:{room_slug}\"),\n- nav.room_url(),\n- true,\n- ))\n- }\n- }\n-}\n-\n-fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {\n- if !show {\n- return html! {};\n- }\n- html! {\n- section class=\"compose\" id=\"thread-compose\" {\n- form id=\"thread-compose-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n- \"action\": \"check_ingest\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": thread_tag,\n- \"text\": {\"$form\": \"text\"},\n- \"error_target\": \"thread-compose-errors\",\n- \"form_id\": \"thread-compose-form\",\n- })).unwrap()) {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n- \"action\": \"post_ingest\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": thread_tag,\n- \"text\": {\"$form\": \"text\"},\n- \"error_target\": \"thread-compose-errors\",\n- \"form_id\": \"thread-compose-form\",\n- })).unwrap());\n- textarea name=\"text\" rows=\"5\" cols=\"80\" placeholder=\"prose or ~/items and votes…\" {}\n- p {\n- button type=\"submit\" { \"post\" }\n- }\n- }\n- div id=\"thread-compose-errors\" {}\n- }\n- }\n-}\n-\n-fn new_thread_form_public(show: bool) -> Markup {\n- if !show {\n- return html! {};\n- }\n- html! {\n- section class=\"compose\" id=\"public-new-thread-compose\" {\n- div id=\"public-new-thread-errors\" {}\n- form id=\"public-new-thread-form\" method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n- \"action\": \"post_ingest\",\n- \"room\": \"public\",\n- \"thread_tag\": {\"$form\": \"thread_tag\"},\n- \"text\": {\"$form\": \"text\"},\n- })).unwrap());\n- input type=\"text\" id=\"new-thread-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" placeholder=\"thread-title-slug-here\";\n- textarea id=\"new-thread-text\" name=\"text\" rows=\"4\" placeholder=\"Hello threadgoers!! Behold my new thread!\" {}\n- p { button type=\"submit\" { \"create thread / make first post\" } }\n- }\n- }\n- }\n-}\n-\n-/// Returns the current public thread feed markup for SSE (`#thread-feed`).\n-pub async fn thread_feed_html(state: &AppState) -> Markup {\n- let now = now_ms();\n- let nav = ThreadNav::public();\n- let mut rows = {\n- let reduced = state.reduced.read().await;\n- collect_thread_rows_for_scope(&reduced, &ScopeId::Public, now)\n- };\n- rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n- render_thread_feed(Some(&nav), \"thread-feed\", &rows, now)\n-}\n-\n-/// Returns the current private room thread feed markup for SSE (`#room-thread-feed`).\n-pub async fn thread_feed_html_for_room(state: &AppState, room_id: &str) -> Markup {\n- let now = now_ms();\n- let Some(nav) = ThreadNav::from_room_id(room_id) else {\n- return html! { div id=\"room-thread-feed\" { p class=\"muted\" { \"room not found\" } } };\n- };\n- let mut rows = {\n- let reduced = state.reduced.read().await;\n- collect_thread_rows_for_scope(&reduced, &ScopeId::Room(room_id.to_string()), now)\n- };\n- rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n- render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now)\n-}\n-\n-fn render_thread_feed_region_markup(\n- nav: &ThreadNav,\n- tag: &str,\n- display_ingests: &[crate::events::Ingest],\n- offset: usize,\n- total: usize,\n- now: i64,\n- viewer: Option<&str>,\n- reduced: &ReducerState,\n-) -> Markup {\n- let paginator_top = render_thread_paginator(nav, tag, offset, total, true);\n- let paginator_bot = render_thread_paginator(nav, tag, offset, total, false);\n- html! {\n- div id=\"thread-feed-region\" {\n- @if display_ingests.is_empty() {\n- p class=\"muted\" { \"no activity yet\" }\n- } @else {\n- (paginator_top)\n- @for (i, ing) in display_ingests.iter().enumerate() {\n- @let post_idx = offset + i;\n- (ingest_entry_markup(nav, tag, post_idx, ing, viewer, now, reduced))\n- }\n- (paginator_bot)\n- }\n- }\n- }\n-}\n-\n-pub async fn thread_feed_region_markup(\n- state: &AppState,\n- room_id: Option<&str>,\n- tag: &str,\n- viewer: Option<&str>,\n-) -> Markup {\n- let tag = canonicalize_tag(tag);\n- let Some(nav) = (match room_id {\n- Some(room_id) => ThreadNav::from_room_id(room_id),\n- None => Some(ThreadNav::public()),\n- }) else {\n- return html! { div id=\"thread-feed-region\" { p class=\"muted\" { \"thread not found\" } } };\n- };\n- let scope = nav.scope();\n- let all_ids: Vec = {\n- let reduced = state.reduced.read().await;\n- reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .map(|q| q.iter().rev().cloned().collect())\n- .unwrap_or_default()\n- };\n- let total = all_ids.len();\n- let offset = total.saturating_sub(PAGE_SIZE);\n- let page_ids: Vec = all_ids.into_iter().skip(offset).take(PAGE_SIZE).collect();\n- let reduced = state.reduced.read().await;\n- let display_ingests = page_ids\n- .iter()\n- .filter_map(|id| reduced.ingests_by_id.get(id).cloned())\n- .collect::>();\n- render_thread_feed_region_markup(\n- &nav,\n- &tag,\n- &display_ingests,\n- offset,\n- total,\n- now_ms(),\n- viewer,\n- &reduced,\n- )\n-}\n-\n-/// Home: private rooms (signed-in), then public bump-ordered threads.\n-pub async fn home(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let now = now_ms();\n- let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n- let room_ids = user\n- .as_ref()\n- .map(|u| rooms_for_user(&reduced, u))\n- .unwrap_or_default();\n- let mut public_rows = collect_thread_rows_for_scope(&reduced, &ScopeId::Public, now);\n- drop(reduced);\n- public_rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n-\n- let nav = ThreadNav::public();\n- let reduced_read = state.reduced.read().await;\n- let strip = auth_strip(&headers, &jar, &reduced_read);\n- drop(reduced_read);\n-\n- let page = layout(\n- \"slug.social\",\n- \"view-thread\",\n- html! {\n- (strip)\n- nav class=\"breadcrumb\" { (bc_threads(None)) }\n- @if !room_ids.is_empty() {\n- h2 { \"your rooms\" }\n- ul class=\"thread-feed\" {\n- @for rid in &room_ids {\n- @if let Some(nav_r) = ThreadNav::from_room_id(rid) {\n- @let slug = if let Some((_, s)) = rid.split_once('/') { s } else { rid.as_str() };\n- li {\n- a href=(nav_r.room_url()) {\n- (slug)\n- span class=\"muted\" { \" · \" (rid) }\n- }\n- }\n- }\n- }\n- }\n- }\n- p class=\"muted\" { \"dark = time-ordered · light = vote-ranked\" }\n- div class=\"thread-feed-toolbar\" {\n- form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect(\"static json\"));\n- button type=\"submit\" class=\"section-add-btn\" { \"+\" }\n- }\n- }\n- div id=\"public-new-thread-ui-slot\" {}\n- (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n- (cli_panel(&[\"npx slugsocial public forum list\"]))\n- },\n- None,\n- theme_from_jar(&jar),\n- &theme_next_from_uri(&uri),\n- );\n- Html(page.into_string())\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct ThreadViewQuery {\n- pub offset: Option,\n-}\n-\n-const PAGE_SIZE: usize = 10;\n-\n-fn render_thread_paginator(nav: &ThreadNav, tag: &str, offset: usize, total: usize, top: bool) -> Markup {\n- let newer_offset = offset.checked_add(PAGE_SIZE).filter(|&o| o < total);\n- let older_offset = if offset > 0 {\n- Some(offset.saturating_sub(PAGE_SIZE))\n- } else {\n- None\n- };\n- let latest_offset = total.saturating_sub(PAGE_SIZE);\n- let on_latest = offset >= latest_offset;\n- let (id, scroll_href, scroll_label) = if top {\n- (\"top\", \"#bottom\", \"↓\")\n- } else {\n- (\"bottom\", \"#top\", \"↑\")\n- };\n- html! {\n- div class=\"thread-paginator\" id=(id) {\n- a href=(scroll_href) class=\"post-nav-btn\" { (scroll_label) }\n- @if let Some(o) = older_offset {\n- a href=(nav.thread_page_url(tag, o)) class=\"post-nav-btn\" { \"← older\" }\n- } @else {\n- a href=\"#\" class=\"post-nav-btn disabled\" { \"← older\" }\n- }\n- span class=\"post-nav-pos muted\" {\n- (offset + 1) \"–\" (total.min(offset + PAGE_SIZE)) \" / \" (total)\n- }\n- @if let Some(o) = newer_offset {\n- a href=(nav.thread_page_url(tag, o)) class=\"post-nav-btn\" { \"newer →\" }\n- } @else {\n- a href=\"#\" class=\"post-nav-btn disabled\" { \"newer →\" }\n- }\n- @if !on_latest {\n- a href=(nav.thread_page_url(tag, latest_offset)) class=\"post-nav-btn\" { \"latest\" }\n- }\n- }\n- }\n-}\n-\n-async fn thread_view_inner(\n- state: AppState,\n- tag: String,\n- q: ThreadViewQuery,\n- nav: ThreadNav,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let tag = canonicalize_tag(&tag);\n- let scope = nav.scope();\n-\n- let all_ids: Vec = {\n- let reduced = state.reduced.read().await;\n- reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .map(|q| q.iter().rev().cloned().collect())\n- .unwrap_or_default()\n- };\n-\n- let total = all_ids.len();\n- let offset = q.offset.unwrap_or(0);\n- let page_ids: Vec = all_ids.into_iter().skip(offset).take(PAGE_SIZE).collect();\n-\n- let (display_ingests, _subtitle) = {\n- let reduced = state.reduced.read().await;\n- let ingests = page_ids\n- .iter()\n- .filter_map(|id| reduced.ingests_by_id.get(id).cloned())\n- .collect::>();\n- (ingests, None::)\n- };\n-\n- let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n- let sc = nav.scope();\n- let show_compose = match &sc {\n- ScopeId::Public => user.is_some(),\n- ScopeId::Room(rid) => user\n- .as_ref()\n- .map(|u| user_can_post_room(&reduced, rid, u))\n- .unwrap_or(false),\n- };\n- let strip = auth_strip(&headers, &jar, &reduced);\n- let now = now_ms();\n- let entry_rows: Vec = display_ingests\n- .iter()\n- .enumerate()\n- .map(|(i, ing)| {\n- let post_idx = offset + i;\n- ingest_entry_markup(&nav, &tag, post_idx, ing, user.as_deref(), now, &reduced)\n- })\n- .collect();\n- drop(reduced);\n-\n- let paginator_top = render_thread_paginator(&nav, &tag, offset, total, true);\n- let paginator_bot = render_thread_paginator(&nav, &tag, offset, total, false);\n-\n- let bc: Markup = match &sc {\n- ScopeId::Public => bc_threads(Some(&tag)),\n- ScopeId::Room(rid) => {\n- let slug = if let Some((_, s)) = rid.split_once('/') {\n- s\n- } else {\n- rid.as_str()\n- };\n- bc_room(&nav, slug, Some(&tag))\n- }\n- };\n-\n- let cli = match &sc {\n- ScopeId::Public => format!(\"npx slugsocial public forum show {tag}\"),\n- ScopeId::Room(r) => format!(\"npx slugsocial private {r} forum show {tag}\"),\n- };\n-\n- let page = layout(\n- &format!(\"#{tag}\"),\n- \"view-thread\",\n- html! {\n- (strip)\n- nav class=\"breadcrumb\" { (bc) }\n- p class=\"muted\" { \"top=oldest · bottom=newest\" }\n- div id=\"thread-feed-region\" {\n- @if display_ingests.is_empty() {\n- p class=\"muted\" { \"no activity yet\" }\n- } @else {\n- (paginator_top)\n- @for row in &entry_rows {\n- (row)\n- }\n- (paginator_bot)\n- }\n- }\n- div id=\"thread-live-region\" {\n- (compose_form(&nav, &tag, show_compose))\n- }\n- (cli_panel(std::slice::from_ref(&cli)))\n- },\n- None,\n- theme_from_jar(&jar),\n- &theme_next_from_uri(&uri),\n- );\n- Html(page.into_string()).into_response()\n-}\n-\n-/// Thread view — `/t/:tag`\n-pub async fn thread_view(\n- State(state): State,\n- Path(tag): Path,\n- Query(q): Query,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar, uri).await\n-}\n-\n-/// Room thread — `/r/:short/:slug/t/:tag`\n-pub async fn room_thread_view(\n- State(state): State,\n- Path((room_short, room_slug, tag)): Path<(String, String, String)>,\n- Query(q): Query,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let room_id = format!(\"{room_short}/{room_slug}\");\n- let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n- if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n- drop(reduced);\n- return room_not_found_page(&jar, &uri).into_response();\n- }\n- drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n- };\n- thread_view_inner(state, tag, q, nav, headers, jar, uri)\n- .await\n- .into_response()\n-}\n-\n-fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoResponse {\n- let body = html! {\n- nav class=\"breadcrumb\" { a href=\"/\" { \"slug.social\" } }\n- h1 { \"not found\" }\n- p { \"The requested page could not be found.\" }\n- p { a href=\"/\" { \"home\" } }\n- };\n- let page = layout(\n- \"not found — slug.social\",\n- \"view-thread\",\n- body,\n- None,\n- theme_from_jar(jar),\n- &theme_next_from_uri(uri),\n- );\n- (StatusCode::NOT_FOUND, Html(page.into_string()))\n-}\n-\n-/// Private room index — `/r/:short/:slug`\n-pub async fn room_page(\n- State(state): State,\n- Path((room_short, room_slug)): Path<(String, String)>,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let room_id = format!(\"{room_short}/{room_slug}\");\n- let now = now_ms();\n- let reduced = state.reduced.read().await;\n- if !reduced.rooms.contains(&room_id) {\n- drop(reduced);\n- return (StatusCode::NOT_FOUND, \"room not found\").into_response();\n- }\n- let user = optional_principal(&headers, &jar, &reduced);\n- if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n- drop(reduced);\n- return room_not_found_page(&jar, &uri).into_response();\n- }\n- let scope = ScopeId::Room(room_id.clone());\n- let mut rows = collect_thread_rows_for_scope(&reduced, &scope, now);\n- let strip = auth_strip(&headers, &jar, &reduced);\n- let show_new = user\n- .as_ref()\n- .map(|u| user_can_post_room(&reduced, &room_id, u))\n- .unwrap_or(false);\n- rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n-\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- drop(reduced);\n- return (StatusCode::NOT_FOUND, \"room not found\").into_response();\n- };\n- // Precompute markup that needs `&ReducerState`, then release the lock before `layout`.\n- // `tokio::sync::RwLock` queues new readers behind a waiting writer; holding the read\n- // guard across HTML/stringify could stall other navigations when an ingest needs a write lock.\n- let members_markup = room_members_section_markup(&reduced, &room_id, false);\n- let forum_cli = format!(\"npx slugsocial private {room_id} forum list\");\n- let garden_cli = format!(\"npx slugsocial private {room_id} garden tree\");\n- let audit_cli = format!(\"npx slugsocial private {room_id} audit\");\n- drop(reduced);\n-\n- let slug_display = room_slug.as_str();\n- let page = layout(\n- &format!(\"room {slug_display} — slug.social\"),\n- \"view-thread\",\n- html! {\n- (strip)\n- nav class=\"breadcrumb\" { (bc_room(&nav, slug_display, None)) }\n- (members_markup)\n- h3 { \"threads\" }\n- (render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now))\n- @if show_new {\n- div id=\"room-new-thread-ui-slot\" {\n- (new_thread_form_for_room(&nav, true, false))\n- }\n- }\n- (cli_panel(&[forum_cli, garden_cli, audit_cli]))\n- },\n- None,\n- theme_from_jar(&jar),\n- &theme_next_from_uri(&uri),\n- );\n- Html(page.into_string()).into_response()\n-}\n-\n-fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {\n- if !show {\n- return html! {};\n- }\n- // Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section).\n- html! {\n- div class=\"room-new-thread-slot-inner\" {\n- @if compose_expanded {\n- form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {\n- room_wire: nav.room_wire.clone(),\n- expanded: false,\n- }).expect(\"static json\"));\n- button type=\"submit\" class=\"form-toggle\" aria-expanded=\"true\" {\n- \"-\"\n- }\n- }\n- section class=\"compose\" id=\"room-new-thread-compose\" {\n- div id=\"room-new-thread-errors\" {}\n- form id=\"room-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n- \"action\": \"check_ingest\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": {\"$form\": \"thread_tag\"},\n- \"text\": {\"$form\": \"text\"},\n- \"error_target\": \"room-new-thread-errors\",\n- \"form_id\": \"room-new-thread-form\",\n- })).unwrap()) {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n- \"action\": \"post_ingest\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": {\"$form\": \"thread_tag\"},\n- \"text\": {\"$form\": \"text\"},\n- \"error_target\": \"room-new-thread-errors\",\n- \"form_id\": \"room-new-thread-form\",\n- })).unwrap());\n- input type=\"text\" id=\"room-new-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" required placeholder=\"thread-topic-slug-here\";\n- textarea name=\"text\" rows=\"4\" placeholder=\"First post body…\" required {}\n- p { button type=\"submit\" { \"post\" } }\n- }\n- }\n- } @else {\n- form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {\n- room_wire: nav.room_wire.clone(),\n- expanded: true,\n- }).expect(\"static json\"));\n- button type=\"submit\" class=\"form-toggle\" aria-expanded=\"false\" {\n- \"+\"\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-pub(crate) fn login_to_post_hint_markup() -> Markup {\n- html! {\n- p class=\"muted\" { \"log in to post\" }\n- }\n-}\n-\n-pub(crate) fn fragment_public_new_thread_form(show: bool) -> Markup {\n- new_thread_form_public(show)\n-}\n-\n-pub(crate) fn fragment_room_new_thread_form(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {\n- new_thread_form_for_room(nav, show, compose_expanded)\n-}\n-\n-async fn thread_post_view_inner(\n- state: AppState,\n- tag: String,\n- index_str: String,\n- nav: ThreadNav,\n- viewer: Option,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let tag = canonicalize_tag(&tag);\n- let index: usize = index_str.parse().unwrap_or(0);\n- let scope = nav.scope();\n- let now = now_ms();\n- let (ing, subtitle, body_markup) = {\n- let reduced = state.reduced.read().await;\n- let ing = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .and_then(|q| q.iter().rev().nth(index))\n- .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n- let body = ing.as_ref().map(|i| {\n- ingest_entry_markup(&nav, &tag, index, i, viewer.as_deref(), now, &reduced)\n- });\n- (ing, None::, body)\n- };\n-\n- let sc = nav.scope();\n- let bc: Markup = match &sc {\n- ScopeId::Public => bc_threads(Some(&tag)),\n- ScopeId::Room(rid) => {\n- let slug = if let Some((_, s)) = rid.split_once('/') {\n- s\n- } else {\n- rid.as_str()\n- };\n- bc_room(&nav, slug, Some(&tag))\n- }\n- };\n-\n- let page = layout(\n- &format!(\"#{tag} / post #{index}\"),\n- \"view-thread\",\n- html! {\n- nav class=\"breadcrumb\" { (bc) }\n- h2 { \"#\" (tag) @if let Some(sub) = &subtitle { \": \" (sub) } \" / post #\" (index) }\n- @if let (Some(_ing), Some(bm)) = (&ing, &body_markup) {\n- (bm)\n- } @else {\n- p class=\"muted\" { \"post not found\" }\n- }\n- },\n- None,\n- theme_from_jar(&jar),\n- &theme_next_from_uri(&uri),\n- );\n- Html(page.into_string()).into_response()\n-}\n-\n-pub async fn thread_post_view(\n- State(state): State,\n- Path((tag, index_str)): Path<(String, String)>,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let reduced = state.reduced.read().await;\n- let viewer = optional_principal(&headers, &jar, &reduced);\n- drop(reduced);\n- thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer, jar, uri)\n- .await\n-}\n-\n-pub async fn room_thread_post_view(\n- State(state): State,\n- Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let room_id = format!(\"{room_short}/{room_slug}\");\n- let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n- if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n- drop(reduced);\n- return room_not_found_page(&jar, &uri).into_response();\n- }\n- drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n- };\n- thread_post_view_inner(state, tag, index_str, nav, user, jar, uri)\n- .await\n- .into_response()\n-}\n-\n-pub(crate) async fn thread_ui_expand_post_full(\n- state: &AppState,\n- room: &str,\n- thread_tag: &str,\n- post_index: usize,\n- viewer: Option<&str>,\n-) -> axum::response::Response {\n- let room = room.trim();\n- let tag = canonicalize_tag(thread_tag);\n- let scope = scope_from_room_wire(room);\n- let nav = if room.is_empty() || room == \"public\" {\n- ThreadNav::public()\n- } else {\n- let Some(n) = ThreadNav::from_room_id(room) else {\n- return super::ui_js_warn(\"bad room\");\n- };\n- n\n- };\n- if let ScopeId::Room(ref rid) = scope {\n- let reduced = state.reduced.read().await;\n- if !user_can_view_room(&reduced, rid, viewer) {\n- return super::ui_js_warn(\"forbidden\");\n- }\n- }\n-\n- let now = now_ms();\n- let reduced = state.reduced.read().await;\n- let ing = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .and_then(|q| q.iter().rev().nth(post_index))\n- .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n- let Some(ing) = ing else {\n- return super::ui_js_warn(\"post not found\");\n- };\n- if reduced.redacted_posts.contains(&ing.id) {\n- return super::ui_js_warn(\"post not found\");\n- }\n- let ing_id = ing.id.clone();\n- drop(reduced);\n-\n- let full_html = html! {\n- div class=\"ingest-entry\" data-ingest-id=(ing_id) {\n- (post_header_row(\n- &nav,\n- &tag,\n- post_index,\n- &ing,\n- viewer,\n- now,\n- viewer == Some(ing.principal.as_str()),\n- ))\n- (render_linkified_with_embeds_in_scope(&ing.raw, nav.garden_root_url()))\n- }\n- };\n-\n- JsBuilder::new()\n- .morph_selector(&format!(\"[data-ingest-id=\\\"{}\\\"]\", ing_id), full_html)\n- .into_response()\n-}\n-\n-pub(crate) async fn thread_ui_expand_redacted_post(\n- state: &AppState,\n- room: &str,\n- thread_tag: &str,\n- post_index: usize,\n- viewer: Option<&str>,\n-) -> axum::response::Response {\n- let room = room.trim();\n- let tag = canonicalize_tag(thread_tag);\n- let scope = scope_from_room_wire(room);\n- let nav = if room.is_empty() || room == \"public\" {\n- ThreadNav::public()\n- } else {\n- let Some(n) = ThreadNav::from_room_id(room) else {\n- return super::ui_js_warn(\"bad room\");\n- };\n- n\n- };\n- if let ScopeId::Room(ref rid) = scope {\n- let reduced = state.reduced.read().await;\n- if !user_can_view_room(&reduced, rid, viewer) {\n- return super::ui_js_warn(\"forbidden\");\n- }\n- }\n-\n- let now = now_ms();\n- let reduced = state.reduced.read().await;\n- let ing = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .and_then(|q| q.iter().rev().nth(post_index))\n- .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n- let Some(ing) = ing else {\n- return super::ui_js_warn(\"post not found\");\n- };\n- if !reduced.redacted_posts.contains(&ing.id) {\n- return super::ui_js_warn(\"post not found\");\n- }\n- let ing_id = ing.id.clone();\n- drop(reduced);\n-\n- let full_html = html! {\n- div class=\"ingest-entry ingest-redacted-expanded\" data-ingest-id=(ing_id) {\n- (redacted_header_row(&nav, &tag, post_index, &ing, now, true))\n- (render_linkified_with_embeds_in_scope(&ing.raw, nav.garden_root_url()))\n- }\n- };\n-\n- JsBuilder::new()\n- .morph_selector(&format!(\"[data-ingest-id=\\\"{}\\\"]\", ing_id), full_html)\n- .into_response()\n-}\n-\n-pub(crate) async fn thread_ui_collapse_redacted_post(\n- state: &AppState,\n- room: &str,\n- thread_tag: &str,\n- post_index: usize,\n- viewer: Option<&str>,\n-) -> axum::response::Response {\n- let room = room.trim();\n- let tag = canonicalize_tag(thread_tag);\n- let scope = scope_from_room_wire(room);\n- let nav = if room.is_empty() || room == \"public\" {\n- ThreadNav::public()\n- } else {\n- let Some(n) = ThreadNav::from_room_id(room) else {\n- return super::ui_js_warn(\"bad room\");\n- };\n- n\n- };\n- if let ScopeId::Room(ref rid) = scope {\n- let reduced = state.reduced.read().await;\n- if !user_can_view_room(&reduced, rid, viewer) {\n- return super::ui_js_warn(\"forbidden\");\n- }\n- }\n-\n- let now = now_ms();\n- let reduced = state.reduced.read().await;\n- let ing = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), tag.clone()))\n- .and_then(|q| q.iter().rev().nth(post_index))\n- .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n- let Some(ing) = ing else {\n- return super::ui_js_warn(\"post not found\");\n- };\n- if !reduced.redacted_posts.contains(&ing.id) {\n- return super::ui_js_warn(\"post not found\");\n- }\n- let ing_id = ing.id.clone();\n- let collapsed = ingest_entry_markup(&nav, &tag, post_index, &ing, viewer, now, &reduced);\n- drop(reduced);\n-\n- JsBuilder::new()\n- .morph_selector(&format!(\"[data-ingest-id=\\\"{}\\\"]\", ing_id), collapsed)\n- .into_response()\n-}\n-\n-struct ProfilePostRow {\n- thread_tag: String,\n- post_idx: usize,\n- post_href: String,\n- ts: i64,\n- snippet: String,\n-}\n-\n-pub async fn user_profile_page(\n- State(state): State,\n- Path(username): Path,\n- headers: HeaderMap,\n- jar: CookieJar,\n- uri: Uri,\n-) -> impl IntoResponse {\n- let canon = match parse_username(&username) {\n- Ok(u) => u,\n- Err(_) => return (StatusCode::NOT_FOUND, \"not found\").into_response(),\n- };\n-\n- let (rows, strip) = {\n- let reduced = state.reduced.read().await;\n- let viewer = optional_principal(&headers, &jar, &reduced);\n- let ids = reduced.visible_posts_for_actor(&canon, viewer.as_deref());\n- let mut rows: Vec = Vec::new();\n- for id in ids {\n- if reduced.redacted_posts.contains(&id) {\n- continue;\n- }\n- let Some(ing) = reduced.ingests_by_id.get(&id).cloned() else {\n- continue;\n- };\n- let Some(nav) = thread_nav_for_ingest(&ing) else {\n- continue;\n- };\n- let Some(post_idx) = thread_post_index_in_scope(&reduced, &ing) else {\n- continue;\n- };\n- let tag = canonicalize_tag(&ing.thread_tag);\n- let post_href = nav.post_url(&tag, post_idx);\n- let raw_one_line = ing.raw.lines().next().unwrap_or(\"\").trim();\n- let snippet: String = raw_one_line.chars().take(120).collect();\n- rows.push(ProfilePostRow {\n- thread_tag: tag,\n- post_idx,\n- post_href,\n- ts: ing.ts,\n- snippet,\n- });\n- }\n- rows.sort_by(|a, b| b.ts.cmp(&a.ts));\n- let strip = auth_strip(&headers, &jar, &reduced);\n- (rows, strip)\n- };\n-\n- let now = now_ms();\n- let page = layout(\n- &format!(\"@{canon}\"),\n- \"view-thread\",\n- html! {\n- (strip)\n- nav class=\"breadcrumb\" {\n- a href=\"/\" { \"slug.social\" }\n- (bc_segment(&format!(\"@{canon}\"), &profile_href(&canon), true))\n- }\n- h2 { \"@\" (canon) }\n- p class=\"muted\" { \"posts (newest first)\" }\n- @if rows.is_empty() {\n- p class=\"muted\" { \"no public posts yet\" }\n- } @else {\n- ul class=\"profile-post-list\" {\n- @for r in &rows {\n- @let hover = timeago::rfc3339_utc(r.ts);\n- @let ago = timeago::timeago(now, r.ts);\n- li {\n- a href=(r.post_href.as_str()) {\n- \"#\" (r.thread_tag)\n- \" / #\"\n- (r.post_idx)\n- }\n- span class=\"muted\" title=(hover) { \" · \" (ago) }\n- @if !r.snippet.is_empty() {\n- p class=\"profile-post-snippet muted\" { (r.snippet) }\n- }\n- }\n- }\n- }\n- }\n- (cli_panel(&[format!(\"npx slugsocial public forum list\")]))\n- },\n- None,\n- theme_from_jar(&jar),\n- &theme_next_from_uri(&uri),\n- );\n- Html(page.into_string()).into_response()\n-}\ndiff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..30b95ff6e85245a82199c96e41484d03c24989dd\n--- /dev/null\n+++ b/server/src/html/forum/feed.rs\n@@ -0,0 +1,261 @@\n+use axum::{\n+ extract::State,\n+ http::{HeaderMap, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::{html, Markup};\n+\n+use crate::api::optional_principal;\n+use crate::canonical_path::canonicalize_tag;\n+use crate::reducer::{ReducerState, ScopeId};\n+use crate::state::AppState;\n+use crate::timeago;\n+\n+use super::ingest::ingest_entry_markup;\n+use super::nav::ThreadNav;\n+use super::page::auth_strip;\n+use super::paginator::{render_thread_paginator, PAGE_SIZE};\n+use crate::html::{\n+ bc_threads, cli_panel, layout, now_ms, recency_class, theme_from_jar, theme_next_from_uri,\n+};\n+\n+#[derive(Clone)]\n+pub(super) struct ThreadRow {\n+ pub(super) tag: String,\n+ pub(super) subtitle: Option,\n+ pub(super) last_ts: i64,\n+ pub(super) ingests: usize,\n+}\n+\n+pub(super) fn collect_thread_rows_for_scope(reduced: &ReducerState, scope: &ScopeId, now: i64) -> Vec {\n+ let _ = now;\n+ reduced\n+ .forum_threads\n+ .iter()\n+ .filter(|((s, _), _)| s == scope)\n+ .map(|((_, tag), thread)| {\n+ let ingests = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .map(|q| q.len())\n+ .unwrap_or(0);\n+ ThreadRow {\n+ tag: tag.clone(),\n+ subtitle: None,\n+ last_ts: thread.last_activity_ts,\n+ ingests,\n+ }\n+ })\n+ .collect()\n+}\n+\n+pub(super) fn rooms_for_user(reduced: &ReducerState, username: &str) -> Vec {\n+ let mut v: Vec = reduced\n+ .grants\n+ .iter()\n+ .filter(|(rid, m)| reduced.rooms.contains(*rid) && m.contains_key(username))\n+ .map(|(rid, _)| rid.clone())\n+ .collect();\n+ v.sort();\n+ v\n+}\n+\n+/// `feed_id` is e.g. `thread-feed` (public bump list, SSE) or `room-thread-feed`.\n+pub(super) fn render_thread_feed(nav: Option<&ThreadNav>, feed_id: &str, rows: &[ThreadRow], now: i64) -> Markup {\n+ html! {\n+ div id=(feed_id) {\n+ @if rows.is_empty() {\n+ p class=\"muted\" { \"no threads yet\" }\n+ } @else {\n+ ul class=\"thread-feed\" {\n+ @for r in rows {\n+ @let thread_href = nav\n+ .as_ref()\n+ .map(|n| n.thread_url(&r.tag))\n+ .unwrap_or_else(|| format!(\"/t/{}\", r.tag));\n+ @let hover = timeago::rfc3339_utc(r.last_ts);\n+ @let ago = timeago::timeago(now, r.last_ts);\n+ @let age_cls = recency_class(now, r.last_ts);\n+ li class=(age_cls) {\n+ a href=(thread_href) {\n+ \"#\" (r.tag)\n+ @if let Some(subtitle) = &r.subtitle {\n+ \": \" (subtitle)\n+ }\n+ \" \"\n+ span class=\"muted\" title=(hover) {\n+ (ago)\n+ \" · \"\n+ (format!(\"{}n\", r.ingests))\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+/// Returns the current public thread feed markup for SSE (`#thread-feed`).\n+pub async fn thread_feed_html(state: &AppState) -> Markup {\n+ let now = now_ms();\n+ let nav = ThreadNav::public();\n+ let mut rows = {\n+ let reduced = state.reduced.read().await;\n+ collect_thread_rows_for_scope(&reduced, &ScopeId::Public, now)\n+ };\n+ rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n+ render_thread_feed(Some(&nav), \"thread-feed\", &rows, now)\n+}\n+\n+/// Returns the current private room thread feed markup for SSE (`#room-thread-feed`).\n+pub async fn thread_feed_html_for_room(state: &AppState, room_id: &str) -> Markup {\n+ let now = now_ms();\n+ let Some(nav) = ThreadNav::from_room_id(room_id) else {\n+ return html! { div id=\"room-thread-feed\" { p class=\"muted\" { \"room not found\" } } };\n+ };\n+ let mut rows = {\n+ let reduced = state.reduced.read().await;\n+ collect_thread_rows_for_scope(&reduced, &ScopeId::Room(room_id.to_string()), now)\n+ };\n+ rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n+ render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now)\n+}\n+\n+fn render_thread_feed_region_markup(\n+ nav: &ThreadNav,\n+ tag: &str,\n+ display_ingests: &[crate::events::Ingest],\n+ offset: usize,\n+ total: usize,\n+ now: i64,\n+ viewer: Option<&str>,\n+ reduced: &ReducerState,\n+) -> Markup {\n+ let paginator_top = render_thread_paginator(nav, tag, offset, total, true);\n+ let paginator_bot = render_thread_paginator(nav, tag, offset, total, false);\n+ html! {\n+ div id=\"thread-feed-region\" {\n+ @if display_ingests.is_empty() {\n+ p class=\"muted\" { \"no activity yet\" }\n+ } @else {\n+ (paginator_top)\n+ @for (i, ing) in display_ingests.iter().enumerate() {\n+ @let post_idx = offset + i;\n+ (ingest_entry_markup(nav, tag, post_idx, ing, viewer, now, reduced))\n+ }\n+ (paginator_bot)\n+ }\n+ }\n+ }\n+}\n+\n+pub async fn thread_feed_region_markup(\n+ state: &AppState,\n+ room_id: Option<&str>,\n+ tag: &str,\n+ viewer: Option<&str>,\n+) -> Markup {\n+ let tag = canonicalize_tag(tag);\n+ let Some(nav) = (match room_id {\n+ Some(room_id) => ThreadNav::from_room_id(room_id),\n+ None => Some(ThreadNav::public()),\n+ }) else {\n+ return html! { div id=\"thread-feed-region\" { p class=\"muted\" { \"thread not found\" } } };\n+ };\n+ let scope = nav.scope();\n+ let all_ids: Vec = {\n+ let reduced = state.reduced.read().await;\n+ reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .map(|q| q.iter().rev().cloned().collect())\n+ .unwrap_or_default()\n+ };\n+ let total = all_ids.len();\n+ let offset = total.saturating_sub(PAGE_SIZE);\n+ let page_ids: Vec = all_ids.into_iter().skip(offset).take(PAGE_SIZE).collect();\n+ let reduced = state.reduced.read().await;\n+ let display_ingests = page_ids\n+ .iter()\n+ .filter_map(|id| reduced.ingests_by_id.get(id).cloned())\n+ .collect::>();\n+ render_thread_feed_region_markup(\n+ &nav,\n+ &tag,\n+ &display_ingests,\n+ offset,\n+ total,\n+ now_ms(),\n+ viewer,\n+ &reduced,\n+ )\n+}\n+\n+/// Home: private rooms (signed-in), then public bump-ordered threads.\n+pub async fn home(\n+ State(state): State,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let now = now_ms();\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ let room_ids = user\n+ .as_ref()\n+ .map(|u| rooms_for_user(&reduced, u))\n+ .unwrap_or_default();\n+ let mut public_rows = collect_thread_rows_for_scope(&reduced, &ScopeId::Public, now);\n+ drop(reduced);\n+ public_rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n+\n+ let nav = ThreadNav::public();\n+ let reduced_read = state.reduced.read().await;\n+ let strip = auth_strip(&headers, &jar, &reduced_read);\n+ drop(reduced_read);\n+\n+ use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n+ use crate::form_template::template_json_compact;\n+\n+ let page = layout(\n+ \"slug.social\",\n+ \"view-thread\",\n+ html! {\n+ (strip)\n+ nav class=\"breadcrumb\" { (bc_threads(None)) }\n+ @if !room_ids.is_empty() {\n+ h2 { \"your rooms\" }\n+ ul class=\"thread-feed\" {\n+ @for rid in &room_ids {\n+ @if let Some(nav_r) = ThreadNav::from_room_id(rid) {\n+ @let slug = if let Some((_, s)) = rid.split_once('/') { s } else { rid.as_str() };\n+ li {\n+ a href=(nav_r.room_url()) {\n+ (slug)\n+ span class=\"muted\" { \" · \" (rid) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ p class=\"muted\" { \"dark = time-ordered · light = vote-ranked\" }\n+ div class=\"thread-feed-toolbar\" {\n+ form method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect(\"static json\"));\n+ button type=\"submit\" class=\"section-add-btn\" { \"+\" }\n+ }\n+ }\n+ div id=\"public-new-thread-ui-slot\" {}\n+ (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n+ (cli_panel(&[\"npx slugsocial public forum list\"]))\n+ },\n+ None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string())\n+}\ndiff --git a/server/src/html/forum/ingest.rs b/server/src/html/forum/ingest.rs\nindex c045046a005db4f8f4d1dc7ea74aaaba45e0f3f4..5bd0c09c546e4ab8ed9e03a8c4879efc9bdc7188 100644\n--- a/server/src/html/forum/ingest.rs\n+++ b/server/src/html/forum/ingest.rs\n@@ -6,7 +6,8 @@ use serde_json::json;\n \n use crate::html::js_string_literal;\n use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n-use crate::html::{profile_href, render_linkified_with_embeds_in_scope, timeago};\n+use crate::html::{profile_href, render_linkified_with_embeds_in_scope};\n+use crate::timeago;\n \n use super::nav::ThreadNav;\n \ndiff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..8fbf3b71f41fafb474999ebb3d622e6bd5b5f313\n--- /dev/null\n+++ b/server/src/html/forum/mod.rs\n@@ -0,0 +1,29 @@\n+//! Forum / thread HTML: public home, room index, thread views, profile, and UI morph helpers.\n+\n+mod access;\n+mod feed;\n+mod ingest;\n+mod nav;\n+mod new_thread;\n+mod page;\n+mod paginator;\n+mod post_single;\n+mod profile;\n+mod room_members;\n+mod thread_morph;\n+mod views;\n+\n+pub use feed::{home, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup};\n+pub use nav::ThreadNav;\n+pub use post_single::{room_thread_post_view, thread_post_view};\n+pub use profile::user_profile_page;\n+pub use views::{room_page, room_thread_view, thread_view};\n+\n+pub(crate) use access::{user_can_post_room, user_can_view_room};\n+pub(crate) use new_thread::{\n+ fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n+};\n+pub(crate) use room_members::room_members_section_markup;\n+pub(crate) use thread_morph::{\n+ thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n+};\ndiff --git a/server/src/html/forum/page.rs b/server/src/html/forum/page.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..b0461fc22e0d7b9c865c483362113d60e992e0b8\n--- /dev/null\n+++ b/server/src/html/forum/page.rs\n@@ -0,0 +1,50 @@\n+use axum::http::HeaderMap;\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::{html, Markup};\n+\n+use crate::api::optional_principal;\n+use crate::reducer::ReducerState;\n+\n+use super::nav::ThreadNav;\n+use crate::html::bc_segment;\n+\n+pub(super) fn auth_strip(\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ reduced: &ReducerState,\n+) -> Markup {\n+ match optional_principal(headers, jar, reduced) {\n+ Some(u) => html! {\n+ p class=\"muted auth-strip\" {\n+ \"@\" (u)\n+ \" · \"\n+ a href=\"/logout\" { \"log out\" }\n+ }\n+ },\n+ None => html! {\n+ p class=\"muted auth-strip\" {\n+ a href=\"/login\" { \"log in\" }\n+ }\n+ },\n+ }\n+}\n+\n+pub(super) fn bc_room(nav: &ThreadNav, room_slug: &str, thread_tag: Option<&str>) -> Markup {\n+ html! {\n+ a href=\"/\" { \"slug.social\" }\n+ @if let Some(t) = thread_tag {\n+ (bc_segment(\n+ &format!(\"room:{room_slug}\"),\n+ nav.room_url(),\n+ false,\n+ ))\n+ (bc_segment(&format!(\"#{t}\"), &nav.thread_url(t), true))\n+ } @else {\n+ (bc_segment(\n+ &format!(\"room:{room_slug}\"),\n+ nav.room_url(),\n+ true,\n+ ))\n+ }\n+ }\n+}\ndiff --git a/server/src/html/forum/paginator.rs b/server/src/html/forum/paginator.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..af2fda58a409673c51669d1d6f23baf7072c979a\n--- /dev/null\n+++ b/server/src/html/forum/paginator.rs\n@@ -0,0 +1,42 @@\n+use maud::{html, Markup};\n+\n+use super::nav::ThreadNav;\n+\n+pub(super) const PAGE_SIZE: usize = 10;\n+\n+pub(super) fn render_thread_paginator(nav: &ThreadNav, tag: &str, offset: usize, total: usize, top: bool) -> Markup {\n+ let newer_offset = offset.checked_add(PAGE_SIZE).filter(|&o| o < total);\n+ let older_offset = if offset > 0 {\n+ Some(offset.saturating_sub(PAGE_SIZE))\n+ } else {\n+ None\n+ };\n+ let latest_offset = total.saturating_sub(PAGE_SIZE);\n+ let on_latest = offset >= latest_offset;\n+ let (id, scroll_href, scroll_label) = if top {\n+ (\"top\", \"#bottom\", \"↓\")\n+ } else {\n+ (\"bottom\", \"#top\", \"↑\")\n+ };\n+ html! {\n+ div class=\"thread-paginator\" id=(id) {\n+ a href=(scroll_href) class=\"post-nav-btn\" { (scroll_label) }\n+ @if let Some(o) = older_offset {\n+ a href=(nav.thread_page_url(tag, o)) class=\"post-nav-btn\" { \"← older\" }\n+ } @else {\n+ a href=\"#\" class=\"post-nav-btn disabled\" { \"← older\" }\n+ }\n+ span class=\"post-nav-pos muted\" {\n+ (offset + 1) \"–\" (total.min(offset + PAGE_SIZE)) \" / \" (total)\n+ }\n+ @if let Some(o) = newer_offset {\n+ a href=(nav.thread_page_url(tag, o)) class=\"post-nav-btn\" { \"newer →\" }\n+ } @else {\n+ a href=\"#\" class=\"post-nav-btn disabled\" { \"newer →\" }\n+ }\n+ @if !on_latest {\n+ a href=(nav.thread_page_url(tag, latest_offset)) class=\"post-nav-btn\" { \"latest\" }\n+ }\n+ }\n+ }\n+}\ndiff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c316f8f836df9d4ef9c05ebd9e54f699540e6d72\n--- /dev/null\n+++ b/server/src/html/forum/post_single.rs\n@@ -0,0 +1,115 @@\n+use axum::{\n+ extract::{Path, State},\n+ http::{HeaderMap, StatusCode, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::html;\n+\n+use crate::api::optional_principal;\n+use crate::canonical_path::canonicalize_tag;\n+use crate::reducer::ScopeId;\n+use crate::state::AppState;\n+\n+use super::access::user_can_view_room;\n+use super::ingest::ingest_entry_markup;\n+use super::nav::ThreadNav;\n+use super::page::bc_room;\n+use super::views::room_not_found_page;\n+use crate::html::{bc_threads, layout, now_ms, theme_from_jar, theme_next_from_uri};\n+use maud::Markup;\n+\n+async fn thread_post_view_inner(\n+ state: AppState,\n+ tag: String,\n+ index_str: String,\n+ nav: ThreadNav,\n+ viewer: Option,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let tag = canonicalize_tag(&tag);\n+ let index: usize = index_str.parse().unwrap_or(0);\n+ let scope = nav.scope();\n+ let now = now_ms();\n+ let (ing, subtitle, body_markup) = {\n+ let reduced = state.reduced.read().await;\n+ let ing = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .and_then(|q| q.iter().rev().nth(index))\n+ .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n+ let body = ing.as_ref().map(|i| {\n+ ingest_entry_markup(&nav, &tag, index, i, viewer.as_deref(), now, &reduced)\n+ });\n+ (ing, None::, body)\n+ };\n+\n+ let sc = nav.scope();\n+ let bc: Markup = match &sc {\n+ ScopeId::Public => bc_threads(Some(&tag)),\n+ ScopeId::Room(rid) => {\n+ let slug = if let Some((_, s)) = rid.split_once('/') {\n+ s\n+ } else {\n+ rid.as_str()\n+ };\n+ bc_room(&nav, slug, Some(&tag))\n+ }\n+ };\n+\n+ let page = layout(\n+ &format!(\"#{tag} / post #{index}\"),\n+ \"view-thread\",\n+ html! {\n+ nav class=\"breadcrumb\" { (bc) }\n+ h2 { \"#\" (tag) @if let Some(sub) = &subtitle { \": \" (sub) } \" / post #\" (index) }\n+ @if let (Some(_ing), Some(bm)) = (&ing, &body_markup) {\n+ (bm)\n+ } @else {\n+ p class=\"muted\" { \"post not found\" }\n+ }\n+ },\n+ None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string()).into_response()\n+}\n+\n+pub async fn thread_post_view(\n+ State(state): State,\n+ Path((tag, index_str)): Path<(String, String)>,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let reduced = state.reduced.read().await;\n+ let viewer = optional_principal(&headers, &jar, &reduced);\n+ drop(reduced);\n+ thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer, jar, uri)\n+ .await\n+}\n+\n+pub async fn room_thread_post_view(\n+ State(state): State,\n+ Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let room_id = format!(\"{room_short}/{room_slug}\");\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ drop(reduced);\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n+ thread_post_view_inner(state, tag, index_str, nav, user, jar, uri)\n+ .await\n+ .into_response()\n+}\ndiff --git a/server/src/html/forum/profile.rs b/server/src/html/forum/profile.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6f9574a975bd0a33460bb2ab79760c272019f6bf\n--- /dev/null\n+++ b/server/src/html/forum/profile.rs\n@@ -0,0 +1,116 @@\n+use axum::{\n+ extract::{Path, State},\n+ http::{HeaderMap, StatusCode, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::html;\n+\n+use crate::api::optional_principal;\n+use crate::canonical_path::canonicalize_tag;\n+use crate::identity::parse_username;\n+use crate::state::AppState;\n+\n+use super::ingest::{thread_nav_for_ingest, thread_post_index_in_scope};\n+use super::page::auth_strip;\n+use crate::timeago;\n+use crate::html::{\n+ bc_segment, cli_panel, layout, now_ms, profile_href, theme_from_jar, theme_next_from_uri,\n+};\n+\n+struct ProfilePostRow {\n+ thread_tag: String,\n+ post_idx: usize,\n+ post_href: String,\n+ ts: i64,\n+ snippet: String,\n+}\n+\n+pub async fn user_profile_page(\n+ State(state): State,\n+ Path(username): Path,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let canon = match parse_username(&username) {\n+ Ok(u) => u,\n+ Err(_) => return (StatusCode::NOT_FOUND, \"not found\").into_response(),\n+ };\n+\n+ let (rows, strip) = {\n+ let reduced = state.reduced.read().await;\n+ let viewer = optional_principal(&headers, &jar, &reduced);\n+ let ids = reduced.visible_posts_for_actor(&canon, viewer.as_deref());\n+ let mut rows: Vec = Vec::new();\n+ for id in ids {\n+ if reduced.redacted_posts.contains(&id) {\n+ continue;\n+ }\n+ let Some(ing) = reduced.ingests_by_id.get(&id).cloned() else {\n+ continue;\n+ };\n+ let Some(nav) = thread_nav_for_ingest(&ing) else {\n+ continue;\n+ };\n+ let Some(post_idx) = thread_post_index_in_scope(&reduced, &ing) else {\n+ continue;\n+ };\n+ let tag = canonicalize_tag(&ing.thread_tag);\n+ let post_href = nav.post_url(&tag, post_idx);\n+ let raw_one_line = ing.raw.lines().next().unwrap_or(\"\").trim();\n+ let snippet: String = raw_one_line.chars().take(120).collect();\n+ rows.push(ProfilePostRow {\n+ thread_tag: tag,\n+ post_idx,\n+ post_href,\n+ ts: ing.ts,\n+ snippet,\n+ });\n+ }\n+ rows.sort_by(|a, b| b.ts.cmp(&a.ts));\n+ let strip = auth_strip(&headers, &jar, &reduced);\n+ (rows, strip)\n+ };\n+\n+ let now = now_ms();\n+ let page = layout(\n+ &format!(\"@{canon}\"),\n+ \"view-thread\",\n+ html! {\n+ (strip)\n+ nav class=\"breadcrumb\" {\n+ a href=\"/\" { \"slug.social\" }\n+ (bc_segment(&format!(\"@{canon}\"), &profile_href(&canon), true))\n+ }\n+ h2 { \"@\" (canon) }\n+ p class=\"muted\" { \"posts (newest first)\" }\n+ @if rows.is_empty() {\n+ p class=\"muted\" { \"no public posts yet\" }\n+ } @else {\n+ ul class=\"profile-post-list\" {\n+ @for r in &rows {\n+ @let hover = timeago::rfc3339_utc(r.ts);\n+ @let ago = timeago::timeago(now, r.ts);\n+ li {\n+ a href=(r.post_href.as_str()) {\n+ \"#\" (r.thread_tag)\n+ \" / #\"\n+ (r.post_idx)\n+ }\n+ span class=\"muted\" title=(hover) { \" · \" (ago) }\n+ @if !r.snippet.is_empty() {\n+ p class=\"profile-post-snippet muted\" { (r.snippet) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ (cli_panel(&[format!(\"npx slugsocial public forum list\")]))\n+ },\n+ None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string()).into_response()\n+}\ndiff --git a/server/src/html/forum/thread_morph.rs b/server/src/html/forum/thread_morph.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..77ac43b159a7f353e47b1f8368da07bb4392daf8\n--- /dev/null\n+++ b/server/src/html/forum/thread_morph.rs\n@@ -0,0 +1,171 @@\n+use maud::html;\n+\n+use crate::canonical_path::canonicalize_tag;\n+use crate::reducer::{scope_from_room_wire, ScopeId};\n+use crate::state::AppState;\n+\n+use super::access::user_can_view_room;\n+use super::ingest::{ingest_entry_markup, post_header_row, redacted_header_row};\n+use super::nav::ThreadNav;\n+use crate::html::{render_linkified_with_embeds_in_scope, JsBuilder, now_ms};\n+\n+pub(crate) async fn thread_ui_expand_post_full(\n+ state: &AppState,\n+ room: &str,\n+ thread_tag: &str,\n+ post_index: usize,\n+ viewer: Option<&str>,\n+) -> axum::response::Response {\n+ let room = room.trim();\n+ let tag = canonicalize_tag(thread_tag);\n+ let scope = scope_from_room_wire(room);\n+ let nav = if room.is_empty() || room == \"public\" {\n+ ThreadNav::public()\n+ } else {\n+ let Some(n) = ThreadNav::from_room_id(room) else {\n+ return crate::html::ui_js_warn(\"bad room\");\n+ };\n+ n\n+ };\n+ if let ScopeId::Room(ref rid) = scope {\n+ let reduced = state.reduced.read().await;\n+ if !user_can_view_room(&reduced, rid, viewer) {\n+ return crate::html::ui_js_warn(\"forbidden\");\n+ }\n+ }\n+\n+ let now = now_ms();\n+ let reduced = state.reduced.read().await;\n+ let ing = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .and_then(|q| q.iter().rev().nth(post_index))\n+ .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n+ let Some(ing) = ing else {\n+ return crate::html::ui_js_warn(\"post not found\");\n+ };\n+ if reduced.redacted_posts.contains(&ing.id) {\n+ return crate::html::ui_js_warn(\"post not found\");\n+ }\n+ let ing_id = ing.id.clone();\n+ drop(reduced);\n+\n+ let full_html = html! {\n+ div class=\"ingest-entry\" data-ingest-id=(ing_id) {\n+ (post_header_row(\n+ &nav,\n+ &tag,\n+ post_index,\n+ &ing,\n+ viewer,\n+ now,\n+ viewer == Some(ing.principal.as_str()),\n+ ))\n+ (render_linkified_with_embeds_in_scope(&ing.raw, nav.garden_root_url()))\n+ }\n+ };\n+\n+ JsBuilder::new()\n+ .morph_selector(&format!(\"[data-ingest-id=\\\"{}\\\"]\", ing_id), full_html)\n+ .into_response()\n+}\n+\n+pub(crate) async fn thread_ui_expand_redacted_post(\n+ state: &AppState,\n+ room: &str,\n+ thread_tag: &str,\n+ post_index: usize,\n+ viewer: Option<&str>,\n+) -> axum::response::Response {\n+ let room = room.trim();\n+ let tag = canonicalize_tag(thread_tag);\n+ let scope = scope_from_room_wire(room);\n+ let nav = if room.is_empty() || room == \"public\" {\n+ ThreadNav::public()\n+ } else {\n+ let Some(n) = ThreadNav::from_room_id(room) else {\n+ return crate::html::ui_js_warn(\"bad room\");\n+ };\n+ n\n+ };\n+ if let ScopeId::Room(ref rid) = scope {\n+ let reduced = state.reduced.read().await;\n+ if !user_can_view_room(&reduced, rid, viewer) {\n+ return crate::html::ui_js_warn(\"forbidden\");\n+ }\n+ }\n+\n+ let now = now_ms();\n+ let reduced = state.reduced.read().await;\n+ let ing = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .and_then(|q| q.iter().rev().nth(post_index))\n+ .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n+ let Some(ing) = ing else {\n+ return crate::html::ui_js_warn(\"post not found\");\n+ };\n+ if !reduced.redacted_posts.contains(&ing.id) {\n+ return crate::html::ui_js_warn(\"post not found\");\n+ }\n+ let ing_id = ing.id.clone();\n+ drop(reduced);\n+\n+ let full_html = html! {\n+ div class=\"ingest-entry ingest-redacted-expanded\" data-ingest-id=(ing_id) {\n+ (redacted_header_row(&nav, &tag, post_index, &ing, now, true))\n+ (render_linkified_with_embeds_in_scope(&ing.raw, nav.garden_root_url()))\n+ }\n+ };\n+\n+ JsBuilder::new()\n+ .morph_selector(&format!(\"[data-ingest-id=\\\"{}\\\"]\", ing_id), full_html)\n+ .into_response()\n+}\n+\n+pub(crate) async fn thread_ui_collapse_redacted_post(\n+ state: &AppState,\n+ room: &str,\n+ thread_tag: &str,\n+ post_index: usize,\n+ viewer: Option<&str>,\n+) -> axum::response::Response {\n+ let room = room.trim();\n+ let tag = canonicalize_tag(thread_tag);\n+ let scope = scope_from_room_wire(room);\n+ let nav = if room.is_empty() || room == \"public\" {\n+ ThreadNav::public()\n+ } else {\n+ let Some(n) = ThreadNav::from_room_id(room) else {\n+ return crate::html::ui_js_warn(\"bad room\");\n+ };\n+ n\n+ };\n+ if let ScopeId::Room(ref rid) = scope {\n+ let reduced = state.reduced.read().await;\n+ if !user_can_view_room(&reduced, rid, viewer) {\n+ return crate::html::ui_js_warn(\"forbidden\");\n+ }\n+ }\n+\n+ let now = now_ms();\n+ let reduced = state.reduced.read().await;\n+ let ing = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .and_then(|q| q.iter().rev().nth(post_index))\n+ .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n+ let Some(ing) = ing else {\n+ return crate::html::ui_js_warn(\"post not found\");\n+ };\n+ if !reduced.redacted_posts.contains(&ing.id) {\n+ return crate::html::ui_js_warn(\"post not found\");\n+ }\n+ let ing_id = ing.id.clone();\n+ let collapsed = ingest_entry_markup(&nav, &tag, post_index, &ing, viewer, now, &reduced);\n+ drop(reduced);\n+\n+ JsBuilder::new()\n+ .morph_selector(&format!(\"[data-ingest-id=\\\"{}\\\"]\", ing_id), collapsed)\n+ .into_response()\n+}\ndiff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..be08491b893f5960723e77c7f7fb9d416100f0fb\n--- /dev/null\n+++ b/server/src/html/forum/views.rs\n@@ -0,0 +1,290 @@\n+use axum::{\n+ extract::{Path, Query, State},\n+ http::{HeaderMap, StatusCode, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::{html, Markup};\n+use serde::Deserialize;\n+use serde_json::json;\n+\n+use crate::api::optional_principal;\n+use crate::canonical_path::canonicalize_tag;\n+use crate::form_template::template_json_compact;\n+use crate::reducer::ScopeId;\n+use crate::state::AppState;\n+\n+use super::access::user_can_post_room;\n+use super::access::user_can_view_room;\n+use super::feed::{collect_thread_rows_for_scope, render_thread_feed};\n+use super::ingest::ingest_entry_markup;\n+use super::nav::ThreadNav;\n+use super::new_thread::fragment_room_new_thread_form;\n+use super::page::{auth_strip, bc_room};\n+use super::paginator::{render_thread_paginator, PAGE_SIZE};\n+use super::room_members::room_members_section_markup;\n+use crate::html::ui_action::UI_RPC_FIELD;\n+use crate::html::{\n+ bc_threads, cli_panel, layout, now_ms, theme_from_jar, theme_next_from_uri,\n+};\n+\n+fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {\n+ if !show {\n+ return html! {};\n+ }\n+ html! {\n+ section class=\"compose\" id=\"thread-compose\" {\n+ form id=\"thread-compose-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ })).unwrap());\n+ textarea name=\"text\" rows=\"5\" cols=\"80\" placeholder=\"prose or ~/items and votes…\" {}\n+ p {\n+ button type=\"submit\" { \"post\" }\n+ }\n+ }\n+ div id=\"thread-compose-errors\" {}\n+ }\n+ }\n+}\n+\n+#[derive(Debug, Deserialize)]\n+pub struct ThreadViewQuery {\n+ pub offset: Option,\n+}\n+\n+async fn thread_view_inner(\n+ state: AppState,\n+ tag: String,\n+ q: ThreadViewQuery,\n+ nav: ThreadNav,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let tag = canonicalize_tag(&tag);\n+ let scope = nav.scope();\n+\n+ let all_ids: Vec = {\n+ let reduced = state.reduced.read().await;\n+ reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), tag.clone()))\n+ .map(|q| q.iter().rev().cloned().collect())\n+ .unwrap_or_default()\n+ };\n+\n+ let total = all_ids.len();\n+ let offset = q.offset.unwrap_or(0);\n+ let page_ids: Vec = all_ids.into_iter().skip(offset).take(PAGE_SIZE).collect();\n+\n+ let (display_ingests, _subtitle) = {\n+ let reduced = state.reduced.read().await;\n+ let ingests = page_ids\n+ .iter()\n+ .filter_map(|id| reduced.ingests_by_id.get(id).cloned())\n+ .collect::>();\n+ (ingests, None::)\n+ };\n+\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ let sc = nav.scope();\n+ let show_compose = match &sc {\n+ ScopeId::Public => user.is_some(),\n+ ScopeId::Room(rid) => user\n+ .as_ref()\n+ .map(|u| user_can_post_room(&reduced, rid, u))\n+ .unwrap_or(false),\n+ };\n+ let strip = auth_strip(&headers, &jar, &reduced);\n+ let now = now_ms();\n+ let entry_rows: Vec = display_ingests\n+ .iter()\n+ .enumerate()\n+ .map(|(i, ing)| {\n+ let post_idx = offset + i;\n+ ingest_entry_markup(&nav, &tag, post_idx, ing, user.as_deref(), now, &reduced)\n+ })\n+ .collect();\n+ drop(reduced);\n+\n+ let paginator_top = render_thread_paginator(&nav, &tag, offset, total, true);\n+ let paginator_bot = render_thread_paginator(&nav, &tag, offset, total, false);\n+\n+ let bc: Markup = match &sc {\n+ ScopeId::Public => bc_threads(Some(&tag)),\n+ ScopeId::Room(rid) => {\n+ let slug = if let Some((_, s)) = rid.split_once('/') {\n+ s\n+ } else {\n+ rid.as_str()\n+ };\n+ bc_room(&nav, slug, Some(&tag))\n+ }\n+ };\n+\n+ let cli = match &sc {\n+ ScopeId::Public => format!(\"npx slugsocial public forum show {tag}\"),\n+ ScopeId::Room(r) => format!(\"npx slugsocial private {r} forum show {tag}\"),\n+ };\n+\n+ let page = layout(\n+ &format!(\"#{tag}\"),\n+ \"view-thread\",\n+ html! {\n+ (strip)\n+ nav class=\"breadcrumb\" { (bc) }\n+ p class=\"muted\" { \"top=oldest · bottom=newest\" }\n+ div id=\"thread-feed-region\" {\n+ @if display_ingests.is_empty() {\n+ p class=\"muted\" { \"no activity yet\" }\n+ } @else {\n+ (paginator_top)\n+ @for row in &entry_rows {\n+ (row)\n+ }\n+ (paginator_bot)\n+ }\n+ }\n+ div id=\"thread-live-region\" {\n+ (compose_form(&nav, &tag, show_compose))\n+ }\n+ (cli_panel(std::slice::from_ref(&cli)))\n+ },\n+ None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string()).into_response()\n+}\n+\n+/// Thread view — `/t/:tag`\n+pub async fn thread_view(\n+ State(state): State,\n+ Path(tag): Path,\n+ Query(q): Query,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar, uri).await\n+}\n+\n+/// Room thread — `/r/:short/:slug/t/:tag`\n+pub async fn room_thread_view(\n+ State(state): State,\n+ Path((room_short, room_slug, tag)): Path<(String, String, String)>,\n+ Query(q): Query,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let room_id = format!(\"{room_short}/{room_slug}\");\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ drop(reduced);\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n+ thread_view_inner(state, tag, q, nav, headers, jar, uri)\n+ .await\n+ .into_response()\n+}\n+\n+pub(super) fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoResponse {\n+ let body = html! {\n+ nav class=\"breadcrumb\" { a href=\"/\" { \"slug.social\" } }\n+ h1 { \"not found\" }\n+ p { \"The requested page could not be found.\" }\n+ p { a href=\"/\" { \"home\" } }\n+ };\n+ let page = layout(\n+ \"not found — slug.social\",\n+ \"view-thread\",\n+ body,\n+ None,\n+ theme_from_jar(jar),\n+ &theme_next_from_uri(uri),\n+ );\n+ (StatusCode::NOT_FOUND, Html(page.into_string()))\n+}\n+\n+/// Private room index — `/r/:short/:slug`\n+pub async fn room_page(\n+ State(state): State,\n+ Path((room_short, room_slug)): Path<(String, String)>,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let room_id = format!(\"{room_short}/{room_slug}\");\n+ let now = now_ms();\n+ let reduced = state.reduced.read().await;\n+ if !reduced.rooms.contains(&room_id) {\n+ drop(reduced);\n+ return (StatusCode::NOT_FOUND, \"room not found\").into_response();\n+ }\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ let scope = ScopeId::Room(room_id.clone());\n+ let mut rows = collect_thread_rows_for_scope(&reduced, &scope, now);\n+ let strip = auth_strip(&headers, &jar, &reduced);\n+ let show_new = user\n+ .as_ref()\n+ .map(|u| user_can_post_room(&reduced, &room_id, u))\n+ .unwrap_or(false);\n+ rows.sort_by(|a, b| b.last_ts.cmp(&a.last_ts));\n+\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ drop(reduced);\n+ return (StatusCode::NOT_FOUND, \"room not found\").into_response();\n+ };\n+ let members_markup = room_members_section_markup(&reduced, &room_id, false);\n+ let forum_cli = format!(\"npx slugsocial private {room_id} forum list\");\n+ let garden_cli = format!(\"npx slugsocial private {room_id} garden tree\");\n+ let audit_cli = format!(\"npx slugsocial private {room_id} audit\");\n+ drop(reduced);\n+\n+ let slug_display = room_slug.as_str();\n+ let page = layout(\n+ &format!(\"room {slug_display} — slug.social\"),\n+ \"view-thread\",\n+ html! {\n+ (strip)\n+ nav class=\"breadcrumb\" { (bc_room(&nav, slug_display, None)) }\n+ (members_markup)\n+ h3 { \"threads\" }\n+ (render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now))\n+ @if show_new {\n+ div id=\"room-new-thread-ui-slot\" {\n+ (fragment_room_new_thread_form(&nav, true, false))\n+ }\n+ }\n+ (cli_panel(&[forum_cli, garden_cli, audit_cli]))\n+ },\n+ None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string()).into_response()\n+}\n","role":"user"}],"model":"~x-ai/grok-latest"}