Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

follows.tsxBlame257 lines · 1 contributor
7aa8b99Claude1/**
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 };