Implement /explore: local user discovery directory
Stream F implementation. Closes the gap between "I want to follow
someone on this instance" and "I have a username from outside the
app." Anonymous visitors and signed-in users both reach a paginated
directory of local public users; signed-in viewers also see Follow
buttons inline.
- apps/journal/app/lib/explore.server.ts — listDirectory (paginated,
ordered by MAX(public-activity created_at) DESC NULLS LAST,
tiebreaker users.id DESC), listActiveRecently (top 5 in last 30
days), countDirectory, batched countFollowersBatch and
getFollowStateBatch helpers so the page issues two extra queries
regardless of page size. Excludes private profiles and the demo
persona; banned/suspended scaffolding noted for forward-compat.
- apps/journal/app/routes/explore.tsx — loader fans out the four
queries in parallel; component renders an "Active recently" strip
(hidden when empty) above the main directory; FollowButton inlines
per row for signed-in viewers. Bio truncated to 120 chars.
- apps/journal/app/routes.ts — register /explore.
- apps/journal/app/root.tsx — add Explore navbar entry for signed-in
users.
- apps/journal/app/routes/home.tsx — visitor home gains a secondary
"Browse who's here →" link to /explore alongside the Planner
escape hatch.
- packages/i18n/src/locales/{en,de}.ts — explore.*, nav.explore,
home.exploreLink keys.
- apps/journal/app/lib/explore.integration.test.ts — opt-in
(EXPLORE_INTEGRATION=1) integration tests covering inclusion,
exclusion, ordering, NULLS LAST behavior, "Active recently"
30-day filter, count helpers.
- e2e/explore.test.ts — anonymous loads /explore (no auth needed);
private profile is excluded from the directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65f8313f2b
commit
37f7a349b9
9 changed files with 684 additions and 1 deletions
180
apps/journal/app/lib/explore.integration.test.ts
Normal file
180
apps/journal/app/lib/explore.integration.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { getDb } from "./db.ts";
|
||||||
|
import { activities, users } from "@trails-cool/db/schema/journal";
|
||||||
|
import {
|
||||||
|
listDirectory,
|
||||||
|
countDirectory,
|
||||||
|
listActiveRecently,
|
||||||
|
countFollowersBatch,
|
||||||
|
} from "./explore.server.ts";
|
||||||
|
import { loadPersona } from "./demo-bot.server.ts";
|
||||||
|
|
||||||
|
// Opt-in: these talk to real Postgres. Gated by EXPLORE_INTEGRATION=1.
|
||||||
|
const runIntegration = process.env.EXPLORE_INTEGRATION === "1";
|
||||||
|
|
||||||
|
async function makeUser(opts: {
|
||||||
|
username: string;
|
||||||
|
profileVisibility?: "public" | "private";
|
||||||
|
bio?: string | null;
|
||||||
|
}) {
|
||||||
|
const db = getDb();
|
||||||
|
const id = randomUUID();
|
||||||
|
await db.insert(users).values({
|
||||||
|
id,
|
||||||
|
email: `${opts.username}@example.test`,
|
||||||
|
username: opts.username,
|
||||||
|
domain: "test.local",
|
||||||
|
bio: opts.bio ?? null,
|
||||||
|
profileVisibility: opts.profileVisibility ?? "public",
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeActivity(opts: {
|
||||||
|
ownerId: string;
|
||||||
|
visibility?: "public" | "unlisted" | "private";
|
||||||
|
createdAt?: Date;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
const db = getDb();
|
||||||
|
const id = randomUUID();
|
||||||
|
await db.insert(activities).values({
|
||||||
|
id,
|
||||||
|
ownerId: opts.ownerId,
|
||||||
|
name: opts.name ?? `act-${id.slice(0, 8)}`,
|
||||||
|
visibility: opts.visibility ?? "public",
|
||||||
|
createdAt: opts.createdAt ?? new Date(),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipe() {
|
||||||
|
const db = getDb();
|
||||||
|
await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
|
||||||
|
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
|
||||||
|
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!runIntegration)("explore.server integration", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
});
|
||||||
|
afterEach(wipe);
|
||||||
|
|
||||||
|
it("public user appears in the directory", async () => {
|
||||||
|
const id = await makeUser({ username: `ex_pub_${Date.now()}` });
|
||||||
|
const { rows, totalCount } = await listDirectory({ page: 1, perPage: 50 });
|
||||||
|
expect(rows.find((r) => r.id === id)).toBeDefined();
|
||||||
|
expect(totalCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("private user is excluded from the directory", async () => {
|
||||||
|
const id = await makeUser({
|
||||||
|
username: `ex_priv_${Date.now()}`,
|
||||||
|
profileVisibility: "private",
|
||||||
|
});
|
||||||
|
const { rows } = await listDirectory({ page: 1, perPage: 50 });
|
||||||
|
expect(rows.find((r) => r.id === id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demo persona is excluded from the directory", async () => {
|
||||||
|
const persona = loadPersona();
|
||||||
|
// Insert a user with the persona's username — should still be filtered out.
|
||||||
|
const id = await makeUser({ username: persona.username });
|
||||||
|
const { rows } = await listDirectory({ page: 1, perPage: 50 });
|
||||||
|
expect(rows.find((r) => r.id === id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders by most-recent public activity, NULLS LAST", async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const aId = await makeUser({ username: `ex_a_${stamp}` });
|
||||||
|
const bId = await makeUser({ username: `ex_b_${stamp}` });
|
||||||
|
// A has yesterday, B has today.
|
||||||
|
await makeActivity({ ownerId: aId, createdAt: new Date(Date.now() - 86_400_000) });
|
||||||
|
await makeActivity({ ownerId: bId, createdAt: new Date() });
|
||||||
|
const cId = await makeUser({ username: `ex_c_${stamp}` }); // no activities
|
||||||
|
|
||||||
|
const { rows } = await listDirectory({ page: 1, perPage: 50 });
|
||||||
|
const indexOf = (id: string) => rows.findIndex((r) => r.id === id);
|
||||||
|
expect(indexOf(bId)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(indexOf(aId)).toBeGreaterThan(indexOf(bId)); // B before A
|
||||||
|
expect(indexOf(cId)).toBeGreaterThan(indexOf(aId)); // A before C (C is NULL)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("private activities don't count for ordering", async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const aId = await makeUser({ username: `ex_priv_act_a_${stamp}` });
|
||||||
|
const bId = await makeUser({ username: `ex_priv_act_b_${stamp}` });
|
||||||
|
// A has a private activity from today; B has a public activity from yesterday.
|
||||||
|
await makeActivity({ ownerId: aId, visibility: "private", createdAt: new Date() });
|
||||||
|
await makeActivity({
|
||||||
|
ownerId: bId,
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: new Date(Date.now() - 86_400_000),
|
||||||
|
});
|
||||||
|
const { rows } = await listDirectory({ page: 1, perPage: 50 });
|
||||||
|
const indexOf = (id: string) => rows.findIndex((r) => r.id === id);
|
||||||
|
// B (public yesterday) should come before A (private today is invisible).
|
||||||
|
expect(indexOf(bId)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(indexOf(aId)).toBeGreaterThan(indexOf(bId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Active recently' includes public users with public activity in last 30 days", async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const aId = await makeUser({ username: `ex_ar_a_${stamp}` });
|
||||||
|
const bId = await makeUser({ username: `ex_ar_b_${stamp}` });
|
||||||
|
// A has a public activity from 5 days ago.
|
||||||
|
await makeActivity({
|
||||||
|
ownerId: aId,
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: new Date(Date.now() - 5 * 86_400_000),
|
||||||
|
});
|
||||||
|
// B has a public activity from 60 days ago.
|
||||||
|
await makeActivity({
|
||||||
|
ownerId: bId,
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: new Date(Date.now() - 60 * 86_400_000),
|
||||||
|
});
|
||||||
|
const rows = await listActiveRecently(10);
|
||||||
|
expect(rows.find((r) => r.id === aId)).toBeDefined();
|
||||||
|
expect(rows.find((r) => r.id === bId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Active recently' caps at the requested limit", async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const id = await makeUser({ username: `ex_cap_${i}_${stamp}` });
|
||||||
|
await makeActivity({
|
||||||
|
ownerId: id,
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: new Date(Date.now() - i * 60_000),
|
||||||
|
});
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
const rows = await listActiveRecently(3);
|
||||||
|
expect(rows.length).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("countDirectory matches listDirectory's totalCount", async () => {
|
||||||
|
await makeUser({ username: `ex_count_a_${Date.now()}` });
|
||||||
|
await makeUser({ username: `ex_count_b_${Date.now()}` });
|
||||||
|
const total = await countDirectory();
|
||||||
|
const { totalCount } = await listDirectory({ page: 1, perPage: 1 });
|
||||||
|
expect(total).toBe(totalCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("countFollowersBatch handles empty input", async () => {
|
||||||
|
const result = await countFollowersBatch([]);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("countFollowersBatch fills zeros for users with no followers", async () => {
|
||||||
|
const id = await makeUser({ username: `ex_fb_${Date.now()}` });
|
||||||
|
const result = await countFollowersBatch([id]);
|
||||||
|
expect(result.get(id)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
215
apps/journal/app/lib/explore.server.ts
Normal file
215
apps/journal/app/lib/explore.server.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { and, count, desc, eq, gte, inArray, isNotNull, ne, 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;
|
||||||
|
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 and not the demo persona. 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
@ -117,6 +117,9 @@ function NavBar({
|
||||||
<Link to="/feed" className={linkClass("/feed")}>
|
<Link to="/feed" className={linkClass("/feed")}>
|
||||||
{t("social.feed.title")}
|
{t("social.feed.title")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/explore" className={linkClass("/explore")}>
|
||||||
|
{t("nav.explore")}
|
||||||
|
</Link>
|
||||||
<Link to="/routes" className={linkClass("/routes")}>
|
<Link to="/routes" className={linkClass("/routes")}>
|
||||||
{t("nav.routes")}
|
{t("nav.routes")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export default [
|
||||||
route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"),
|
route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"),
|
||||||
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
|
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
|
||||||
route("feed", "routes/feed.tsx"),
|
route("feed", "routes/feed.tsx"),
|
||||||
|
route("explore", "routes/explore.tsx"),
|
||||||
route("api/events", "routes/api.events.ts"),
|
route("api/events", "routes/api.events.ts"),
|
||||||
route("notifications", "routes/notifications.tsx"),
|
route("notifications", "routes/notifications.tsx"),
|
||||||
route("api/notifications/:id/read", "routes/api.notifications.$id.read.ts"),
|
route("api/notifications/:id/read", "routes/api.notifications.$id.read.ts"),
|
||||||
|
|
|
||||||
183
apps/journal/app/routes/explore.tsx
Normal file
183
apps/journal/app/routes/explore.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { data } from "react-router";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Route } from "./+types/explore";
|
||||||
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
|
import {
|
||||||
|
EXPLORE_DEFAULT_PAGE_SIZE,
|
||||||
|
countFollowersBatch,
|
||||||
|
getFollowStateBatch,
|
||||||
|
listActiveRecently,
|
||||||
|
listDirectory,
|
||||||
|
} from "~/lib/explore.server";
|
||||||
|
import { FollowButton } from "~/components/FollowButton";
|
||||||
|
|
||||||
|
const BIO_TRUNCATE = 120;
|
||||||
|
|
||||||
|
function truncateBio(bio: string | null): string | null {
|
||||||
|
if (!bio) return null;
|
||||||
|
const trimmed = bio.trim();
|
||||||
|
if (trimmed.length === 0) return null;
|
||||||
|
if (trimmed.length <= BIO_TRUNCATE) return trimmed;
|
||||||
|
return trimmed.slice(0, BIO_TRUNCATE).trimEnd() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const viewer = await getSessionUser(request);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const page = Number(url.searchParams.get("page") ?? "1");
|
||||||
|
const perPage = Number(url.searchParams.get("perPage") ?? String(EXPLORE_DEFAULT_PAGE_SIZE));
|
||||||
|
|
||||||
|
const [activeRecently, directory] = await Promise.all([
|
||||||
|
listActiveRecently(),
|
||||||
|
listDirectory({ page, perPage }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Per-row data: follower count (for everyone) + follow state (for
|
||||||
|
// signed-in viewers only). Both are batched so the page issues at
|
||||||
|
// most two extra queries regardless of page size.
|
||||||
|
const allRows = [...activeRecently, ...directory.rows];
|
||||||
|
const allIds = allRows.map((r) => r.id);
|
||||||
|
const followerCounts = await countFollowersBatch(allIds);
|
||||||
|
const followStates = viewer
|
||||||
|
? await getFollowStateBatch(viewer.id, allRows.map((r) => ({ id: r.id, username: r.username })))
|
||||||
|
: new Map();
|
||||||
|
|
||||||
|
const isSelf = (rowId: string) => viewer?.id === rowId;
|
||||||
|
|
||||||
|
const decorate = (row: typeof allRows[number]) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.displayName,
|
||||||
|
bio: truncateBio(row.bio),
|
||||||
|
followerCount: followerCounts.get(row.id) ?? 0,
|
||||||
|
followState: followStates.get(row.id) ?? null,
|
||||||
|
isSelf: isSelf(row.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolved page size (after loader-side clamping inside listDirectory)
|
||||||
|
// for the pagination math here. We can compute totalPages without
|
||||||
|
// re-querying since `directory.totalCount` is authoritative.
|
||||||
|
const resolvedPerPage = Math.max(1, Math.min(100, Math.floor(Number.isFinite(perPage) ? perPage : EXPLORE_DEFAULT_PAGE_SIZE)));
|
||||||
|
const resolvedPage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1));
|
||||||
|
const totalPages = Math.max(1, Math.ceil(directory.totalCount / resolvedPerPage));
|
||||||
|
|
||||||
|
return data({
|
||||||
|
isSignedIn: !!viewer,
|
||||||
|
activeRecently: activeRecently.map(decorate),
|
||||||
|
directory: directory.rows.map(decorate),
|
||||||
|
page: resolvedPage,
|
||||||
|
perPage: resolvedPerPage,
|
||||||
|
totalPages,
|
||||||
|
totalCount: directory.totalCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meta(_args: Route.MetaArgs) {
|
||||||
|
return [{ title: "Explore — trails.cool" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoratedRow {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
followerCount: number;
|
||||||
|
followState: { following: boolean; pending: boolean } | null;
|
||||||
|
isSelf: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) {
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
|
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>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
@{row.username} · {t("social.followers.count", { count: row.followerCount })}
|
||||||
|
</p>
|
||||||
|
{row.bio && <p className="mt-1 text-sm text-gray-600">{row.bio}</p>}
|
||||||
|
</div>
|
||||||
|
{isSignedIn && !row.isSelf && (
|
||||||
|
<FollowButton
|
||||||
|
username={row.username}
|
||||||
|
isPrivateTarget={false /* directory only contains public users */}
|
||||||
|
initialState={row.followState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Explore({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { isSignedIn, activeRecently, directory, page, totalPages, totalCount } = loaderData;
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{t("explore.heading")}</h1>
|
||||||
|
|
||||||
|
{activeRecently.length > 0 && (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
{t("explore.activeRecently.heading")}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-3 rounded-lg border border-gray-200 bg-white">
|
||||||
|
{activeRecently.map((row) => (
|
||||||
|
<DirectoryRow key={`ar-${row.id}`} row={row} isSignedIn={isSignedIn} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
{t("explore.directory.heading")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{totalCount === 0 ? (
|
||||||
|
<p className="mt-6 text-center text-gray-500">{t("explore.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="mt-3 rounded-lg border border-gray-200 bg-white">
|
||||||
|
{directory.map((row) => (
|
||||||
|
<DirectoryRow key={row.id} row={row} isSignedIn={isSignedIn} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<nav className="mt-4 flex items-center justify-between text-sm">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link
|
||||||
|
to={`/explore?page=${page - 1}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
← {t("social.prevPage")}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{t("social.pageOfTotal", { page, totalPages })}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<Link
|
||||||
|
to={`/explore?page=${page + 1}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t("social.nextPage")} →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -277,13 +277,19 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/* Demoted escape hatch: the Planner is anonymous and useful
|
{/* Demoted escape hatch: the Planner is anonymous and useful
|
||||||
on its own, but shouldn't compete visually with sign-up. */}
|
on its own, but shouldn't compete visually with sign-up.
|
||||||
|
Same line surfaces /explore so first-time visitors have an
|
||||||
|
in-app path to the local user directory before signing up. */}
|
||||||
<p className="mt-3 text-sm text-gray-500">
|
<p className="mt-3 text-sm text-gray-500">
|
||||||
{t("home.tryPlannerPrefix")}
|
{t("home.tryPlannerPrefix")}
|
||||||
<a href={plannerUrl} className="text-blue-600 hover:underline">
|
<a href={plannerUrl} className="text-blue-600 hover:underline">
|
||||||
{t("home.tryPlannerLink")}
|
{t("home.tryPlannerLink")}
|
||||||
</a>
|
</a>
|
||||||
{t("home.tryPlannerSuffix")}
|
{t("home.tryPlannerSuffix")}
|
||||||
|
{" · "}
|
||||||
|
<a href="/explore" className="text-blue-600 hover:underline">
|
||||||
|
{t("home.exploreLink")}
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
71
e2e/explore.test.ts
Normal file
71
e2e/explore.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
|
||||||
|
|
||||||
|
async function setupVirtualAuthenticator(cdp: CDPSession) {
|
||||||
|
await cdp.send("WebAuthn.enable");
|
||||||
|
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||||
|
options: {
|
||||||
|
protocol: "ctap2",
|
||||||
|
transport: "internal",
|
||||||
|
hasResidentKey: true,
|
||||||
|
hasUserVerification: true,
|
||||||
|
isUserVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return authenticatorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUser(page: Page, email: string, username: string) {
|
||||||
|
await page.goto("/auth/register");
|
||||||
|
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
|
||||||
|
await page.getByLabel("Email").fill(email);
|
||||||
|
await page.getByLabel("Username").fill(username);
|
||||||
|
await page.getByRole("checkbox").check();
|
||||||
|
await page.getByRole("button", { name: /Register with Passkey/ }).click();
|
||||||
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setProfileVisibility(page: Page, value: "public" | "private") {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
|
||||||
|
await page.getByRole("button", { name: /^Save$/ }).first().click();
|
||||||
|
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("/explore", () => {
|
||||||
|
test("anonymous visitor can load /explore", async ({ page }) => {
|
||||||
|
const resp = await page.goto("/explore");
|
||||||
|
expect(resp?.status()).toBe(200);
|
||||||
|
await expect(page.getByRole("heading", { name: "Explore" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("private profile is excluded from the directory", async ({ page, browser }) => {
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await setupVirtualAuthenticator(cdp);
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
// A: signed-in viewer (public, default in test below)
|
||||||
|
const aEmail = `ex-a-${stamp}@example.com`;
|
||||||
|
const aUsername = `exa${stamp}`;
|
||||||
|
await registerUser(page, aEmail, aUsername);
|
||||||
|
await setProfileVisibility(page, "public");
|
||||||
|
|
||||||
|
// B: a separate user who stays at the default `private`
|
||||||
|
const bCtx = await browser.newContext();
|
||||||
|
const bPage = await bCtx.newPage();
|
||||||
|
const bCdp = await bPage.context().newCDPSession(bPage);
|
||||||
|
await setupVirtualAuthenticator(bCdp);
|
||||||
|
const bEmail = `ex-b-${stamp}@example.com`;
|
||||||
|
const bUsername = `exb${stamp}`;
|
||||||
|
await registerUser(bPage, bEmail, bUsername);
|
||||||
|
// B stays private — should NOT appear on /explore.
|
||||||
|
|
||||||
|
// A loads /explore — the directory should include A but not B.
|
||||||
|
await page.goto("/explore");
|
||||||
|
await expect(page.getByText(`@${aUsername}`)).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByText(`@${bUsername}`)).toHaveCount(0);
|
||||||
|
|
||||||
|
await bCtx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -168,6 +168,7 @@ export default {
|
||||||
tryPlannerPrefix: "Oder ",
|
tryPlannerPrefix: "Oder ",
|
||||||
tryPlannerLink: "öffne den Planer ohne Konto",
|
tryPlannerLink: "öffne den Planer ohne Konto",
|
||||||
tryPlannerSuffix: " →",
|
tryPlannerSuffix: " →",
|
||||||
|
exploreLink: "Wer ist hier? →",
|
||||||
marketing: {
|
marketing: {
|
||||||
planner: {
|
planner: {
|
||||||
title: "Routen gemeinsam planen",
|
title: "Routen gemeinsam planen",
|
||||||
|
|
@ -385,12 +386,23 @@ export default {
|
||||||
nav: {
|
nav: {
|
||||||
routes: "Routen",
|
routes: "Routen",
|
||||||
activities: "Aktivitäten",
|
activities: "Aktivitäten",
|
||||||
|
explore: "Entdecken",
|
||||||
login: "Anmelden",
|
login: "Anmelden",
|
||||||
register: "Registrieren",
|
register: "Registrieren",
|
||||||
profile: "Profil",
|
profile: "Profil",
|
||||||
settings: "Einstellungen",
|
settings: "Einstellungen",
|
||||||
logout: "Abmelden",
|
logout: "Abmelden",
|
||||||
},
|
},
|
||||||
|
explore: {
|
||||||
|
heading: "Entdecken",
|
||||||
|
empty: "Auf dieser Instanz hat noch niemand ein öffentliches Profil.",
|
||||||
|
activeRecently: {
|
||||||
|
heading: "Kürzlich aktiv",
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
heading: "Alle",
|
||||||
|
},
|
||||||
|
},
|
||||||
alpha: {
|
alpha: {
|
||||||
message: "trails.cool befindet sich in früher Entwicklung — deine Daten können zurückgesetzt werden.",
|
message: "trails.cool befindet sich in früher Entwicklung — deine Daten können zurückgesetzt werden.",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export default {
|
||||||
tryPlannerPrefix: "Or ",
|
tryPlannerPrefix: "Or ",
|
||||||
tryPlannerLink: "try the Planner without an account",
|
tryPlannerLink: "try the Planner without an account",
|
||||||
tryPlannerSuffix: " →",
|
tryPlannerSuffix: " →",
|
||||||
|
exploreLink: "Browse who's here →",
|
||||||
marketing: {
|
marketing: {
|
||||||
planner: {
|
planner: {
|
||||||
title: "Plan routes together",
|
title: "Plan routes together",
|
||||||
|
|
@ -385,12 +386,23 @@ export default {
|
||||||
nav: {
|
nav: {
|
||||||
routes: "Routes",
|
routes: "Routes",
|
||||||
activities: "Activities",
|
activities: "Activities",
|
||||||
|
explore: "Explore",
|
||||||
login: "Sign In",
|
login: "Sign In",
|
||||||
register: "Register",
|
register: "Register",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
logout: "Log Out",
|
logout: "Log Out",
|
||||||
},
|
},
|
||||||
|
explore: {
|
||||||
|
heading: "Explore",
|
||||||
|
empty: "Nobody on this instance has a public profile yet.",
|
||||||
|
activeRecently: {
|
||||||
|
heading: "Active recently",
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
heading: "Everyone",
|
||||||
|
},
|
||||||
|
},
|
||||||
alpha: {
|
alpha: {
|
||||||
message: "trails.cool is in early development — your data may be reset.",
|
message: "trails.cool is in early development — your data may be reset.",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue