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:
Ullrich Schäfer 2026-04-26 09:24:19 +02:00
parent 65f8313f2b
commit 37f7a349b9
9 changed files with 684 additions and 1 deletions

View 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);
});
});

View 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;

View file

@ -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>

View file

@ -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"),

View 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>
);
}

View file

@ -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
View 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();
});
});

View file

@ -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.",
}, },

View file

@ -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.",
}, },