Include the demo persona on /explore so users can follow it
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) <noreply@anthropic.com>
This commit is contained in:
parent
b647c493d7
commit
8d7c48d8c1
4 changed files with 44 additions and 26 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<li className="flex items-start justify-between gap-4 border-b border-gray-100 px-4 py-4 last:border-b-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/users/${row.username}`}
|
||||
className="text-sm font-medium text-gray-900 hover:underline"
|
||||
>
|
||||
{row.displayName ?? row.username}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/users/${row.username}`}
|
||||
className="text-sm font-medium text-gray-900 hover:underline"
|
||||
>
|
||||
{row.displayName ?? row.username}
|
||||
</Link>
|
||||
{row.isDemoUser && (
|
||||
<span
|
||||
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||
title={t("demo.badge")}
|
||||
>
|
||||
{t("demo.badge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
@{row.username} · {t("social.followers.count", { count: row.followerCount })}
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue