From 37f7a349b92a303ab1ac55b35c6d074877990e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 09:24:19 +0200 Subject: [PATCH] Implement /explore: local user discovery directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/lib/explore.integration.test.ts | 180 +++++++++++++++ apps/journal/app/lib/explore.server.ts | 215 ++++++++++++++++++ apps/journal/app/root.tsx | 3 + apps/journal/app/routes.ts | 1 + apps/journal/app/routes/explore.tsx | 183 +++++++++++++++ apps/journal/app/routes/home.tsx | 8 +- e2e/explore.test.ts | 71 ++++++ packages/i18n/src/locales/de.ts | 12 + packages/i18n/src/locales/en.ts | 12 + 9 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 apps/journal/app/lib/explore.integration.test.ts create mode 100644 apps/journal/app/lib/explore.server.ts create mode 100644 apps/journal/app/routes/explore.tsx create mode 100644 e2e/explore.test.ts diff --git a/apps/journal/app/lib/explore.integration.test.ts b/apps/journal/app/lib/explore.integration.test.ts new file mode 100644 index 0000000..f8ad466 --- /dev/null +++ b/apps/journal/app/lib/explore.integration.test.ts @@ -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); + }); +}); diff --git a/apps/journal/app/lib/explore.server.ts b/apps/journal/app/lib/explore.server.ts new file mode 100644 index 0000000..69fb1a2 --- /dev/null +++ b/apps/journal/app/lib/explore.server.ts @@ -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 { + const db = getDb(); + const perPage = clampPerPage(opts.perPage); + const page = clampPage(opts.page); + const offset = (page - 1) * perPage; + + const latestActivity = sql`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 { + 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 { + const db = getDb(); + const cutoff = new Date(Date.now() - ACTIVE_RECENTLY_DAYS * 24 * 60 * 60 * 1000); + + const latestActivity = sql`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> { + const result = new Map(); + 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. 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> { + const result = new Map(); + 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; diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index e30c5db..ebdf5c0 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -117,6 +117,9 @@ function NavBar({ {t("social.feed.title")} + + {t("nav.explore")} + {t("nav.routes")} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 0247118..b5a5138 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -31,6 +31,7 @@ export default [ route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"), route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"), route("feed", "routes/feed.tsx"), + route("explore", "routes/explore.tsx"), route("api/events", "routes/api.events.ts"), route("notifications", "routes/notifications.tsx"), route("api/notifications/:id/read", "routes/api.notifications.$id.read.ts"), diff --git a/apps/journal/app/routes/explore.tsx b/apps/journal/app/routes/explore.tsx new file mode 100644 index 0000000..a773005 --- /dev/null +++ b/apps/journal/app/routes/explore.tsx @@ -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 ( +
  • +
    + + {row.displayName ?? row.username} + +

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

    + {row.bio &&

    {row.bio}

    } +
    + {isSignedIn && !row.isSelf && ( + + )} +
  • + ); +} + +export default function Explore({ loaderData }: Route.ComponentProps) { + const { isSignedIn, activeRecently, directory, page, totalPages, totalCount } = loaderData; + const { t } = useTranslation("journal"); + + return ( +
    +

    {t("explore.heading")}

    + + {activeRecently.length > 0 && ( +
    +

    + {t("explore.activeRecently.heading")} +

    +
      + {activeRecently.map((row) => ( + + ))} +
    +
    + )} + +
    +

    + {t("explore.directory.heading")} +

    + + {totalCount === 0 ? ( +

    {t("explore.empty")}

    + ) : ( + <> +
      + {directory.map((row) => ( + + ))} +
    + + + + )} +
    +
    + ); +} diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index a1850aa..071f681 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -277,13 +277,19 @@ export default function Home({ loaderData }: Route.ComponentProps) { {/* 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. */}

    {t("home.tryPlannerPrefix")} {t("home.tryPlannerLink")} {t("home.tryPlannerSuffix")} + {" · "} + + {t("home.exploreLink")} +

    diff --git a/e2e/explore.test.ts b/e2e/explore.test.ts new file mode 100644 index 0000000..7bd442a --- /dev/null +++ b/e2e/explore.test.ts @@ -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(); + }); +}); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e651fd3..9e3fa83 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -168,6 +168,7 @@ export default { tryPlannerPrefix: "Oder ", tryPlannerLink: "öffne den Planer ohne Konto", tryPlannerSuffix: " →", + exploreLink: "Wer ist hier? →", marketing: { planner: { title: "Routen gemeinsam planen", @@ -385,12 +386,23 @@ export default { nav: { routes: "Routen", activities: "Aktivitäten", + explore: "Entdecken", login: "Anmelden", register: "Registrieren", profile: "Profil", settings: "Einstellungen", logout: "Abmelden", }, + explore: { + heading: "Entdecken", + empty: "Auf dieser Instanz hat noch niemand ein öffentliches Profil.", + activeRecently: { + heading: "Kürzlich aktiv", + }, + directory: { + heading: "Alle", + }, + }, alpha: { message: "trails.cool befindet sich in früher Entwicklung — deine Daten können zurückgesetzt werden.", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e509866..8a553af 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -168,6 +168,7 @@ export default { tryPlannerPrefix: "Or ", tryPlannerLink: "try the Planner without an account", tryPlannerSuffix: " →", + exploreLink: "Browse who's here →", marketing: { planner: { title: "Plan routes together", @@ -385,12 +386,23 @@ export default { nav: { routes: "Routes", activities: "Activities", + explore: "Explore", login: "Sign In", register: "Register", profile: "Profile", settings: "Settings", logout: "Log Out", }, + explore: { + heading: "Explore", + empty: "Nobody on this instance has a public profile yet.", + activeRecently: { + heading: "Active recently", + }, + directory: { + heading: "Everyone", + }, + }, alpha: { message: "trails.cool is in early development — your data may be reset.", },