birdclaw 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -0,0 +1,883 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ ChevronDown,
4
+ ChevronUp,
5
+ ExternalLink,
6
+ MessageCircle,
7
+ Play,
8
+ Repeat2,
9
+ Search,
10
+ Users,
11
+ } from "lucide-react";
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
+ import { AvatarChip } from "#/components/AvatarChip";
14
+ import { ProfilePreview } from "#/components/ProfilePreview";
15
+ import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
16
+ import type {
17
+ LinkInsightItem,
18
+ LinkInsightKind,
19
+ LinkInsightMention,
20
+ LinkInsightRange,
21
+ LinkInsightResponse,
22
+ LinkInsightSort,
23
+ LinkInsightSource,
24
+ ProfileRecord,
25
+ TweetMediaItem,
26
+ } from "#/lib/types";
27
+ import {
28
+ cx,
29
+ emptyStateClass,
30
+ pageHeaderClass,
31
+ pageHeaderRowClass,
32
+ pageSubtitleClass,
33
+ pageTitleClass,
34
+ searchFieldIconClass,
35
+ searchFieldInputClass,
36
+ searchFieldShellClass,
37
+ secondaryButtonClass,
38
+ segmentActiveClass,
39
+ segmentClass,
40
+ segmentedClass,
41
+ } from "#/lib/ui";
42
+
43
+ export const Route = createFileRoute("/links")({
44
+ component: LinksRoute,
45
+ });
46
+
47
+ const INITIAL_VISIBLE_COMMENTS = 3;
48
+ const MORE_COMMENTS_BATCH = 6;
49
+ const LINK_INSIGHTS_LIMIT = 30;
50
+ const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
51
+ const PROFILE_HYDRATION_LIMIT = 30;
52
+ const PROFILE_HYDRATION_DELAY_MS = 1200;
53
+
54
+ const ranges: Array<{ value: LinkInsightRange; label: string }> = [
55
+ { value: "today", label: "Today" },
56
+ { value: "week", label: "Week" },
57
+ { value: "month", label: "Month" },
58
+ { value: "year", label: "Year" },
59
+ { value: "all", label: "All" },
60
+ ];
61
+
62
+ function itemTitle(item: LinkInsightItem) {
63
+ return item.title?.trim() || item.displayUrl;
64
+ }
65
+
66
+ function itemSubtitle(item: LinkInsightItem) {
67
+ const description = item.description?.trim();
68
+ if (description) {
69
+ return description;
70
+ }
71
+ return item.displayUrl.split("?")[0] || item.displayUrl;
72
+ }
73
+
74
+ function insightCacheKey(
75
+ kind: LinkInsightKind,
76
+ range: LinkInsightRange,
77
+ sort: LinkInsightSort,
78
+ source: LinkInsightSource,
79
+ refreshTick: number,
80
+ ) {
81
+ return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
82
+ }
83
+
84
+ function linkInsightsUrl(
85
+ kind: LinkInsightKind,
86
+ range: LinkInsightRange,
87
+ sort: LinkInsightSort,
88
+ source: LinkInsightSource,
89
+ refreshTick: number,
90
+ ) {
91
+ const url = new URL("/api/link-insights", window.location.origin);
92
+ url.searchParams.set("kind", kind);
93
+ url.searchParams.set("range", range);
94
+ url.searchParams.set("sort", sort);
95
+ url.searchParams.set("source", source);
96
+ url.searchParams.set("limit", String(LINK_INSIGHTS_LIMIT));
97
+ url.searchParams.set("commentsLimit", String(LINK_INSIGHTS_COMMENTS_LIMIT));
98
+ url.searchParams.set("refresh", String(refreshTick));
99
+ return url;
100
+ }
101
+
102
+ function mentionHref(mention: LinkInsightMention, item: LinkInsightItem) {
103
+ return mention.sourceUrl || mention.contentTweetUrl || item.url;
104
+ }
105
+
106
+ function mentionCopy(mention: LinkInsightMention) {
107
+ return (
108
+ mention.commentText ||
109
+ mention.sharedContentText ||
110
+ mention.rawText ||
111
+ "Shared without comment"
112
+ );
113
+ }
114
+
115
+ function isSameProfile(
116
+ left: ProfileRecord | null | undefined,
117
+ right: ProfileRecord | null | undefined,
118
+ ) {
119
+ return Boolean(left && right && left.id === right.id);
120
+ }
121
+
122
+ function mediaImage(media: TweetMediaItem[]) {
123
+ return media.find((item) => item.thumbnailUrl || item.url) ?? null;
124
+ }
125
+
126
+ function youtubeVideoId(rawUrl: string) {
127
+ try {
128
+ const url = new URL(rawUrl);
129
+ const host = url.hostname.toLowerCase().replace(/^www\./, "");
130
+ if (host === "youtu.be") {
131
+ return url.pathname.split("/").filter(Boolean)[0] ?? null;
132
+ }
133
+ if (!host.endsWith("youtube.com")) {
134
+ return null;
135
+ }
136
+ if (url.pathname === "/watch") {
137
+ return url.searchParams.get("v");
138
+ }
139
+ const parts = url.pathname.split("/").filter(Boolean);
140
+ if (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "live") {
141
+ return parts[1] ?? null;
142
+ }
143
+ return null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function youtubeThumbnailUrl(rawUrl: string) {
150
+ const id = youtubeVideoId(rawUrl);
151
+ if (!id || !/^[\w-]{6,}$/.test(id)) {
152
+ return null;
153
+ }
154
+ return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
155
+ }
156
+
157
+ function commentCount(item: LinkInsightItem) {
158
+ return (
159
+ item.commentCount ??
160
+ item.mentions.filter((mention) => mention.hasComment).length
161
+ );
162
+ }
163
+
164
+ function pureShareCount(item: LinkInsightItem) {
165
+ return (
166
+ item.pureShareCount ??
167
+ item.mentions.filter(
168
+ (mention) => mention.isPureShare || !mention.hasComment,
169
+ ).length
170
+ );
171
+ }
172
+
173
+ function isArchivePlaceholderProfile(profile: ProfileRecord) {
174
+ return (
175
+ /^id\d+$/i.test(profile.handle) &&
176
+ profile.displayName === profile.handle &&
177
+ profile.id === `profile_user_${profile.handle.slice(2)}`
178
+ );
179
+ }
180
+
181
+ function profileNeedsHydration(profile: ProfileRecord | null | undefined) {
182
+ if (!profile?.handle || isArchivePlaceholderProfile(profile)) {
183
+ return false;
184
+ }
185
+ return !profile.avatarUrl || profile.followersCount === 0;
186
+ }
187
+
188
+ function collectProfilesForHydration(data: LinkInsightResponse | null) {
189
+ const handles = new Set<string>();
190
+ for (const item of data?.items ?? []) {
191
+ for (const profile of [
192
+ item.topSharer,
193
+ ...item.sharers,
194
+ ...item.mentions.flatMap((mention) => [
195
+ mention.sharedBy,
196
+ mention.contentAuthor,
197
+ mention.participant,
198
+ ]),
199
+ ]) {
200
+ if (!profile || !profileNeedsHydration(profile)) {
201
+ continue;
202
+ }
203
+ handles.add(profile.handle.replace(/^@/, ""));
204
+ if (handles.size >= PROFILE_HYDRATION_LIMIT) {
205
+ return [...handles];
206
+ }
207
+ }
208
+ }
209
+ return [...handles];
210
+ }
211
+
212
+ function ProfilePill({
213
+ profile,
214
+ className,
215
+ }: {
216
+ profile: ProfileRecord | null | undefined;
217
+ className?: string;
218
+ }) {
219
+ if (!profile) {
220
+ return (
221
+ <span className={cx("text-[var(--ink-soft)]", className)}>unknown</span>
222
+ );
223
+ }
224
+ return (
225
+ <ProfilePreview profile={profile}>
226
+ <span
227
+ className={cx("font-bold text-[var(--ink)] hover:underline", className)}
228
+ >
229
+ @{profile.handle}
230
+ </span>
231
+ </ProfilePreview>
232
+ );
233
+ }
234
+
235
+ function SharerStrip({ item }: { item: LinkInsightItem }) {
236
+ const itemSharers = item.sharers ?? [];
237
+ const sharers =
238
+ itemSharers.length > 0
239
+ ? itemSharers
240
+ : item.topSharer
241
+ ? [item.topSharer]
242
+ : [];
243
+ const visible = sharers.slice(0, 5);
244
+ const hidden = Math.max(0, item.uniqueSharers - visible.length);
245
+
246
+ if (visible.length === 0) {
247
+ return <span>top unknown</span>;
248
+ }
249
+
250
+ return (
251
+ <div className="group/sharers relative z-10 inline-flex items-center gap-1 hover:z-50 focus-within:z-50">
252
+ <div className="flex -space-x-2">
253
+ {visible.slice(0, 4).map((profile) => (
254
+ <a
255
+ aria-label={`Open @${profile.handle}`}
256
+ className="inline-flex rounded-full bg-[var(--bg)] ring-2 ring-[var(--bg)]"
257
+ href={`https://x.com/${profile.handle}`}
258
+ key={profile.id}
259
+ rel="noreferrer"
260
+ target="_blank"
261
+ >
262
+ <AvatarChip
263
+ avatarUrl={profile.avatarUrl}
264
+ hue={profile.avatarHue}
265
+ name={profile.displayName}
266
+ profileId={profile.id}
267
+ size="small"
268
+ />
269
+ </a>
270
+ ))}
271
+ </div>
272
+ <span className="inline-flex items-center gap-1 text-[13px] text-[var(--ink-soft)]">
273
+ <span>top</span>
274
+ <a
275
+ className="font-medium text-[var(--ink-soft)] hover:underline"
276
+ href={`https://x.com/${visible[0]?.handle ?? ""}`}
277
+ rel="noreferrer"
278
+ target="_blank"
279
+ >
280
+ @{visible[0]?.handle}
281
+ </a>
282
+ </span>
283
+ {hidden > 0 ? (
284
+ <span className="text-[13px] text-[var(--ink-soft)]">
285
+ +{formatCompactNumber(hidden)}
286
+ </span>
287
+ ) : null}
288
+ <div className="pointer-events-none absolute left-0 top-[calc(100%+8px)] z-[80] grid w-[280px] translate-y-1 gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover/sharers:pointer-events-auto group-hover/sharers:translate-y-0 group-hover/sharers:opacity-100 group-focus-within/sharers:pointer-events-auto group-focus-within/sharers:translate-y-0 group-focus-within/sharers:opacity-100">
289
+ <div className="flex items-center gap-2 text-[13px] font-bold text-[var(--ink)]">
290
+ <Users className="size-4" />
291
+ <span>{formatCompactNumber(item.uniqueSharers)} sharers</span>
292
+ </div>
293
+ <div className="grid gap-2">
294
+ {visible.map((profile) => (
295
+ <a
296
+ className="flex min-w-0 items-center gap-2 rounded-xl p-1 transition-colors hover:bg-[var(--bg-hover)]"
297
+ href={`https://x.com/${profile.handle}`}
298
+ key={profile.id}
299
+ rel="noreferrer"
300
+ target="_blank"
301
+ >
302
+ <AvatarChip
303
+ avatarUrl={profile.avatarUrl}
304
+ hue={profile.avatarHue}
305
+ name={profile.displayName}
306
+ profileId={profile.id}
307
+ size="small"
308
+ />
309
+ <span className="min-w-0">
310
+ <span className="block truncate text-[14px] font-bold text-[var(--ink)]">
311
+ {profile.displayName}
312
+ </span>
313
+ <span className="block truncate text-[13px] text-[var(--ink-soft)]">
314
+ @{profile.handle} /{" "}
315
+ {formatCompactNumber(profile.followersCount)} followers
316
+ </span>
317
+ </span>
318
+ </a>
319
+ ))}
320
+ </div>
321
+ </div>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ function SourceActions({
327
+ item,
328
+ mention,
329
+ }: {
330
+ item: LinkInsightItem;
331
+ mention: LinkInsightMention;
332
+ }) {
333
+ const links = [
334
+ mention.sourceUrl
335
+ ? {
336
+ href: mention.sourceUrl,
337
+ label: mention.sourceKind === "dm" ? "Open DM" : "Open tweet",
338
+ }
339
+ : null,
340
+ mention.contentTweetUrl
341
+ ? { href: mention.contentTweetUrl, label: "Open source tweet" }
342
+ : null,
343
+ { href: item.url, label: "Open link" },
344
+ ].filter(Boolean) as Array<{ href: string; label: string }>;
345
+
346
+ return (
347
+ <div className="flex flex-wrap items-center gap-3 text-[13px]">
348
+ {links.map((link) => (
349
+ <a
350
+ key={`${link.label}:${link.href}`}
351
+ className="inline-flex items-center gap-1 font-semibold text-[var(--accent)] hover:underline"
352
+ href={link.href}
353
+ rel="noreferrer"
354
+ target="_blank"
355
+ >
356
+ <span>{link.label}</span>
357
+ <ExternalLink className="size-3.5" />
358
+ </a>
359
+ ))}
360
+ </div>
361
+ );
362
+ }
363
+
364
+ function MentionCard({
365
+ item,
366
+ mention,
367
+ }: {
368
+ item: LinkInsightItem;
369
+ mention: LinkInsightMention;
370
+ }) {
371
+ const image = mediaImage(mention.media);
372
+ const href = mentionHref(mention, item);
373
+ const contentAuthor =
374
+ mention.contentAuthor &&
375
+ !isSameProfile(mention.sharedBy, mention.contentAuthor)
376
+ ? mention.contentAuthor
377
+ : null;
378
+
379
+ return (
380
+ <article className="grid gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg)] px-3 py-2.5">
381
+ <div className="flex min-w-0 items-center gap-2 text-[13px] text-[var(--ink-soft)]">
382
+ <ProfilePill profile={mention.sharedBy} />
383
+ <span>{mention.sourceLabel ?? mention.sourceKind}</span>
384
+ <span>/</span>
385
+ <a
386
+ className="hover:underline"
387
+ href={href}
388
+ rel="noreferrer"
389
+ target="_blank"
390
+ >
391
+ {formatShortTimestamp(mention.createdAt)}
392
+ </a>
393
+ </div>
394
+ <p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
395
+ {mentionCopy(mention)}
396
+ </p>
397
+ {contentAuthor ? (
398
+ <div className="text-[13px] text-[var(--ink-soft)]">
399
+ source by <ProfilePill profile={contentAuthor} />
400
+ </div>
401
+ ) : null}
402
+ {image ? (
403
+ <a
404
+ className="block max-w-[220px] overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-active)]"
405
+ href={href}
406
+ rel="noreferrer"
407
+ target="_blank"
408
+ >
409
+ <img
410
+ alt={image.altText || "Mention media"}
411
+ className="aspect-video h-auto w-full object-cover"
412
+ src={image.thumbnailUrl || image.url}
413
+ />
414
+ </a>
415
+ ) : null}
416
+ <SourceActions item={item} mention={mention} />
417
+ </article>
418
+ );
419
+ }
420
+
421
+ function PureShareCluster({
422
+ item,
423
+ mentions,
424
+ }: {
425
+ item: LinkInsightItem;
426
+ mentions: LinkInsightMention[];
427
+ }) {
428
+ const profiles = mentions
429
+ .map((mention) => mention.sharedBy)
430
+ .filter(Boolean) as ProfileRecord[];
431
+ const visible = profiles.slice(0, 6);
432
+ const firstSource = mentions.find((mention) => mention.sourceUrl)?.sourceUrl;
433
+
434
+ return (
435
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] px-3 py-2.5">
436
+ <div className="flex min-w-0 items-center gap-2">
437
+ <div className="grid size-8 shrink-0 place-items-center rounded-full bg-[var(--bg-active)] text-[var(--ink-soft)]">
438
+ <Repeat2 className="size-4" />
439
+ </div>
440
+ <div className="min-w-0">
441
+ <div className="text-[14px] font-bold text-[var(--ink)]">
442
+ {formatCompactNumber(pureShareCount(item))} shares without comment
443
+ </div>
444
+ <div className="flex min-w-0 flex-wrap items-center gap-1 text-[13px] text-[var(--ink-soft)]">
445
+ {visible.map((profile) => (
446
+ <ProfilePill key={profile.id} profile={profile} />
447
+ ))}
448
+ {pureShareCount(item) > visible.length ? (
449
+ <span>
450
+ +{formatCompactNumber(pureShareCount(item) - visible.length)}{" "}
451
+ more
452
+ </span>
453
+ ) : null}
454
+ </div>
455
+ </div>
456
+ </div>
457
+ {firstSource ? (
458
+ <a
459
+ className="inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent)] hover:underline"
460
+ href={firstSource}
461
+ rel="noreferrer"
462
+ target="_blank"
463
+ >
464
+ <span>Open first source</span>
465
+ <ExternalLink className="size-3.5" />
466
+ </a>
467
+ ) : null}
468
+ </div>
469
+ );
470
+ }
471
+
472
+ function CommentsSection({ item }: { item: LinkInsightItem }) {
473
+ const [expanded, setExpanded] = useState(false);
474
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
475
+ const comments = item.mentions.filter((mention) => mention.hasComment);
476
+ const pureShares = item.mentions.filter(
477
+ (mention) => mention.isPureShare || !mention.hasComment,
478
+ );
479
+ const itemCommentCount = commentCount(item);
480
+ const itemPureShareCount = pureShareCount(item);
481
+ const totalDiscussion = itemCommentCount + itemPureShareCount;
482
+ const visibleComments = comments.slice(0, visibleCount);
483
+ const remainingLocalComments = Math.max(0, comments.length - visibleCount);
484
+ const remainingComments = Math.max(
485
+ 0,
486
+ itemCommentCount - visibleComments.length,
487
+ );
488
+
489
+ if (totalDiscussion === 0) {
490
+ return null;
491
+ }
492
+
493
+ return (
494
+ <div className="mt-2 grid gap-2 border-t border-[var(--line)] pt-2">
495
+ <button
496
+ className="inline-flex w-fit items-center gap-1 rounded-full border-0 bg-transparent px-2 py-1 text-[13px] font-bold text-[var(--accent)] transition-colors hover:bg-[var(--accent-soft)]"
497
+ onClick={() => setExpanded((value) => !value)}
498
+ type="button"
499
+ >
500
+ <MessageCircle className="size-4" />
501
+ <span>
502
+ {expanded ? "Hide" : "Show"} {formatCompactNumber(itemCommentCount)}{" "}
503
+ comments
504
+ {itemPureShareCount > 0
505
+ ? ` / ${formatCompactNumber(itemPureShareCount)} shares`
506
+ : ""}
507
+ </span>
508
+ {expanded ? (
509
+ <ChevronUp className="size-4" />
510
+ ) : (
511
+ <ChevronDown className="size-4" />
512
+ )}
513
+ </button>
514
+ {expanded ? (
515
+ <div className="grid gap-2">
516
+ {visibleComments.map((mention) => (
517
+ <MentionCard key={mention.id} item={item} mention={mention} />
518
+ ))}
519
+ {remainingLocalComments > 0 ? (
520
+ <button
521
+ className={cx(secondaryButtonClass, "w-fit")}
522
+ onClick={() =>
523
+ setVisibleCount((value) => value + MORE_COMMENTS_BATCH)
524
+ }
525
+ type="button"
526
+ >
527
+ Show{" "}
528
+ {formatCompactNumber(
529
+ Math.min(MORE_COMMENTS_BATCH, remainingLocalComments),
530
+ )}{" "}
531
+ more
532
+ </button>
533
+ ) : null}
534
+ {remainingComments > 0 && remainingLocalComments === 0 ? (
535
+ <div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] px-3 py-2 text-[13px] text-[var(--ink-soft)]">
536
+ {formatCompactNumber(remainingComments)} more mentions in archive.
537
+ Narrow the range or raise the API limit to inspect them.
538
+ </div>
539
+ ) : null}
540
+ {itemPureShareCount > 0 ? (
541
+ <PureShareCluster item={item} mentions={pureShares} />
542
+ ) : null}
543
+ </div>
544
+ ) : null}
545
+ </div>
546
+ );
547
+ }
548
+
549
+ function VideoPreview({ item }: { item: LinkInsightItem }) {
550
+ if (item.kind !== "videos") {
551
+ return null;
552
+ }
553
+ const image = mediaImage(item.mentions.flatMap((mention) => mention.media));
554
+ const thumbnailUrl =
555
+ image?.thumbnailUrl || image?.url || youtubeThumbnailUrl(item.url);
556
+
557
+ return (
558
+ <a
559
+ className="mt-2 flex max-w-md items-center gap-3 overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] transition-colors hover:bg-[var(--bg-active)]"
560
+ href={item.url}
561
+ rel="noreferrer"
562
+ target="_blank"
563
+ >
564
+ <div className="relative grid aspect-video w-36 shrink-0 place-items-center overflow-hidden bg-[var(--bg-active)] text-[var(--ink-soft)]">
565
+ {thumbnailUrl ? (
566
+ <img
567
+ alt={image?.altText || itemTitle(item)}
568
+ className="h-full w-full object-cover transition-transform duration-150 hover:scale-105"
569
+ src={thumbnailUrl}
570
+ />
571
+ ) : (
572
+ <Play className="size-6" />
573
+ )}
574
+ <div className="absolute inset-0 grid place-items-center bg-black/20">
575
+ <span className="grid size-9 place-items-center rounded-full bg-black/70 text-white shadow-[0_4px_18px_rgba(0,0,0,0.35)]">
576
+ <Play className="ml-0.5 size-4 fill-current" />
577
+ </span>
578
+ </div>
579
+ </div>
580
+ <div className="min-w-0 py-2 pr-3">
581
+ <div className="truncate text-[13px] font-bold text-[var(--ink)]">
582
+ {itemTitle(item)}
583
+ </div>
584
+ <div className="truncate text-[13px] text-[var(--ink-soft)]">
585
+ {item.displayUrl}
586
+ </div>
587
+ </div>
588
+ </a>
589
+ );
590
+ }
591
+
592
+ function LinkInsightRow({
593
+ index,
594
+ item,
595
+ }: {
596
+ index: number;
597
+ item: LinkInsightItem;
598
+ }) {
599
+ return (
600
+ <article
601
+ className="flex flex-col gap-2 border-b border-[var(--line)] px-4 py-3 transition-colors duration-150 hover:bg-[var(--bg-hover)]"
602
+ data-perf="link-insight-row"
603
+ >
604
+ <div className="flex items-start gap-3">
605
+ <div className="grid size-9 shrink-0 place-items-center rounded-full bg-[var(--bg-active)] text-[14px] font-bold text-[var(--ink-soft)]">
606
+ {index + 1}
607
+ </div>
608
+ <div className="min-w-0 flex-1">
609
+ <a
610
+ className="block break-words text-[15px] font-bold text-[var(--ink)] [overflow-wrap:anywhere] hover:underline"
611
+ href={item.url}
612
+ rel="noreferrer"
613
+ target="_blank"
614
+ >
615
+ {itemTitle(item)}
616
+ </a>
617
+ <p className="mt-0.5 truncate text-[13px] text-[var(--ink-soft)]">
618
+ {itemSubtitle(item)}
619
+ </p>
620
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
621
+ <span>{item.host}</span>
622
+ <span>/</span>
623
+ <span>{formatCompactNumber(item.shareCount)} shares</span>
624
+ <span>/</span>
625
+ <span>{formatCompactNumber(item.uniqueSharers)} sharers</span>
626
+ <span>/</span>
627
+ <span>{formatCompactNumber(commentCount(item))} comments</span>
628
+ <span>/</span>
629
+ <SharerStrip item={item} />
630
+ <span>/</span>
631
+ <span>{formatShortTimestamp(item.lastSeenAt)}</span>
632
+ </div>
633
+ <VideoPreview item={item} />
634
+ </div>
635
+ </div>
636
+ <CommentsSection item={item} />
637
+ </article>
638
+ );
639
+ }
640
+
641
+ function LinksRoute() {
642
+ const [kind, setKind] = useState<LinkInsightKind>("links");
643
+ const [range, setRange] = useState<LinkInsightRange>("week");
644
+ const [source, setSource] = useState<LinkInsightSource>("all");
645
+ const [sort, setSort] = useState<LinkInsightSort>("rank");
646
+ const [search, setSearch] = useState("");
647
+ const [refreshTick, setRefreshTick] = useState(0);
648
+ const hydratedHandlesRef = useRef(new Set<string>());
649
+ const cacheRef = useRef(new Map<string, LinkInsightResponse>());
650
+ const inFlightRef = useRef(new Set<string>());
651
+ const mountedRef = useRef(true);
652
+ const [, bumpCacheVersion] = useState(0);
653
+ const currentCacheKey = insightCacheKey(
654
+ kind,
655
+ range,
656
+ sort,
657
+ source,
658
+ refreshTick,
659
+ );
660
+ const data = cacheRef.current.get(currentCacheKey) ?? null;
661
+
662
+ useEffect(() => {
663
+ return () => {
664
+ mountedRef.current = false;
665
+ };
666
+ }, []);
667
+
668
+ const fetchInsights = useCallback(
669
+ (fetchKind: LinkInsightKind) => {
670
+ const key = insightCacheKey(fetchKind, range, sort, source, refreshTick);
671
+ if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
672
+ return;
673
+ }
674
+ inFlightRef.current.add(key);
675
+ fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
676
+ .then((response) => response.json())
677
+ .then((response: LinkInsightResponse) => {
678
+ cacheRef.current.set(key, response);
679
+ if (mountedRef.current) {
680
+ bumpCacheVersion((value) => value + 1);
681
+ }
682
+ })
683
+ .catch((error: unknown) => {
684
+ if (error instanceof DOMException && error.name === "AbortError") {
685
+ return;
686
+ }
687
+ console.warn("Link insights failed", error);
688
+ })
689
+ .finally(() => {
690
+ inFlightRef.current.delete(key);
691
+ });
692
+ },
693
+ [range, refreshTick, sort, source],
694
+ );
695
+
696
+ useEffect(() => {
697
+ fetchInsights(kind);
698
+ }, [fetchInsights, kind]);
699
+
700
+ useEffect(() => {
701
+ if (!data) {
702
+ return;
703
+ }
704
+ const prefetchKind = kind === "links" ? "videos" : "links";
705
+ const timer = window.setTimeout(() => fetchInsights(prefetchKind), 250);
706
+ return () => window.clearTimeout(timer);
707
+ }, [data, fetchInsights, kind]);
708
+
709
+ useEffect(() => {
710
+ const handles = collectProfilesForHydration(data).filter(
711
+ (handle) => !hydratedHandlesRef.current.has(handle.toLowerCase()),
712
+ );
713
+ if (handles.length === 0) {
714
+ return;
715
+ }
716
+
717
+ const controller = new AbortController();
718
+ const url = new URL("/api/profile-hydrate", window.location.origin);
719
+ url.searchParams.set("handles", handles.join(","));
720
+ for (const handle of handles) {
721
+ hydratedHandlesRef.current.add(handle.toLowerCase());
722
+ }
723
+
724
+ let idleId: number | null = null;
725
+ const runHydration = () => {
726
+ fetch(url, { signal: controller.signal })
727
+ .then((response) => response.json())
728
+ .then((response: { hydratedProfiles?: number }) => {
729
+ if ((response.hydratedProfiles ?? 0) > 0) {
730
+ setRefreshTick((value) => value + 1);
731
+ }
732
+ })
733
+ .catch((error: unknown) => {
734
+ if (error instanceof DOMException && error.name === "AbortError") {
735
+ return;
736
+ }
737
+ console.warn("Profile hydration failed", error);
738
+ });
739
+ };
740
+ const timer = window.setTimeout(() => {
741
+ if ("requestIdleCallback" in window) {
742
+ idleId = window.requestIdleCallback(runHydration, { timeout: 2500 });
743
+ } else {
744
+ runHydration();
745
+ }
746
+ }, PROFILE_HYDRATION_DELAY_MS);
747
+
748
+ return () => {
749
+ controller.abort();
750
+ window.clearTimeout(timer);
751
+ if (idleId !== null && "cancelIdleCallback" in window) {
752
+ window.cancelIdleCallback(idleId);
753
+ }
754
+ };
755
+ }, [data]);
756
+
757
+ const items = useMemo(() => {
758
+ const query = search.trim().toLowerCase();
759
+ const filtered = (data?.items ?? []).filter((item) => {
760
+ if (!query) {
761
+ return true;
762
+ }
763
+ return [
764
+ item.title,
765
+ item.description,
766
+ item.displayUrl,
767
+ item.host,
768
+ item.topSharer?.handle,
769
+ ...item.mentions.map((mention) => mention.commentText),
770
+ ]
771
+ .filter(Boolean)
772
+ .some((value) => String(value).toLowerCase().includes(query));
773
+ });
774
+ return filtered;
775
+ }, [data?.items, search]);
776
+
777
+ const subtitle = useMemo(() => {
778
+ if (!data) {
779
+ return "Loading link memory...";
780
+ }
781
+ const label = kind === "videos" ? "video URLs" : "URLs";
782
+ return `${formatCompactNumber(data.stats.occurrences)} ${label} across ${formatCompactNumber(data.stats.groups)} groups`;
783
+ }, [data, kind]);
784
+
785
+ return (
786
+ <>
787
+ <header className={pageHeaderClass}>
788
+ <div className={pageHeaderRowClass}>
789
+ <div className="flex min-w-0 flex-col">
790
+ <h1 className={pageTitleClass}>Links</h1>
791
+ <p className={pageSubtitleClass}>{subtitle}</p>
792
+ </div>
793
+ </div>
794
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
795
+ <label className={cx(searchFieldShellClass, "min-w-[180px] flex-1")}>
796
+ <Search className={searchFieldIconClass} strokeWidth={2} />
797
+ <input
798
+ className={searchFieldInputClass}
799
+ onChange={(event) => setSearch(event.target.value)}
800
+ placeholder="Search links"
801
+ value={search}
802
+ />
803
+ </label>
804
+ <div className={segmentedClass}>
805
+ {(["links", "videos"] as const).map((value) => (
806
+ <button
807
+ key={value}
808
+ className={cx(
809
+ segmentClass,
810
+ kind === value && segmentActiveClass,
811
+ )}
812
+ onClick={() => setKind(value)}
813
+ type="button"
814
+ >
815
+ {value}
816
+ </button>
817
+ ))}
818
+ </div>
819
+ <div className={segmentedClass}>
820
+ {ranges.map((entry) => (
821
+ <button
822
+ key={entry.value}
823
+ className={cx(
824
+ segmentClass,
825
+ range === entry.value && segmentActiveClass,
826
+ )}
827
+ onClick={() => setRange(entry.value)}
828
+ type="button"
829
+ >
830
+ {entry.label}
831
+ </button>
832
+ ))}
833
+ </div>
834
+ <div className={segmentedClass}>
835
+ {(["all", "tweet", "dm"] as const).map((value) => (
836
+ <button
837
+ key={value}
838
+ className={cx(
839
+ segmentClass,
840
+ source === value && segmentActiveClass,
841
+ )}
842
+ onClick={() => setSource(value)}
843
+ type="button"
844
+ >
845
+ {value}
846
+ </button>
847
+ ))}
848
+ </div>
849
+ <div className={segmentedClass}>
850
+ {(["rank", "recent", "comments"] as const).map((value) => (
851
+ <button
852
+ key={value}
853
+ className={cx(
854
+ segmentClass,
855
+ sort === value && segmentActiveClass,
856
+ )}
857
+ onClick={() => setSort(value)}
858
+ type="button"
859
+ >
860
+ {value}
861
+ </button>
862
+ ))}
863
+ </div>
864
+ </div>
865
+ </header>
866
+
867
+ <section className="flex flex-col">
868
+ {items.length === 0 ? (
869
+ <div className={emptyStateClass}>
870
+ {data ? "No links in this window." : "Loading links..."}
871
+ </div>
872
+ ) : null}
873
+ {items.map((item, index) => (
874
+ <LinkInsightRow
875
+ index={index}
876
+ item={item}
877
+ key={`${kind}:${range}:${source}:${item.id}`}
878
+ />
879
+ ))}
880
+ </section>
881
+ </>
882
+ );
883
+ }