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>
212 lines
6.2 KiB
TypeScript
212 lines
6.2 KiB
TypeScript
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 { localActorIri } from "./actor-iri.ts";
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
const MAX_PAGE_SIZE = 100;
|
|
const ACTIVE_RECENTLY_DAYS = 30;
|
|
const ACTIVE_RECENTLY_DEFAULT_LIMIT = 5;
|
|
|
|
export interface DirectoryRow {
|
|
id: string;
|
|
username: string;
|
|
displayName: string | null;
|
|
bio: string | null;
|
|
latestActivityAt: Date | null;
|
|
}
|
|
|
|
export interface DirectoryListing {
|
|
rows: DirectoryRow[];
|
|
totalCount: number;
|
|
}
|
|
|
|
export interface ListDirectoryOptions {
|
|
page?: number;
|
|
perPage?: number;
|
|
}
|
|
|
|
/**
|
|
* Clamps `?perPage=` into [1, MAX_PAGE_SIZE]. Out-of-range values
|
|
* (NaN, negative, > MAX) snap to bounds rather than 400.
|
|
*/
|
|
function clampPerPage(raw: number | undefined): number {
|
|
if (!raw || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
|
|
return Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(raw)));
|
|
}
|
|
|
|
function clampPage(raw: number | undefined): number {
|
|
if (!raw || !Number.isFinite(raw)) return 1;
|
|
return Math.max(1, Math.floor(raw));
|
|
}
|
|
|
|
function exclusionFilters() {
|
|
// 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.
|
|
return eq(users.profileVisibility, "public");
|
|
}
|
|
|
|
/**
|
|
* Paginated directory of public local users. Order: most-recent public
|
|
* activity DESC NULLS LAST, tiebreaker `users.id DESC` for stable
|
|
* pagination.
|
|
*/
|
|
export async function listDirectory(opts: ListDirectoryOptions = {}): Promise<DirectoryListing> {
|
|
const db = getDb();
|
|
const perPage = clampPerPage(opts.perPage);
|
|
const page = clampPage(opts.page);
|
|
const offset = (page - 1) * perPage;
|
|
|
|
const latestActivity = sql<Date | null>`MAX(CASE WHEN ${activities.visibility} = 'public' THEN ${activities.createdAt} ELSE NULL END)`;
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: users.id,
|
|
username: users.username,
|
|
displayName: users.displayName,
|
|
bio: users.bio,
|
|
latestActivityAt: latestActivity,
|
|
})
|
|
.from(users)
|
|
.leftJoin(activities, eq(activities.ownerId, users.id))
|
|
.where(exclusionFilters())
|
|
.groupBy(users.id)
|
|
.orderBy(sql`${latestActivity} DESC NULLS LAST`, desc(users.id))
|
|
.limit(perPage)
|
|
.offset(offset);
|
|
|
|
const [countRow] = await db
|
|
.select({ n: count() })
|
|
.from(users)
|
|
.where(exclusionFilters());
|
|
|
|
return { rows, totalCount: countRow?.n ?? 0 };
|
|
}
|
|
|
|
/**
|
|
* Cheap count helper if a caller only wants the size — used by the
|
|
* pagination math path that doesn't need the page rows.
|
|
*/
|
|
export async function countDirectory(): Promise<number> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select({ n: count() })
|
|
.from(users)
|
|
.where(exclusionFilters());
|
|
return row?.n ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Top-N public users with at least one public activity in the last
|
|
* 30 days. Same exclusion rules as `listDirectory`. Returns at most
|
|
* `limit` rows; an empty array signals "no qualifying users — hide
|
|
* the strip."
|
|
*/
|
|
export async function listActiveRecently(limit: number = ACTIVE_RECENTLY_DEFAULT_LIMIT): Promise<DirectoryRow[]> {
|
|
const db = getDb();
|
|
const cutoff = new Date(Date.now() - ACTIVE_RECENTLY_DAYS * 24 * 60 * 60 * 1000);
|
|
|
|
const latestActivity = sql<Date | null>`MAX(${activities.createdAt})`;
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: users.id,
|
|
username: users.username,
|
|
displayName: users.displayName,
|
|
bio: users.bio,
|
|
latestActivityAt: latestActivity,
|
|
})
|
|
.from(users)
|
|
.innerJoin(activities, eq(activities.ownerId, users.id))
|
|
.where(
|
|
and(
|
|
exclusionFilters(),
|
|
eq(activities.visibility, "public"),
|
|
gte(activities.createdAt, cutoff),
|
|
),
|
|
)
|
|
.groupBy(users.id)
|
|
.orderBy(sql`${latestActivity} DESC`, desc(users.id))
|
|
.limit(Math.max(1, Math.min(50, Math.floor(limit))));
|
|
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Batched accepted-follower count for a set of users. One query
|
|
* regardless of how many users are on the page — avoids N+1 against
|
|
* `countFollowers` per row.
|
|
*/
|
|
export async function countFollowersBatch(userIds: string[]): Promise<Map<string, number>> {
|
|
const result = new Map<string, number>();
|
|
if (userIds.length === 0) return result;
|
|
const db = getDb();
|
|
const rows = await db
|
|
.select({
|
|
userId: follows.followedUserId,
|
|
n: count(),
|
|
})
|
|
.from(follows)
|
|
.where(
|
|
and(
|
|
inArray(follows.followedUserId, userIds),
|
|
isNotNull(follows.acceptedAt),
|
|
),
|
|
)
|
|
.groupBy(follows.followedUserId);
|
|
for (const r of rows) {
|
|
if (r.userId) result.set(r.userId, r.n);
|
|
}
|
|
// Fill missing user ids with 0 so callers don't need to coalesce.
|
|
for (const id of userIds) {
|
|
if (!result.has(id)) result.set(id, 0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Batched follow-state lookup for a viewer against a set of target
|
|
* users. Returns Map<targetUserId, FollowState>. Used by /explore so
|
|
* each row's FollowButton has its `initialState` without N round-trips.
|
|
*/
|
|
export interface FollowStateRow {
|
|
following: boolean;
|
|
pending: boolean;
|
|
}
|
|
|
|
export async function getFollowStateBatch(
|
|
followerId: string,
|
|
targets: { id: string; username: string }[],
|
|
): Promise<Map<string, FollowStateRow>> {
|
|
const result = new Map<string, FollowStateRow>();
|
|
if (targets.length === 0) return result;
|
|
const db = getDb();
|
|
const iris = targets.map((t) => localActorIri(t.username));
|
|
const irisToId = new Map(targets.map((t) => [localActorIri(t.username), t.id]));
|
|
const rows = await db
|
|
.select({
|
|
iri: follows.followedActorIri,
|
|
acceptedAt: follows.acceptedAt,
|
|
})
|
|
.from(follows)
|
|
.where(
|
|
and(
|
|
eq(follows.followerId, followerId),
|
|
inArray(follows.followedActorIri, iris),
|
|
),
|
|
);
|
|
for (const r of rows) {
|
|
const id = irisToId.get(r.iri);
|
|
if (!id) continue;
|
|
result.set(id, { following: r.acceptedAt !== null, pending: r.acceptedAt === null });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Page-size constants exposed for callers (route loader, tests).
|
|
*/
|
|
export const EXPLORE_DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
export const EXPLORE_MAX_PAGE_SIZE = MAX_PAGE_SIZE;
|