From 8d7c48d8c128a37aeb9d145a10405f1ea248a79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 11:43:24 +0200 Subject: [PATCH] Include the demo persona on /explore so users can follow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory was filtering out the demo persona on the rationale that "the demo bot is not a real user and should not appear in real discovery." That's exactly backwards โ€” the whole point of having a demo persona is to give new users a follow target so the platform doesn't feel empty when they arrive. Hiding the bot from the discovery surface defeats its purpose. Concretely on flagship: only one local user (ullrich) was visible on /explore today, even though Bruno (the demo persona) is public-by-default and posting public activities. After this change both appear; Bruno carries a small "๐Ÿ• Demo account" badge next to his display name so viewers know what they're following. - apps/journal/app/lib/explore.server.ts โ€” drop the ne(users.username, persona.username) clause from exclusionFilters. The demo persona is now treated like any other public user. Banned/ suspended scaffolding stays for forward-compat. - apps/journal/app/routes/explore.tsx โ€” loader computes isDemoUser per row (cheap, just username comparison against loadPersona().username). DirectoryRow renders the demo badge inline with the display name, matching the existing pattern on /users/:username. - openspec/specs/explore/spec.md โ€” updated the "Excluded users" requirement to remove the demo persona, replaced the "demo excluded" scenario with "demo appears with badge", and updated the "Active recently" requirement + scenarios accordingly. - apps/journal/app/lib/explore.integration.test.ts โ€” flipped the demo-persona test from "is excluded" to "is included". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/explore.integration.test.ts | 8 +++--- apps/journal/app/lib/explore.server.ts | 13 ++++------ apps/journal/app/routes/explore.tsx | 26 ++++++++++++++----- openspec/specs/explore/spec.md | 23 +++++++++------- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/journal/app/lib/explore.integration.test.ts b/apps/journal/app/lib/explore.integration.test.ts index f8ad466..93f916f 100644 --- a/apps/journal/app/lib/explore.integration.test.ts +++ b/apps/journal/app/lib/explore.integration.test.ts @@ -80,12 +80,14 @@ describe.skipIf(!runIntegration)("explore.server integration", () => { expect(rows.find((r) => r.id === id)).toBeUndefined(); }); - it("demo persona is excluded from the directory", async () => { + it("demo persona is INCLUDED in the directory", async () => { const persona = loadPersona(); - // Insert a user with the persona's username โ€” should still be filtered out. + // Insert a user with the persona's username โ€” should appear like any + // other public user. The /explore loader is responsible for the + // demo-badge tagging at render time, not the directory query. const id = await makeUser({ username: persona.username }); const { rows } = await listDirectory({ page: 1, perPage: 50 }); - expect(rows.find((r) => r.id === id)).toBeUndefined(); + expect(rows.find((r) => r.id === id)).toBeDefined(); }); it("orders by most-recent public activity, NULLS LAST", async () => { diff --git a/apps/journal/app/lib/explore.server.ts b/apps/journal/app/lib/explore.server.ts index 69fb1a2..a604075 100644 --- a/apps/journal/app/lib/explore.server.ts +++ b/apps/journal/app/lib/explore.server.ts @@ -1,7 +1,6 @@ -import { and, count, desc, eq, gte, inArray, isNotNull, ne, sql } from "drizzle-orm"; +import { and, count, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, follows, users } from "@trails-cool/db/schema/journal"; -import { loadPersona } from "./demo-bot.server.ts"; import { localActorIri } from "./actor-iri.ts"; const DEFAULT_PAGE_SIZE = 20; @@ -42,13 +41,11 @@ function clampPage(raw: number | undefined): number { } function exclusionFilters() { - // Public-only and not the demo persona. Banned/suspended users would + // Public-only. The demo persona IS included on /explore โ€” its whole + // purpose is to give new users a follow target, and the per-row demo + // badge in the UI signals what it is. Banned/suspended users would // be filtered here too once such a status column exists โ€” see design.md. - const persona = loadPersona(); - return and( - eq(users.profileVisibility, "public"), - ne(users.username, persona.username), - ); + return eq(users.profileVisibility, "public"); } /** diff --git a/apps/journal/app/routes/explore.tsx b/apps/journal/app/routes/explore.tsx index a773005..1479acb 100644 --- a/apps/journal/app/routes/explore.tsx +++ b/apps/journal/app/routes/explore.tsx @@ -10,6 +10,7 @@ import { listActiveRecently, listDirectory, } from "~/lib/explore.server"; +import { loadPersona } from "~/lib/demo-bot.server"; import { FollowButton } from "~/components/FollowButton"; const BIO_TRUNCATE = 120; @@ -44,6 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) { : new Map(); const isSelf = (rowId: string) => viewer?.id === rowId; + const personaUsername = loadPersona().username; const decorate = (row: typeof allRows[number]) => ({ id: row.id, @@ -53,6 +55,7 @@ export async function loader({ request }: Route.LoaderArgs) { followerCount: followerCounts.get(row.id) ?? 0, followState: followStates.get(row.id) ?? null, isSelf: isSelf(row.id), + isDemoUser: row.username === personaUsername, }); // Resolved page size (after loader-side clamping inside listDirectory) @@ -85,6 +88,7 @@ interface DecoratedRow { followerCount: number; followState: { following: boolean; pending: boolean } | null; isSelf: boolean; + isDemoUser: boolean; } function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) { @@ -92,12 +96,22 @@ function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: bool return (
  • - - {row.displayName ?? row.username} - +
    + + {row.displayName ?? row.username} + + {row.isDemoUser && ( + + {t("demo.badge")} + + )} +

    @{row.username} ยท {t("social.followers.count", { count: row.followerCount })}

    diff --git a/openspec/specs/explore/spec.md b/openspec/specs/explore/spec.md index 5c61c68..635f704 100644 --- a/openspec/specs/explore/spec.md +++ b/openspec/specs/explore/spec.md @@ -43,25 +43,26 @@ The directory SHALL be ordered by `MAX(activities.created_at) DESC` per user, co The directory SHALL exclude: 1. Users with `profile_visibility = 'private'` โ€” they have explicitly opted out of public discovery (Mastodon-style locked accounts). -2. The instance's demo persona, identified by the username returned by `loadPersona()` โ€” the demo bot is not a real user and should not appear in real discovery. -3. (Forward-compat) Users in any future banned/suspended state โ€” when such a status column exists, it SHALL be added to the exclusion filter. +2. (Forward-compat) Users in any future banned/suspended state โ€” when such a status column exists, it SHALL be added to the exclusion filter. Excluded users SHALL NOT appear on `/explore` even if they have public activities and would otherwise sort to the top of the directory. +The demo persona (identified by `loadPersona().username`) is **not** excluded โ€” its purpose is to give new users a follow target, so it appears in the directory like any other public user. The directory row SHALL render a "demo account" badge next to the display name so viewers know what they're following. + #### Scenario: Private profile is excluded from the directory - **WHEN** user A has `profile_visibility = 'private'` and any activity history - **THEN** A does not appear in the `/explore` directory regardless of which page is requested -#### Scenario: Demo persona is excluded -- **WHEN** the demo persona username (per `loadPersona()`) matches a row that would otherwise appear -- **THEN** that row is filtered out of the directory +#### Scenario: Demo persona appears with a demo badge +- **WHEN** the demo persona username (per `loadPersona()`) matches a row in the directory +- **THEN** the row is rendered like any other public user, with an additional small "demo account" badge next to the display name #### Scenario: Public user with no activities is included - **WHEN** user A has `profile_visibility = 'public'` but has never created an activity - **THEN** A still appears in the directory (sorted toward the end by the recency rule) ### Requirement: "Active recently" sub-section -The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, demo persona, future banned/suspended users). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). +The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, future banned/suspended users โ€” the demo persona is included like any other public user, with the same demo-badge treatment). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). #### Scenario: Active-recently strip rendered with qualifying users - **WHEN** at least one public local user (excluding private/demo) has a public activity within the last 30 days @@ -71,9 +72,13 @@ The `/explore` page SHALL render an "Active recently" sub-section at the top of - **WHEN** no public local user (excluding private/demo) has any public activity within the last 30 days - **THEN** the "Active recently" sub-section is not rendered at all; the main directory is the only listing on the page -#### Scenario: Strip respects exclusion rules -- **WHEN** a private profile or the demo persona has a recent public activity -- **THEN** they are not included in the "Active recently" strip โ€” the same exclusion rules apply as for the main directory +#### Scenario: Strip excludes private profiles +- **WHEN** a private profile has a recent public activity +- **THEN** they are not included in the "Active recently" strip โ€” the same private-profile exclusion as the main directory + +#### Scenario: Strip includes the demo persona +- **WHEN** the demo persona has a recent public activity +- **THEN** it appears in the "Active recently" strip like any other public user, carrying the same demo-account badge as in the main directory ### Requirement: Pagination The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query parameters. Page size SHALL default to 20 per page and SHALL be capped at 100; `perPage` values outside `[1, 100]` SHALL be clamped to that range without raising an error. The response SHALL surface a "Next page" link when more rows exist past the current page, and a "Previous page" link when `page > 1`.