Commit7aa8b99unknown_key
feat(BLOCK-J): J4 user following + personalised feed
feat(BLOCK-J): J4 user following + personalised feed Users can now follow each other, see follower/following counts on their profile, and get a personalised timeline at /feed filtered to activity from the people they follow. Migration 0031_user_follows.sql adds: - user_follows (follower_id, following_id, created_at) with composite PK, CASCADE on both sides, no-self-follow CHECK, reverse-lookup index on following_id src/lib/follows.ts: - followUser / unfollowUser / isFollowing / listFollowers / listFollowing / followCounts - feedForUser(userId, limit) joins activity_feed against the follow set (capped at 200 edges) and filters out private repos the viewer doesn't own - describeAction maps activity_feed action tokens to human verbs src/routes/follows.tsx serves: - POST /:user/follow + /:user/unfollow (auth-gated, audit-logged) - GET /:user/followers + /:user/following (public lists) - GET /feed (personalised timeline) Reserved-name set protects fixed paths (login, settings, feed, etc.) from the /:user/* patterns. src/routes/web.tsx profile page adds follower/following counts + a Follow / Unfollow button for authed viewers on someone else's profile. 8 new tests (verb table + route auth). 710 tests total. https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
8 files changed+686−17aa8b9964abd97d6ba5bad023bf537575ed61bbc
8 changed files+686−1
ModifiedBUILD_BIBLE.md+3−1View fileUnifiedSplit
@@ -285,6 +285,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
285285- **J1** — Dependency graph → ✅ shipped. `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies` (ecosystem + name + version_spec + manifest_path + is_dev + commit_sha) with indexes on `(repository_id, ecosystem)` + `(name)`. `src/lib/deps.ts` parses seven manifest formats (package.json, requirements.txt, pyproject.toml, go.mod, Cargo.toml, Gemfile, composer.json) without a TOML library — each parser is defensive and returns `[]` on malformed input. Walks default-branch tree (max 200 manifests, 1MB each), replaces the prior set on reindex. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` (grouped by ecosystem with per-ecosystem counts) + owner-only `POST /dependencies/reindex`. Reverse-dep lookup via `repositoriesDependingOn(ecosystem, name)` for future "who depends on me" network-graph UI. 21 new tests.
286286- **J2** — Security advisories (Dependabot-style) → ✅ shipped. `drizzle/0029_security_advisories.sql` adds `security_advisories` (GHSA + CVE IDs, severity, affected range, fixed version) + `repo_advisory_alerts` (per-repo match state with open/dismissed/fixed, unique on `(repo, advisory, manifest_path)`). `src/lib/advisories.ts` ships a 12-entry seed list (log4shell, lodash, minimist, vm2, urllib3, jwt-go, etc.), a minimal version-range matcher (`satisfiesRange` + `rangeMatches`) that handles `<`/`<=`/`>`/`>=`/`=` clauses including compound ranges, and `scanRepositoryForAlerts(repoId)` which cross-references J1 dep rows against the advisory list — inserts new alerts, reopens fixed-then-regressed ones, auto-closes alerts whose dep went away. `src/routes/advisories.tsx` serves `/:owner/:repo/security/advisories` (open), `/all` (everything), owner-only `POST /scan`, and per-alert `POST /:id/dismiss` + `POST /:id/reopen` with audit-log entries. 27 new tests (version parser, range matcher, seed shape, route auth).
287287- **J3** — Commit signature verification (GPG + SSH "Verified" badge) → ✅ shipped. `drizzle/0030_signing_keys.sql` adds `signing_keys` (per-user GPG/SSH pubkeys, unique on `(key_type, fingerprint)`) + `commit_verifications` (memoised per-commit result, unique on `(repo, sha)`). `src/lib/signatures.ts` extracts `gpgsig` / `gpgsig-sha256` from raw commit objects (`getRawCommitObject` added to `src/git/repository.ts`), unarmors PGP + SSH signature blobs, walks OpenPGP packet streams for Issuer Fingerprint (subpacket 33) / Issuer Key ID (subpacket 16), parses the SSHSIG inner publickey field, and SHA-256 fingerprints SSH wire-format keys. Identity matching via fingerprint → optional email check → cached. `src/routes/signing-keys.tsx` serves `GET/POST /settings/signing-keys` + `POST /settings/signing-keys/:id/delete`, audit-logged. `CommitList` + single commit view render a green "Verified" badge when cached `verified=true`. 29 new tests (extraction, unarmor, packet walker, SSH fp, end-to-end fast paths, route auth).
288- **J4** — User following + personalised feed → ✅ shipped. `drizzle/0031_user_follows.sql` adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK constraint rejecting self-follows, reverse-lookup index on `following_id`). `src/lib/follows.ts` exposes `followUser/unfollowUser/isFollowing/listFollowers/listFollowing/followCounts` + `feedForUser(userId, limit)` which joins `activity_feed` against the follow set (bounded to 200 edges) and filters out private repos the viewer doesn't own. `src/routes/follows.tsx` serves `POST /:user/follow` + `/:user/unfollow` (auth-gated, audit-logged), public `GET /:user/followers` + `/:user/following`, and `GET /feed` (personalised timeline). Follow button + follower/following counts added to the user profile page via `src/routes/web.tsx`. Reserved-name set protects fixed paths (`login`, `settings`, `feed`, etc.). 8 new tests (verb table + route auth).
288289
289290### BLOCK H — Marketplace
290291- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
@@ -300,7 +301,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
300301- `src/app.tsx` — route composition, middleware order, error handlers
301302- `src/index.ts` — Bun server entry
302303- `src/lib/config.ts` — env getters (late-binding)
303- `src/db/schema.ts` — 91 tables. New tables only via new migration.
304- `src/db/schema.ts` — 92 tables. New tables only via new migration.
304305- `src/db/index.ts` — lazy proxy DB connection
305306- `src/db/migrate.ts` — migration runner
306307- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -331,6 +332,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
331332- `drizzle/0028_repo_dependencies.sql` (Block J1) — migration, never edited in place. Adds `repo_dependencies` with indexes on `(repository_id, ecosystem)` + `(name)`.
332333- `drizzle/0029_security_advisories.sql` (Block J2) — migration, never edited in place. Adds `security_advisories` (`ghsa_id` unique) + `repo_advisory_alerts` (unique on `(repository_id, advisory_id, manifest_path)`, status index).
333334- `drizzle/0030_signing_keys.sql` (Block J3) — migration, never edited in place. Adds `signing_keys` (unique on `(key_type, fingerprint)`) + `commit_verifications` (unique on `(repository_id, commit_sha)`).
335- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
334336
335337### 4.2 Git layer (locked)
336338- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
Addeddrizzle/0031_user_follows.sql+15−0View fileUnifiedSplit
@@ -0,0 +1,15 @@
1-- Block J4 — User following / follow-feed.
2--
3-- Directed graph of user -> user. Used to filter activity_feed into a
4-- personalised "what's happening in my network" view.
5
6CREATE TABLE IF NOT EXISTS "user_follows" (
7 "follower_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
8 "following_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
9 "created_at" timestamp NOT NULL DEFAULT now(),
10 PRIMARY KEY ("follower_id", "following_id"),
11 CONSTRAINT "user_follows_no_self" CHECK ("follower_id" <> "following_id")
12);
13
14CREATE INDEX IF NOT EXISTS "user_follows_following_idx"
15 ON "user_follows" ("following_id");
Addedsrc/__tests__/follows.test.ts+64−0View fileUnifiedSplit
@@ -0,0 +1,64 @@
1/**
2 * Block J4 — User following route-auth smokes + pure-helper tests.
3 *
4 * Graph mutations (followUser, etc.) are DB-bound so they're only exercised
5 * via integration. Here we cover the describeAction verb table and
6 * verify route guards redirect anonymous users.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { describeAction } from "../lib/follows";
12
13describe("follows — describeAction", () => {
14 it("maps known actions", () => {
15 expect(describeAction("push")).toBe("pushed to");
16 expect(describeAction("issue_open")).toBe("opened an issue in");
17 expect(describeAction("issue_close")).toBe("closed an issue in");
18 expect(describeAction("pr_open")).toBe("opened a pull request in");
19 expect(describeAction("pr_merge")).toBe("merged a pull request in");
20 expect(describeAction("pr_close")).toBe("closed a pull request in");
21 expect(describeAction("star")).toBe("starred");
22 expect(describeAction("comment")).toBe("commented in");
23 });
24
25 it("falls back to underscore-stripped action for unknown tokens", () => {
26 expect(describeAction("release_publish")).toBe("release publish");
27 expect(describeAction("custom")).toBe("custom");
28 });
29});
30
31describe("follows — route auth", () => {
32 it("POST /:user/follow without auth → 302 /login", async () => {
33 const res = await app.request("/alice/follow", { method: "POST" });
34 expect(res.status).toBe(302);
35 expect(res.headers.get("location") || "").toContain("/login");
36 });
37
38 it("POST /:user/unfollow without auth → 302 /login", async () => {
39 const res = await app.request("/alice/unfollow", { method: "POST" });
40 expect(res.status).toBe(302);
41 expect(res.headers.get("location") || "").toContain("/login");
42 });
43
44 it("GET /feed without auth → 302 /login", async () => {
45 const res = await app.request("/feed");
46 expect(res.status).toBe(302);
47 expect(res.headers.get("location") || "").toContain("/login");
48 });
49
50 it("GET /:user/followers is public (404 or 500 for unknown user)", async () => {
51 const res = await app.request("/nobody-x/followers");
52 expect([404, 500]).toContain(res.status);
53 });
54
55 it("GET /:user/following is public (404 or 500 for unknown user)", async () => {
56 const res = await app.request("/nobody-x/following");
57 expect([404, 500]).toContain(res.status);
58 });
59
60 it("reserved name /login/followers is not a profile route", async () => {
61 const res = await app.request("/login/followers");
62 expect([404, 405, 200, 302]).toContain(res.status);
63 });
64});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -72,6 +72,7 @@ import ssoRoutes from "./routes/sso";
7272import depsRoutes from "./routes/deps";
7373import advisoriesRoutes from "./routes/advisories";
7474import signingKeysRoutes from "./routes/signing-keys";
75import followsRoutes from "./routes/follows";
7576import webRoutes from "./routes/web";
7677
7778const app = new Hono();
@@ -250,6 +251,9 @@ app.route("/", advisoriesRoutes);
250251// Commit signature verification / signing keys — /settings/signing-keys (Block J3)
251252app.route("/", signingKeysRoutes);
252253
254// User following + personalised feed (Block J4)
255app.route("/", followsRoutes);
256
253257// Insights + milestones
254258app.route("/", insightsRoutes);
255259
Modifiedsrc/db/schema.ts+32−0View fileUnifiedSplit
@@ -2232,3 +2232,35 @@ export const commitVerifications = pgTable(
22322232);
22332233
22342234export type CommitVerification = typeof commitVerifications.$inferSelect;
2235
2236// ----------------------------------------------------------------------------
2237// Block J4 — User following
2238// ----------------------------------------------------------------------------
2239
2240/**
2241 * Directed user→user follow edges. Primary key is the composite
2242 * (follower_id, following_id) at the SQL level; drizzle sees it as a
2243 * regular table with a unique index plus a secondary index on the
2244 * reverse-lookup column.
2245 */
2246export const userFollows = pgTable(
2247 "user_follows",
2248 {
2249 followerId: uuid("follower_id")
2250 .notNull()
2251 .references(() => users.id, { onDelete: "cascade" }),
2252 followingId: uuid("following_id")
2253 .notNull()
2254 .references(() => users.id, { onDelete: "cascade" }),
2255 createdAt: timestamp("created_at").defaultNow().notNull(),
2256 },
2257 (table) => [
2258 uniqueIndex("user_follows_pair_unique").on(
2259 table.followerId,
2260 table.followingId
2261 ),
2262 index("user_follows_following_idx").on(table.followingId),
2263 ]
2264);
2265
2266export type UserFollow = typeof userFollows.$inferSelect;
Addedsrc/lib/follows.ts+251−0View fileUnifiedSplit
@@ -0,0 +1,251 @@
1/**
2 * Block J4 — User following + personalised feed.
3 *
4 * Core graph ops and a personalised feed built on top of `activity_feed`.
5 * No caches / materialised views — the follow set is small (tens to low
6 * hundreds) and the activity_feed already carries the indexes we need.
7 */
8
9import { and, desc, eq, inArray } from "drizzle-orm";
10import { db } from "../db";
11import {
12 activityFeed,
13 repositories,
14 userFollows,
15 users,
16 type ActivityEntry,
17 type User,
18} from "../db/schema";
19
20// ----------------------------------------------------------------------------
21// Graph mutations
22// ----------------------------------------------------------------------------
23
24export async function followUser(
25 followerId: string,
26 followingId: string
27): Promise<"ok" | "self" | "already" | "error"> {
28 if (followerId === followingId) return "self";
29 try {
30 const rows = await db
31 .insert(userFollows)
32 .values({ followerId, followingId })
33 .onConflictDoNothing()
34 .returning();
35 return rows.length > 0 ? "ok" : "already";
36 } catch {
37 return "error";
38 }
39}
40
41export async function unfollowUser(
42 followerId: string,
43 followingId: string
44): Promise<boolean> {
45 const rows = await db
46 .delete(userFollows)
47 .where(
48 and(
49 eq(userFollows.followerId, followerId),
50 eq(userFollows.followingId, followingId)
51 )
52 )
53 .returning();
54 return rows.length > 0;
55}
56
57export async function isFollowing(
58 followerId: string,
59 followingId: string
60): Promise<boolean> {
61 if (followerId === followingId) return false;
62 const [row] = await db
63 .select({ f: userFollows.followerId })
64 .from(userFollows)
65 .where(
66 and(
67 eq(userFollows.followerId, followerId),
68 eq(userFollows.followingId, followingId)
69 )
70 )
71 .limit(1);
72 return !!row;
73}
74
75// ----------------------------------------------------------------------------
76// Lists
77// ----------------------------------------------------------------------------
78
79export async function listFollowers(userId: string): Promise<User[]> {
80 return db
81 .select({
82 id: users.id,
83 username: users.username,
84 email: users.email,
85 displayName: users.displayName,
86 passwordHash: users.passwordHash,
87 avatarUrl: users.avatarUrl,
88 bio: users.bio,
89 notifyEmailOnMention: users.notifyEmailOnMention,
90 notifyEmailOnAssign: users.notifyEmailOnAssign,
91 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
92 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
93 lastDigestSentAt: users.lastDigestSentAt,
94 createdAt: users.createdAt,
95 updatedAt: users.updatedAt,
96 })
97 .from(userFollows)
98 .innerJoin(users, eq(userFollows.followerId, users.id))
99 .where(eq(userFollows.followingId, userId))
100 .orderBy(desc(userFollows.createdAt));
101}
102
103export async function listFollowing(userId: string): Promise<User[]> {
104 return db
105 .select({
106 id: users.id,
107 username: users.username,
108 email: users.email,
109 displayName: users.displayName,
110 passwordHash: users.passwordHash,
111 avatarUrl: users.avatarUrl,
112 bio: users.bio,
113 notifyEmailOnMention: users.notifyEmailOnMention,
114 notifyEmailOnAssign: users.notifyEmailOnAssign,
115 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
116 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
117 lastDigestSentAt: users.lastDigestSentAt,
118 createdAt: users.createdAt,
119 updatedAt: users.updatedAt,
120 })
121 .from(userFollows)
122 .innerJoin(users, eq(userFollows.followingId, users.id))
123 .where(eq(userFollows.followerId, userId))
124 .orderBy(desc(userFollows.createdAt));
125}
126
127export async function followCounts(userId: string): Promise<{
128 followers: number;
129 following: number;
130}> {
131 const [followers, following] = await Promise.all([
132 db
133 .select({ uid: userFollows.followerId })
134 .from(userFollows)
135 .where(eq(userFollows.followingId, userId)),
136 db
137 .select({ uid: userFollows.followingId })
138 .from(userFollows)
139 .where(eq(userFollows.followerId, userId)),
140 ]);
141 return { followers: followers.length, following: following.length };
142}
143
144// ----------------------------------------------------------------------------
145// Feed
146// ----------------------------------------------------------------------------
147
148export interface FeedEntry {
149 activity: ActivityEntry;
150 actor: { id: string; username: string; displayName: string | null };
151 repository: { id: string; name: string; ownerId: string; isPrivate: boolean };
152 ownerUsername: string;
153}
154
155/**
156 * Activity feed filtered to the users the viewer follows. We cap at 200
157 * following edges to bound the IN list. Private repos are excluded unless
158 * the viewer owns them.
159 */
160export async function feedForUser(
161 userId: string,
162 limit = 50
163): Promise<FeedEntry[]> {
164 const following = await db
165 .select({ id: userFollows.followingId })
166 .from(userFollows)
167 .where(eq(userFollows.followerId, userId))
168 .limit(200);
169 const ids = following.map((f) => f.id);
170 if (ids.length === 0) return [];
171
172 const rows = await db
173 .select({
174 activity: activityFeed,
175 actor: users,
176 repository: repositories,
177 })
178 .from(activityFeed)
179 .innerJoin(users, eq(activityFeed.userId, users.id))
180 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
181 .where(inArray(activityFeed.userId, ids))
182 .orderBy(desc(activityFeed.createdAt))
183 .limit(limit);
184
185 // Resolve owner usernames for repo links.
186 const ownerIds = Array.from(new Set(rows.map((r) => r.repository.ownerId)));
187 let ownerMap: Record<string, string> = {};
188 if (ownerIds.length) {
189 const owners = await db
190 .select({ id: users.id, username: users.username })
191 .from(users)
192 .where(inArray(users.id, ownerIds));
193 for (const o of owners) ownerMap[o.id] = o.username;
194 }
195
196 return rows
197 .filter(
198 (r) => !r.repository.isPrivate || r.repository.ownerId === userId
199 )
200 .map((r) => ({
201 activity: r.activity,
202 actor: {
203 id: r.actor.id,
204 username: r.actor.username,
205 displayName: r.actor.displayName,
206 },
207 repository: {
208 id: r.repository.id,
209 name: r.repository.name,
210 ownerId: r.repository.ownerId,
211 isPrivate: r.repository.isPrivate,
212 },
213 ownerUsername: ownerMap[r.repository.ownerId] || "",
214 }));
215}
216
217/** Human-readable verb for an activity_feed `action` token. */
218export function describeAction(action: string): string {
219 switch (action) {
220 case "push":
221 return "pushed to";
222 case "issue_open":
223 return "opened an issue in";
224 case "issue_close":
225 return "closed an issue in";
226 case "pr_open":
227 return "opened a pull request in";
228 case "pr_merge":
229 return "merged a pull request in";
230 case "pr_close":
231 return "closed a pull request in";
232 case "star":
233 return "starred";
234 case "comment":
235 return "commented in";
236 default:
237 return action.replace(/_/g, " ");
238 }
239}
240
241/** Resolve a username → user ID or null. */
242export async function resolveUserByName(
243 username: string
244): Promise<User | null> {
245 const [row] = await db
246 .select()
247 .from(users)
248 .where(eq(users.username, username))
249 .limit(1);
250 return row ?? null;
251}
Addedsrc/routes/follows.tsx+257−0View fileUnifiedSplit
@@ -0,0 +1,257 @@
1/**
2 * Block J4 — User following routes.
3 *
4 * POST /:user/follow — auth required
5 * POST /:user/unfollow — auth required
6 * GET /:user/followers — public list
7 * GET /:user/following — public list
8 * GET /feed — auth required, personalised activity
9 */
10
11import { Hono } from "hono";
12import { Layout } from "../views/layout";
13import { softAuth, requireAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import {
16 describeAction,
17 feedForUser,
18 followCounts,
19 followUser,
20 isFollowing,
21 listFollowers,
22 listFollowing,
23 resolveUserByName,
24 unfollowUser,
25} from "../lib/follows";
26import { audit } from "../lib/notify";
27
28const follows = new Hono<AuthEnv>();
29follows.use("*", softAuth);
30
31const RESERVED = new Set([
32 "login",
33 "register",
34 "logout",
35 "new",
36 "settings",
37 "api",
38 "feed",
39 "dashboard",
40 "explore",
41 "search",
42 "notifications",
43 "admin",
44 "orgs",
45 "gists",
46 "marketplace",
47 "sponsors",
48 "developer",
49 "ask",
50 "help",
51]);
52
53function profileUrl(username: string): string {
54 return `/${username}`;
55}
56
57// ---------- Follow / unfollow ----------
58
59follows.post("/:user/follow", requireAuth, async (c) => {
60 const me = c.get("user")!;
61 const targetName = c.req.param("user");
62 if (RESERVED.has(targetName)) return c.notFound();
63 const target = await resolveUserByName(targetName);
64 if (!target) return c.notFound();
65 const res = await followUser(me.id, target.id);
66 if (res === "ok") {
67 await audit({
68 userId: me.id,
69 action: "user.follow",
70 targetId: target.id,
71 metadata: { username: target.username },
72 });
73 }
74 return c.redirect(profileUrl(targetName));
75});
76
77follows.post("/:user/unfollow", requireAuth, async (c) => {
78 const me = c.get("user")!;
79 const targetName = c.req.param("user");
80 if (RESERVED.has(targetName)) return c.notFound();
81 const target = await resolveUserByName(targetName);
82 if (!target) return c.notFound();
83 const ok = await unfollowUser(me.id, target.id);
84 if (ok) {
85 await audit({
86 userId: me.id,
87 action: "user.unfollow",
88 targetId: target.id,
89 metadata: { username: target.username },
90 });
91 }
92 return c.redirect(profileUrl(targetName));
93});
94
95// ---------- Lists ----------
96
97async function renderUserList(
98 c: any,
99 ownerName: string,
100 mode: "followers" | "following"
101) {
102 const user = c.get("user");
103 if (RESERVED.has(ownerName)) return c.notFound();
104 const target = await resolveUserByName(ownerName);
105 if (!target) return c.notFound();
106 const list =
107 mode === "followers"
108 ? await listFollowers(target.id)
109 : await listFollowing(target.id);
110 const counts = await followCounts(target.id);
111
112 return c.html(
113 <Layout
114 title={`${mode === "followers" ? "Followers" : "Following"} — ${ownerName}`}
115 user={user}
116 >
117 <div class="settings-container">
118 <h2 style="margin:0">
119 <a href={`/${ownerName}`} style="text-decoration:none">
120 @{ownerName}
121 </a>
122 </h2>
123 <div style="display:flex;gap:16px;margin:10px 0 20px">
124 <a
125 href={`/${ownerName}/followers`}
126 class={mode === "followers" ? "btn btn-primary" : "btn"}
127 >
128 Followers <span style="opacity:.7">({counts.followers})</span>
129 </a>
130 <a
131 href={`/${ownerName}/following`}
132 class={mode === "following" ? "btn btn-primary" : "btn"}
133 >
134 Following <span style="opacity:.7">({counts.following})</span>
135 </a>
136 </div>
137 {list.length === 0 ? (
138 <div class="panel-empty" style="padding:24px">
139 No {mode}.
140 </div>
141 ) : (
142 <div class="panel">
143 {list.map((u) => (
144 <div class="panel-item">
145 <a href={`/${u.username}`} style="font-weight:600">
146 @{u.username}
147 </a>
148 {u.displayName && (
149 <span style="color:var(--text-muted);margin-left:8px">
150 {u.displayName}
151 </span>
152 )}
153 </div>
154 ))}
155 </div>
156 )}
157 </div>
158 </Layout>
159 );
160}
161
162follows.get("/:user/followers", async (c) =>
163 renderUserList(c, c.req.param("user"), "followers")
164);
165follows.get("/:user/following", async (c) =>
166 renderUserList(c, c.req.param("user"), "following")
167);
168
169// ---------- Personalised feed ----------
170
171follows.get("/feed", requireAuth, async (c) => {
172 const user = c.get("user")!;
173 const entries = await feedForUser(user.id, 50);
174 return c.html(
175 <Layout title="Feed" user={user}>
176 <div class="settings-container">
177 <h2>Your feed</h2>
178 <p style="color:var(--text-muted)">
179 Recent activity from users you follow. Follow someone from their
180 profile page to start filling this up.
181 </p>
182 {entries.length === 0 ? (
183 <div class="panel-empty" style="padding:24px">
184 Nothing here yet. Try the{" "}
185 <a href="/explore">explore page</a> to find people to follow.
186 </div>
187 ) : (
188 <div class="panel">
189 {entries.map((e) => {
190 const repoUrl = `/${e.ownerUsername}/${e.repository.name}`;
191 return (
192 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:2px">
193 <div>
194 <a href={`/${e.actor.username}`} style="font-weight:600">
195 @{e.actor.username}
196 </a>{" "}
197 <span style="color:var(--text-muted)">
198 {describeAction(e.activity.action)}
199 </span>{" "}
200 <a href={repoUrl} style="font-weight:600">
201 {e.ownerUsername}/{e.repository.name}
202 </a>
203 </div>
204 <div
205 style="font-size:12px;color:var(--text-muted)"
206 >
207 {new Date(e.activity.createdAt).toLocaleString()}
208 {e.activity.targetType === "issue" &&
209 e.activity.targetId && (
210 <>
211 {" "}
212 ·{" "}
213 <a
214 href={`${repoUrl}/issues/${e.activity.targetId}`}
215 >
216 #{e.activity.targetId}
217 </a>
218 </>
219 )}
220 {e.activity.targetType === "pr" &&
221 e.activity.targetId && (
222 <>
223 {" "}
224 ·{" "}
225 <a href={`${repoUrl}/pulls/${e.activity.targetId}`}>
226 #{e.activity.targetId}
227 </a>
228 </>
229 )}
230 {e.activity.targetType === "commit" &&
231 e.activity.targetId && (
232 <>
233 {" "}
234 ·{" "}
235 <a
236 href={`${repoUrl}/commit/${e.activity.targetId}`}
237 class="commit-sha"
238 >
239 {String(e.activity.targetId).slice(0, 7)}
240 </a>
241 </>
242 )}
243 </div>
244 </div>
245 );
246 })}
247 </div>
248 )}
249 </div>
250 </Layout>
251 );
252});
253
254export default follows;
255
256// Exported for profile page use (web.tsx).
257export { isFollowing, followCounts, resolveUserByName };
Modifiedsrc/routes/web.tsx+60−0View fileUnifiedSplit
@@ -231,6 +231,27 @@ web.get("/:owner", async (c) => {
231231 : allRepos.filter((r) => !r.isPrivate);
232232 }
233233
234 // Block J4 — follow counts + viewer's follow state
235 let followState = {
236 followers: 0,
237 following: 0,
238 viewerFollows: false,
239 };
240 if (ownerUser) {
241 try {
242 const { followCounts, isFollowing } = await import("../lib/follows");
243 const counts = await followCounts(ownerUser.id);
244 followState.followers = counts.followers;
245 followState.following = counts.following;
246 if (user && user.id !== ownerUser.id) {
247 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
248 }
249 } catch {
250 // DB hiccup — fall back to zeros.
251 }
252 }
253 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
254
234255 return c.html(
235256 <Layout title={ownerName} user={user}>
236257 <div class="user-profile">
@@ -241,6 +262,45 @@ web.get("/:owner", async (c) => {
241262 <h2>{ownerUser?.displayName || ownerName}</h2>
242263 <div class="username">@{ownerName}</div>
243264 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
265 <div
266 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
267 >
268 <a
269 href={`/${ownerName}/followers`}
270 style="color:var(--text-muted)"
271 >
272 <strong style="color:var(--text)">
273 {followState.followers}
274 </strong>{" "}
275 follower{followState.followers === 1 ? "" : "s"}
276 </a>
277 <a
278 href={`/${ownerName}/following`}
279 style="color:var(--text-muted)"
280 >
281 <strong style="color:var(--text)">
282 {followState.following}
283 </strong>{" "}
284 following
285 </a>
286 {canFollow && (
287 <form
288 method="POST"
289 action={`/${ownerName}/${
290 followState.viewerFollows ? "unfollow" : "follow"
291 }`}
292 >
293 <button
294 type="submit"
295 class={`btn ${
296 followState.viewerFollows ? "" : "btn-primary"
297 } btn-sm`}
298 >
299 {followState.viewerFollows ? "Unfollow" : "Follow"}
300 </button>
301 </form>
302 )}
303 </div>
244304 </div>
245305 </div>
246306 <h3 style="margin-bottom: 16px">Repositories</h3>
247307