Stream F from docs/information-architecture.md. Closes the gap between "I want to follow someone on this instance" and "I have a username from outside the app." This change is the OpenSpec proposal only — the four artifacts (proposal.md, design.md, specs/, tasks.md) plus a new top-level spec target at openspec/specs/explore/. Implementation lands in a follow-up PR via /opsx:apply. Key decisions captured in design.md: - Anonymous access is allowed (the data shown is already public, and it's a stronger first-visit experience). - Directory order: MAX(activities.created_at) DESC NULLS LAST, tiebreaker users.id DESC. Recency-of-activity is the simplest signal that meaningfully reflects "who's active here right now." - "Active recently" sub-section: same query, sliced — top 5 users with a public activity in the last 30 days, hidden when empty. - Privacy filter: profile_visibility = 'public' AND not the demo persona AND (forward-compat) not banned/suspended. - No search in v1 — deferred until the user count makes it actually useful. /users/<username> already partially solves it. - Offset pagination (?page=, ?perPage=, capped 100). Cursor pagination is the right answer at ~10K users; not now. - Navbar gets an "Explore" entry for signed-in users; anonymous visitors reach /explore via a link on the visitor home. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.9 KiB
Context
The Journal has no in-app discovery surface today. A signed-in user who wants to follow someone has to come in with a username from outside the app or notice a profile in the public activity feed on logged-out /. The four current prod users are all known to the operator, so this hasn't pinched yet — but the gap will become real as the user count grows, and worse once federation lands and remote actor discovery is also on the table.
The IA review (docs/information-architecture.md, Stream F) called this out as the highest-value missing surface. This design is the local-only v1: a paginated directory of public local users, ordered by recency of activity, plus a small "Active recently" sub-section to give the page some texture when the directory is small.
Existing infrastructure this builds on:
userstable hasprofile_visibility(public|private) — the directory's primary opt-out.activitiestable hascreated_atandvisibility— used for ordering by recency and for the "Active recently" filter.apps/journal/app/lib/follow.server.tsalready providescountFollowersandgetFollowStateper user — directly reusable for the per-row data.apps/journal/app/components/FollowButton.tsxalready renders the right Follow / Requested / Unfollow state — directly reusable.- The locked-account access rule (
social-follows) already excludes private profiles from public listings; we follow the same pattern.
Goals / Non-Goals
Goals:
- A signed-in user who lands on
/explorecan browse local public users and tap Follow / Request to follow without leaving the page. - An anonymous visitor can browse the same directory (the data is public anyway), so first-visit discovery doesn't require sign-up.
- The page is paginated and sortable by a stable rule (recency of activity, descending) so it works at 4 users today and at 4,000 users later without a rewrite.
- Private profiles, banned/suspended users (when those statuses exist), and the demo persona are excluded.
- The capability lives in its own spec file; the directory query lives in its own server lib file. No bleed into
social-followsoractivity-feed.
Non-Goals:
- Search. Deferred to v2. With four prod users, a paginated list is enough; users with known handles can already reach
/users/<username>directly. - Algorithmic curation. No "people you might know" recommendations, no graph traversal, no engagement-based ranking. Recency-of-activity is the only signal.
- Remote actor discovery. Federation is
social-federation's problem when that lands; v1 is local-only. - A dedicated user-detail card on
/explore. Each row is a compact summary; deep dive is/users/:username. - Activity content on
/explore. The directory is people, not posts. The Public view of/feed(Stream A) is the content discovery surface.
Decisions
Anonymous access is allowed
/explore is reachable to logged-out visitors. Why:
- The data shown — public profiles only — is already reachable without auth (
/users/<username>is anonymously reachable for public profiles), so there's no privacy concern. - It's a stronger first-visit experience than the current logged-out home, which is hero + marketing + public activity feed. A visitor can see who's on the instance and decide to sign up to follow them.
- It mirrors how
/users/<username>and the public activity feed treat anonymous visitors.
The Follow button on each row only renders for signed-in viewers; anonymous viewers see the row content but no action button.
Alternative considered: require auth, redirect anonymous to /auth/login. Rejected because it makes the discovery surface less valuable for the population it most needs to convert (first-time visitors).
Directory is ordered by MAX(activities.created_at) descending
The most-recent-activity ordering keeps the directory feeling alive without algorithmic complexity. Users with no activities yet sort to the end (by users.created_at descending as a tiebreaker, so newer users appear ahead of older inactive ones).
Alternatives considered:
- Alphabetical by username. Predictable but boring; surfaces inactive accounts to the top half.
- Random shuffle. Fair to small users but not bookmarkable, breaks pagination.
- Follower count. Reasonable but has a Matthew-effect tilt; one or two heavy users would dominate page 1.
Recency-by-activity is the simplest signal that meaningfully reflects "who's active here right now."
"Active recently" sub-section is the same query, sliced
The page renders an "Active recently" strip at the top (top 5 users with at least one public activity in the last 30 days). It's the same join as the main directory; the strip is just a different WHERE and LIMIT. We don't denormalize anything, don't precompute anything, don't cache anything. Below it is the full paginated directory.
Why: at 4 users this strip is the whole directory. At 4,000 users it's a useful signal-of-life. We can revisit when there's data to revisit on.
Paginate via offset, not cursor (yet)
The directory uses standard ?page=N&perPage=20 offset pagination, capped at 100 per page. Why:
- The data is small (single-digit users today, low hundreds soon).
- Recency-of-activity is unstable enough as a sort key that cursor pagination would need to break ties on
users.idanyway, and the win over offset is marginal at this scale. - Offset pagination is the simplest thing that works.
When the directory hits ~10K users we'll revisit. Cursor pagination's the right answer there; not now.
Alternative considered: keyset cursor pagination like /notifications. Rejected as premature.
Navbar entry for signed-in users only
Adding "Explore" to the navbar for signed-in users keeps the discovery surface visible from any page. For anonymous visitors, the navbar stays minimal (Login / Register CTAs) and /explore is reached via a link on the visitor home (/). The navbar redesign (Stream C) will work out the visual treatment; this proposal just specifies that the entry exists for signed-in users.
Alternative considered: put "Explore" in the navbar for everyone. Rejected because the anonymous navbar is intentionally minimal — anything other than auth CTAs dilutes the conversion goal of the visitor home.
Privacy filter is profile_visibility = 'public' AND not in an exclusion set
The directory query filters:
profile_visibility = 'public'— Mastodon-style locked accounts opt out by their visibility setting, same as/users/:username/followersalready enforces.- Banned/suspended users — when those statuses exist (they don't yet; this is a forward-compat scaffold). The query should be written so adding a
statuscolumn or similar is a one-line filter change, not a query rewrite. - Demo persona —
apps/journal/app/lib/demo-bot.server.tsexposes the persona username; exclude it from the directory so the demo bot doesn't appear in real discovery.
The query lives in apps/journal/app/lib/explore.server.ts. Tests cover each exclusion as a separate scenario.
Risks / Trade-offs
- Cold-start UX at 4 users. The directory will be tiny. → Acceptable; the "Active recently" strip handles the "looks empty" feeling, and the public activity feed on logged-out
/already does the same job from the content angle. - Recency ordering tilts toward power users. A user who posts daily will perpetually outrank a quieter user. → Acceptable for v1; if it becomes a fairness issue we add a tiebreaker (e.g., bucket by week, randomize within bucket). Don't optimize speculatively.
- No search. A user looking for a specific handle can't search. → Mitigated by
/users/<username>working directly. Search is a v2 follow-up. - Federation collision. Once federation lands, the directory will need a "local" / "remote" distinction. → The capability spec says "local users" explicitly; remote-actor discovery is a
social-federationadd-on that can introduce its own surface or extend/explorelater.
Migration Plan
No data migration. The directory is a JOIN of existing users and activities rows. Deploy is a normal app rollout. Rollback is reverting the route registration in routes.ts.
Open Questions
- Bio in the directory row? The user table has a
biocolumn. Showing the first ~120 chars per row gives the directory more character but pads the layout. Default plan: include a truncated bio. Easy to drop if it's noisy. - Follower count visible in the directory? Default plan: yes (mirrors how
/users/:usernameshows it). A dissenter could argue this creates social-pressure metric-anxiety; counter-argument is that Mastodon, the comparable, shows it. - Does
/exploredeserve its own SSE event for follow-state changes? The Follow button on a row should reflect "you just followed this person" without a hard reload. Today the FollowButton component handles this via local state on/users/<username>; the same pattern works on/explorewith the same component. No new SSE channel needed.