CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
follows.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 7aa8b99 | 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 | ||
| 11 | import { Hono } from "hono"; | |
| 12 | import { Layout } from "../views/layout"; | |
| 13 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 14 | import type { AuthEnv } from "../middleware/auth"; | |
| 15 | import { | |
| 16 | describeAction, | |
| 17 | feedForUser, | |
| 18 | followCounts, | |
| 19 | followUser, | |
| 20 | isFollowing, | |
| 21 | listFollowers, | |
| 22 | listFollowing, | |
| 23 | resolveUserByName, | |
| 24 | unfollowUser, | |
| 25 | } from "../lib/follows"; | |
| 26 | import { audit } from "../lib/notify"; | |
| 27 | ||
| 28 | const follows = new Hono<AuthEnv>(); | |
| 29 | follows.use("*", softAuth); | |
| 30 | ||
| 31 | const 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 | ||
| 53 | function profileUrl(username: string): string { | |
| 54 | return `/${username}`; | |
| 55 | } | |
| 56 | ||
| 57 | // ---------- Follow / unfollow ---------- | |
| 58 | ||
| 59 | follows.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 | ||
| 77 | follows.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 | ||
| 97 | async 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 | ||
| 162 | follows.get("/:user/followers", async (c) => | |
| 163 | renderUserList(c, c.req.param("user"), "followers") | |
| 164 | ); | |
| 165 | follows.get("/:user/following", async (c) => | |
| 166 | renderUserList(c, c.req.param("user"), "following") | |
| 167 | ); | |
| 168 | ||
| 169 | // ---------- Personalised feed ---------- | |
| 170 | ||
| 171 | follows.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 | ||
| 254 | export default follows; | |
| 255 | ||
| 256 | // Exported for profile page use (web.tsx). | |
| 257 | export { isFollowing, followCounts, resolveUserByName }; |