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
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue