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.tsxBlame740 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
3918b2eClaude9 *
10 * 2026 polish: scoped `.follows-*` class system gives this surface a card
11 * grid, gradient hairline hero, and per-row follow/unfollow buttons. No
12 * shared files touched; the actions and queries are preserved verbatim.
7aa8b99Claude13 */
14
15import { Hono } from "hono";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 describeAction,
21 feedForUser,
22 followCounts,
23 followUser,
24 isFollowing,
25 listFollowers,
26 listFollowing,
27 resolveUserByName,
28 unfollowUser,
29} from "../lib/follows";
30import { audit } from "../lib/notify";
31
32const follows = new Hono<AuthEnv>();
33follows.use("*", softAuth);
34
35const RESERVED = new Set([
36 "login",
37 "register",
38 "logout",
39 "new",
40 "settings",
41 "api",
42 "feed",
43 "dashboard",
44 "explore",
45 "search",
46 "notifications",
47 "admin",
48 "orgs",
49 "gists",
50 "marketplace",
51 "sponsors",
52 "developer",
53 "ask",
54 "help",
55]);
56
57function profileUrl(username: string): string {
58 return `/${username}`;
59}
60
3918b2eClaude61// ─── Scoped CSS (.follows-*) ────────────────────────────────────────────────
62//
63// All selectors namespaced with `.follows-` to avoid bleeding into shared
64// chrome (RepoHeader, layout nav). Pulls tokens from the layout for theme
65// continuity (--bg-elevated, --border, --text-strong, --accent, --space-*).
66
67const followStyles = `
eed4684Claude68 .follows-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
3918b2eClaude69
70 /* ─── Header ─── */
71 .follows-head { margin-bottom: var(--space-5); }
72 .follows-eyebrow {
73 display: inline-flex;
74 align-items: center;
75 gap: 8px;
76 text-transform: uppercase;
77 font-family: var(--font-mono);
78 font-size: 11px;
79 letter-spacing: 0.16em;
80 color: var(--text-muted);
81 font-weight: 600;
82 margin-bottom: 10px;
83 }
84 .follows-eyebrow-dot {
85 width: 8px; height: 8px;
86 border-radius: 9999px;
6fd5915Claude87 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
88 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
3918b2eClaude89 }
90 .follows-title {
91 font-family: var(--font-display);
92 font-size: clamp(24px, 3.4vw, 36px);
93 font-weight: 800;
94 letter-spacing: -0.028em;
95 line-height: 1.1;
96 margin: 0 0 6px;
97 color: var(--text-strong);
98 }
99 .follows-title-grad {
6fd5915Claude100 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
3918b2eClaude101 -webkit-background-clip: text;
102 background-clip: text;
103 -webkit-text-fill-color: transparent;
104 color: transparent;
105 }
106 .follows-sub {
107 margin: 0;
108 font-size: 14px;
109 color: var(--text-muted);
110 line-height: 1.5;
111 max-width: 680px;
112 }
113 .follows-sub a { color: var(--accent); text-decoration: none; }
114 .follows-sub a:hover { text-decoration: underline; }
115
116 /* ─── Tab strip ─── */
117 .follows-tabs {
118 display: inline-flex;
119 flex-wrap: wrap;
120 gap: 8px;
121 margin: var(--space-3) 0 var(--space-5);
122 padding: 4px;
123 background: rgba(255,255,255,0.02);
124 border: 1px solid var(--border);
125 border-radius: 12px;
126 }
127 .follows-tab {
128 display: inline-flex;
129 align-items: center;
130 gap: 8px;
131 padding: 7px 13px;
132 border-radius: 9px;
133 font-size: 13px;
134 font-weight: 600;
135 color: var(--text-muted);
136 text-decoration: none;
137 border: 1px solid transparent;
138 line-height: 1;
139 font-variant-numeric: tabular-nums;
140 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
141 }
142 .follows-tab:hover {
143 color: var(--text-strong);
144 background: rgba(255,255,255,0.03);
145 text-decoration: none;
146 }
147 .follows-tab.is-active {
6fd5915Claude148 background: linear-gradient(135deg, rgba(91,110,232,0.16), rgba(95,143,160,0.14));
3918b2eClaude149 color: #e9e2ff;
6fd5915Claude150 border-color: rgba(91,110,232,0.40);
3918b2eClaude151 }
152 .follows-tab-count {
153 font-family: var(--font-mono);
154 font-size: 11.5px;
155 opacity: 0.75;
156 }
157
158 /* ─── Section card (wraps the grid) ─── */
159 .follows-section {
160 background: var(--bg-elevated);
161 border: 1px solid var(--border);
162 border-radius: 14px;
163 overflow: hidden;
164 position: relative;
165 }
166 .follows-section::before {
167 content: '';
168 position: absolute;
169 top: 0; left: 0; right: 0;
170 height: 2px;
6fd5915Claude171 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
3918b2eClaude172 opacity: 0.55;
173 pointer-events: none;
174 }
175 .follows-section-body { padding: var(--space-4) var(--space-5); }
176
177 /* ─── User card grid ─── */
178 .follows-grid {
179 display: grid;
180 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
181 gap: 12px;
182 }
183 .follows-card {
184 display: flex;
185 align-items: flex-start;
186 gap: 14px;
187 padding: 14px;
188 background: rgba(255,255,255,0.018);
189 border: 1px solid var(--border);
190 border-radius: 12px;
191 transition: border-color 120ms ease, background 120ms ease;
192 }
193 .follows-card:hover {
194 border-color: var(--border-strong);
195 background: rgba(255,255,255,0.03);
196 }
197 .follows-avatar {
198 width: 48px; height: 48px;
199 border-radius: 9999px;
6fd5915Claude200 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
3918b2eClaude201 color: #ffffff;
202 display: inline-flex;
203 align-items: center;
204 justify-content: center;
205 font-family: var(--font-display);
206 font-weight: 700;
207 font-size: 18px;
208 flex-shrink: 0;
209 overflow: hidden;
210 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
211 text-decoration: none;
212 }
213 .follows-avatar img {
214 width: 100%; height: 100%;
215 object-fit: cover;
216 display: block;
217 }
218 .follows-card-body { flex: 1; min-width: 0; }
219 .follows-card-row {
220 display: flex;
221 align-items: baseline;
222 gap: 8px;
223 flex-wrap: wrap;
224 }
225 .follows-card-name {
226 font-family: var(--font-display);
227 font-weight: 700;
228 font-size: 14.5px;
229 color: var(--text-strong);
230 text-decoration: none;
231 letter-spacing: -0.005em;
232 }
233 .follows-card-name:hover { text-decoration: underline; }
234 .follows-card-handle {
235 font-family: var(--font-mono);
236 font-size: 12px;
237 color: var(--text-muted);
238 }
239 .follows-card-bio {
240 margin: 6px 0 0;
241 font-size: 12.5px;
242 color: var(--text-muted);
243 line-height: 1.45;
244 display: -webkit-box;
245 -webkit-line-clamp: 2;
246 -webkit-box-orient: vertical;
247 overflow: hidden;
248 }
249 .follows-card-action { margin-top: 10px; }
250 .follows-card-action form { margin: 0; }
251
252 /* ─── Buttons ─── */
253 .follows-btn {
254 display: inline-flex;
255 align-items: center;
256 justify-content: center;
257 gap: 6px;
258 padding: 7px 14px;
259 border-radius: 9px;
260 font-size: 12.5px;
261 font-weight: 600;
262 text-decoration: none;
263 border: 1px solid transparent;
264 cursor: pointer;
265 font: inherit;
266 line-height: 1;
267 white-space: nowrap;
268 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
269 }
270 .follows-btn-primary {
6fd5915Claude271 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
3918b2eClaude272 color: #ffffff;
6fd5915Claude273 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
3918b2eClaude274 }
275 .follows-btn-primary:hover {
276 transform: translateY(-1px);
6fd5915Claude277 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
3918b2eClaude278 color: #ffffff;
279 text-decoration: none;
280 }
281 .follows-btn-ghost {
282 background: transparent;
283 color: var(--text);
284 border-color: var(--border-strong);
285 }
286 .follows-btn-ghost:hover {
6fd5915Claude287 background: rgba(91,110,232,0.06);
288 border-color: rgba(91,110,232,0.45);
3918b2eClaude289 color: var(--text-strong);
290 text-decoration: none;
291 }
292
293 /* ─── Empty state ─── */
294 .follows-empty {
295 position: relative;
296 overflow: hidden;
297 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
298 text-align: center;
299 background: var(--bg-elevated);
300 border: 1px dashed var(--border-strong);
301 border-radius: 16px;
302 }
303 .follows-empty-orb {
304 position: absolute;
305 inset: -40% 30% auto 30%;
306 height: 280px;
6fd5915Claude307 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
3918b2eClaude308 filter: blur(70px);
309 opacity: 0.7;
310 pointer-events: none;
311 z-index: 0;
312 }
313 .follows-empty-inner { position: relative; z-index: 1; }
314 .follows-empty-icon {
315 width: 56px; height: 56px;
316 border-radius: 9999px;
6fd5915Claude317 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
318 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
3918b2eClaude319 display: inline-flex;
320 align-items: center;
321 justify-content: center;
322 color: #c4b5fd;
323 margin-bottom: 14px;
324 }
325 .follows-empty-title {
326 font-family: var(--font-display);
327 font-size: 18px;
328 font-weight: 700;
329 margin: 0 0 6px;
330 color: var(--text-strong);
331 }
332 .follows-empty-sub {
333 margin: 0 auto;
334 font-size: 13.5px;
335 color: var(--text-muted);
336 max-width: 420px;
337 line-height: 1.5;
338 }
339 .follows-empty-sub a { color: var(--accent); text-decoration: none; font-weight: 600; }
340 .follows-empty-sub a:hover { text-decoration: underline; }
341
342 /* ─── Feed entries ─── */
343 .follows-feed {
344 display: flex;
345 flex-direction: column;
346 gap: 8px;
347 }
348 .follows-feed-entry {
349 padding: 12px 14px;
350 background: rgba(255,255,255,0.018);
351 border: 1px solid var(--border);
352 border-radius: 11px;
353 transition: border-color 120ms ease, background 120ms ease;
354 }
355 .follows-feed-entry:hover {
356 border-color: var(--border-strong);
357 background: rgba(255,255,255,0.03);
358 }
359 .follows-feed-line {
360 font-size: 13.5px;
361 color: var(--text);
362 line-height: 1.45;
363 }
364 .follows-feed-line a {
365 color: var(--text-strong);
366 text-decoration: none;
367 font-weight: 600;
368 }
369 .follows-feed-line a:hover { text-decoration: underline; }
370 .follows-feed-verb {
371 color: var(--text-muted);
372 font-weight: 400;
373 }
374 .follows-feed-meta {
375 margin-top: 4px;
376 font-family: var(--font-mono);
377 font-size: 11.5px;
378 color: var(--text-muted);
379 font-variant-numeric: tabular-nums;
380 }
381 .follows-feed-meta a { color: var(--text-muted); text-decoration: none; }
382 .follows-feed-meta a:hover { color: var(--text); text-decoration: underline; }
383 .follows-feed-sha {
384 font-family: var(--font-mono);
385 background: rgba(255,255,255,0.04);
386 padding: 1px 6px;
387 border-radius: 4px;
388 }
389`;
390
391function IconUsers() {
392 return (
393 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
394 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
395 <circle cx="9" cy="7" r="4" />
396 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
397 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
398 </svg>
399 );
400}
401function IconFeed() {
402 return (
403 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
404 <path d="M4 11a9 9 0 0 1 9 9" />
405 <path d="M4 4a16 16 0 0 1 16 16" />
406 <circle cx="5" cy="19" r="1.5" />
407 </svg>
408 );
409}
410function IconPlus() {
411 return (
412 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
413 <line x1="12" y1="5" x2="12" y2="19" />
414 <line x1="5" y1="12" x2="19" y2="12" />
415 </svg>
416 );
417}
418function IconCheck() {
419 return (
420 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
421 <polyline points="20 6 9 17 4 12" />
422 </svg>
423 );
424}
425
7aa8b99Claude426// ---------- Follow / unfollow ----------
427
428follows.post("/:user/follow", requireAuth, async (c) => {
429 const me = c.get("user")!;
430 const targetName = c.req.param("user");
431 if (RESERVED.has(targetName)) return c.notFound();
432 const target = await resolveUserByName(targetName);
433 if (!target) return c.notFound();
434 const res = await followUser(me.id, target.id);
435 if (res === "ok") {
436 await audit({
437 userId: me.id,
438 action: "user.follow",
439 targetId: target.id,
440 metadata: { username: target.username },
441 });
442 }
443 return c.redirect(profileUrl(targetName));
444});
445
446follows.post("/:user/unfollow", requireAuth, async (c) => {
447 const me = c.get("user")!;
448 const targetName = c.req.param("user");
449 if (RESERVED.has(targetName)) return c.notFound();
450 const target = await resolveUserByName(targetName);
451 if (!target) return c.notFound();
452 const ok = await unfollowUser(me.id, target.id);
453 if (ok) {
454 await audit({
455 userId: me.id,
456 action: "user.unfollow",
457 targetId: target.id,
458 metadata: { username: target.username },
459 });
460 }
461 return c.redirect(profileUrl(targetName));
462});
463
464// ---------- Lists ----------
465
466async function renderUserList(
467 c: any,
468 ownerName: string,
469 mode: "followers" | "following"
470) {
471 const user = c.get("user");
472 if (RESERVED.has(ownerName)) return c.notFound();
473 const target = await resolveUserByName(ownerName);
474 if (!target) return c.notFound();
475 const list =
476 mode === "followers"
477 ? await listFollowers(target.id)
478 : await listFollowing(target.id);
479 const counts = await followCounts(target.id);
480
3918b2eClaude481 // For each row, compute whether the viewer already follows them. This is
482 // best-effort: anonymous viewers / self-references skip the lookup so the
483 // page still renders if `isFollowing` errors.
484 const viewerFollows = new Map<string, boolean>();
485 if (user) {
486 await Promise.all(
487 list.map(async (u) => {
488 if (u.id === user.id) return;
489 try {
490 viewerFollows.set(u.id, await isFollowing(user.id, u.id));
491 } catch {
492 viewerFollows.set(u.id, false);
493 }
494 })
495 );
496 }
497
7aa8b99Claude498 return c.html(
499 <Layout
500 title={`${mode === "followers" ? "Followers" : "Following"} — ${ownerName}`}
501 user={user}
502 >
3918b2eClaude503 <div class="follows-wrap">
504 <header class="follows-head">
505 <div class="follows-eyebrow">
506 <span class="follows-eyebrow-dot" aria-hidden="true" />
507 Profile · @{ownerName}
508 </div>
509 <h1 class="follows-title">
510 <span class="follows-title-grad">
511 {mode === "followers" ? "Followers" : "Following"}.
512 </span>
513 </h1>
514 <p class="follows-sub">
515 {mode === "followers" ? (
516 <>People who follow <a href={`/${ownerName}`}>@{ownerName}</a>.</>
517 ) : (
518 <>People <a href={`/${ownerName}`}>@{ownerName}</a> follows.</>
519 )}
520 </p>
521
522 <nav class="follows-tabs" aria-label="Follow view">
523 <a
524 href={`/${ownerName}/followers`}
525 class={"follows-tab" + (mode === "followers" ? " is-active" : "")}
526 >
527 Followers <span class="follows-tab-count">{counts.followers}</span>
528 </a>
529 <a
530 href={`/${ownerName}/following`}
531 class={"follows-tab" + (mode === "following" ? " is-active" : "")}
532 >
533 Following <span class="follows-tab-count">{counts.following}</span>
534 </a>
535 </nav>
536 </header>
537
7aa8b99Claude538 {list.length === 0 ? (
3918b2eClaude539 <div class="follows-empty">
540 <div class="follows-empty-orb" aria-hidden="true" />
541 <div class="follows-empty-inner">
542 <div class="follows-empty-icon" aria-hidden="true">
543 <IconUsers />
544 </div>
545 <h3 class="follows-empty-title">No {mode} yet</h3>
546 <p class="follows-empty-sub">
547 {mode === "followers"
548 ? "Nobody follows this account yet."
549 : "This account hasn't followed anyone yet."}
550 </p>
551 </div>
7aa8b99Claude552 </div>
553 ) : (
3918b2eClaude554 <section class="follows-section">
555 <div class="follows-section-body">
556 <div class="follows-grid">
557 {list.map((u) => {
558 const isSelf = !!user && u.id === user.id;
559 const already = viewerFollows.get(u.id) === true;
560 return (
561 <div class="follows-card">
562 <a
563 href={`/${u.username}`}
564 class="follows-avatar"
565 aria-hidden="true"
566 tabIndex={-1}
567 >
568 {u.avatarUrl ? (
569 <img src={u.avatarUrl} alt="" loading="lazy" />
570 ) : (
571 u.username[0]?.toUpperCase() ?? "?"
572 )}
573 </a>
574 <div class="follows-card-body">
575 <div class="follows-card-row">
576 <a href={`/${u.username}`} class="follows-card-name">
577 {u.displayName || u.username}
578 </a>
579 <span class="follows-card-handle">@{u.username}</span>
580 </div>
581 {u.bio && <p class="follows-card-bio">{u.bio}</p>}
582 {user && !isSelf && (
583 <div class="follows-card-action">
584 {already ? (
585 <form
586 method="post"
587 action={`/${u.username}/unfollow`}
588 >
589 <button
590 type="submit"
591 class="follows-btn follows-btn-ghost"
592 >
593 <IconCheck />
594 Following
595 </button>
596 </form>
597 ) : (
598 <form
599 method="post"
600 action={`/${u.username}/follow`}
601 >
602 <button
603 type="submit"
604 class="follows-btn follows-btn-primary"
605 >
606 <IconPlus />
607 Follow
608 </button>
609 </form>
610 )}
611 </div>
612 )}
613 </div>
614 </div>
615 );
616 })}
7aa8b99Claude617 </div>
3918b2eClaude618 </div>
619 </section>
7aa8b99Claude620 )}
621 </div>
3918b2eClaude622 <style dangerouslySetInnerHTML={{ __html: followStyles }} />
7aa8b99Claude623 </Layout>
624 );
625}
626
627follows.get("/:user/followers", async (c) =>
628 renderUserList(c, c.req.param("user"), "followers")
629);
630follows.get("/:user/following", async (c) =>
631 renderUserList(c, c.req.param("user"), "following")
632);
633
634// ---------- Personalised feed ----------
635
636follows.get("/feed", requireAuth, async (c) => {
637 const user = c.get("user")!;
638 const entries = await feedForUser(user.id, 50);
639 return c.html(
640 <Layout title="Feed" user={user}>
3918b2eClaude641 <div class="follows-wrap">
642 <header class="follows-head">
643 <div class="follows-eyebrow">
644 <span class="follows-eyebrow-dot" aria-hidden="true" />
645 Your network · Live activity
646 </div>
647 <h1 class="follows-title">
648 <span class="follows-title-grad">Your feed.</span>
649 </h1>
650 <p class="follows-sub">
651 Recent activity from accounts you follow. Find more people on{" "}
652 <a href="/explore">/explore</a> and follow them from their profile.
653 </p>
654 </header>
655
7aa8b99Claude656 {entries.length === 0 ? (
3918b2eClaude657 <div class="follows-empty">
658 <div class="follows-empty-orb" aria-hidden="true" />
659 <div class="follows-empty-inner">
660 <div class="follows-empty-icon" aria-hidden="true">
661 <IconFeed />
662 </div>
663 <h3 class="follows-empty-title">Nothing here yet</h3>
664 <p class="follows-empty-sub">
665 Try the <a href="/explore">explore page</a> to find people to
666 follow — their pushes, PRs, and issues will surface here.
667 </p>
668 </div>
7aa8b99Claude669 </div>
670 ) : (
3918b2eClaude671 <section class="follows-section">
672 <div class="follows-section-body">
673 <div class="follows-feed">
674 {entries.map((e) => {
675 const repoUrl = `/${e.ownerUsername}/${e.repository.name}`;
676 return (
677 <div class="follows-feed-entry">
678 <div class="follows-feed-line">
679 <a href={`/${e.actor.username}`}>
680 @{e.actor.username}
681 </a>{" "}
682 <span class="follows-feed-verb">
683 {describeAction(e.activity.action)}
684 </span>{" "}
685 <a href={repoUrl}>
686 {e.ownerUsername}/{e.repository.name}
687 </a>
688 </div>
689 <div class="follows-feed-meta">
690 {new Date(e.activity.createdAt).toLocaleString()}
691 {e.activity.targetType === "issue" &&
692 e.activity.targetId && (
693 <>
694 {" · "}
695 <a
696 href={`${repoUrl}/issues/${e.activity.targetId}`}
697 >
698 #{e.activity.targetId}
699 </a>
700 </>
701 )}
702 {e.activity.targetType === "pr" &&
703 e.activity.targetId && (
704 <>
705 {" · "}
706 <a href={`${repoUrl}/pulls/${e.activity.targetId}`}>
707 #{e.activity.targetId}
708 </a>
709 </>
710 )}
711 {e.activity.targetType === "commit" &&
712 e.activity.targetId && (
713 <>
714 {" · "}
715 <a
716 href={`${repoUrl}/commit/${e.activity.targetId}`}
717 class="follows-feed-sha"
718 >
719 {String(e.activity.targetId).slice(0, 7)}
720 </a>
721 </>
722 )}
723 </div>
724 </div>
725 );
726 })}
727 </div>
728 </div>
729 </section>
7aa8b99Claude730 )}
731 </div>
3918b2eClaude732 <style dangerouslySetInnerHTML={{ __html: followStyles }} />
7aa8b99Claude733 </Layout>
734 );
735});
736
737export default follows;
738
739// Exported for profile page use (web.tsx).
740export { isFollowing, followCounts, resolveUserByName };