birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -0,0 +1,186 @@
1
+ import { Check, ChevronDown } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { fetchQueryEnvelope } from "#/lib/api-client";
5
+ import type { AccountRecord } from "#/lib/types";
6
+ import { cx } from "#/lib/ui";
7
+ import { AvatarChip } from "./AvatarChip";
8
+ import { setStoredAccountId, useSelectedAccountId } from "./account-selection";
9
+
10
+ function hueForAccount(account: AccountRecord) {
11
+ let hash = 0;
12
+ const value = account.handle || account.name || account.id;
13
+ for (const character of value) {
14
+ hash = (hash * 31 + character.charCodeAt(0)) % 360;
15
+ }
16
+ return hash;
17
+ }
18
+
19
+ function accountLabel(account: AccountRecord) {
20
+ return account.handle || account.name || account.id;
21
+ }
22
+
23
+ function avatarName(account: AccountRecord) {
24
+ return account.name || account.handle || account.id;
25
+ }
26
+
27
+ export function AccountSwitcher({ action }: { action?: ReactNode } = {}) {
28
+ const [accounts, setAccounts] = useState<AccountRecord[]>([]);
29
+ const [open, setOpen] = useState(false);
30
+ const switcherRef = useRef<HTMLDivElement>(null);
31
+ const selectedAccountId = useSelectedAccountId(accounts);
32
+ const selectedAccount = useMemo(
33
+ () => accounts.find((account) => account.id === selectedAccountId),
34
+ [accounts, selectedAccountId],
35
+ );
36
+
37
+ useEffect(() => {
38
+ let active = true;
39
+ fetchQueryEnvelope()
40
+ .then((meta) => {
41
+ if (active) setAccounts(meta.accounts);
42
+ })
43
+ .catch(() => {
44
+ if (active) setAccounts([]);
45
+ });
46
+ return () => {
47
+ active = false;
48
+ };
49
+ }, []);
50
+
51
+ useEffect(() => {
52
+ if (!open) return;
53
+
54
+ const closeOnOutside = (event: PointerEvent) => {
55
+ if (!switcherRef.current?.contains(event.target as Node)) {
56
+ setOpen(false);
57
+ }
58
+ };
59
+ const closeOnEscape = (event: KeyboardEvent) => {
60
+ if (event.key === "Escape") setOpen(false);
61
+ };
62
+ window.addEventListener("pointerdown", closeOnOutside);
63
+ window.addEventListener("keydown", closeOnEscape);
64
+ return () => {
65
+ window.removeEventListener("pointerdown", closeOnOutside);
66
+ window.removeEventListener("keydown", closeOnEscape);
67
+ };
68
+ }, [open]);
69
+
70
+ if (accounts.length < 2 || !selectedAccount) {
71
+ return action ? (
72
+ <div className="flex justify-center px-1 min-[1100px]:justify-start min-[1100px]:px-2">
73
+ {action}
74
+ </div>
75
+ ) : null;
76
+ }
77
+
78
+ return (
79
+ <div ref={switcherRef} className="relative px-1 min-[1100px]:px-2">
80
+ <div
81
+ className={cx(
82
+ "group grid w-full justify-items-center gap-2 text-[var(--ink)] min-[1100px]:flex min-[1100px]:h-11 min-[1100px]:items-center min-[1100px]:justify-start min-[1100px]:gap-1 min-[1100px]:rounded-full min-[1100px]:border min-[1100px]:border-transparent min-[1100px]:bg-transparent min-[1100px]:p-0.5 min-[1100px]:transition-[background,border-color,box-shadow] min-[1100px]:duration-150 min-[1100px]:hover:bg-[var(--bg-hover)]",
83
+ open &&
84
+ "min-[1100px]:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] min-[1100px]:bg-[var(--bg-active)] min-[1100px]:shadow-[0_0_0_1px_color-mix(in_srgb,var(--accent)_20%,transparent)]",
85
+ )}
86
+ >
87
+ <button
88
+ type="button"
89
+ aria-expanded={open}
90
+ aria-haspopup="listbox"
91
+ aria-label={`Active account: ${accountLabel(selectedAccount)}`}
92
+ className="flex size-11 min-w-0 flex-none items-center justify-center gap-2 rounded-full px-1 py-1 text-left transition-colors duration-150 hover:bg-[var(--bg-hover)] focus-visible:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_srgb,var(--accent)_28%,transparent)] min-[1100px]:h-auto min-[1100px]:w-auto min-[1100px]:flex-1 min-[1100px]:justify-start min-[1100px]:hover:bg-transparent"
93
+ onClick={() => setOpen((value) => !value)}
94
+ >
95
+ <AvatarChip
96
+ avatarUrl={selectedAccount.avatarUrl}
97
+ hue={selectedAccount.avatarHue ?? hueForAccount(selectedAccount)}
98
+ name={avatarName(selectedAccount)}
99
+ profileId={selectedAccount.profileId}
100
+ size="small"
101
+ />
102
+ <span className="hidden min-w-0 flex-1 flex-col leading-tight min-[1100px]:flex">
103
+ <span className="truncate text-[14px] font-bold">
104
+ {accountLabel(selectedAccount)}
105
+ </span>
106
+ <span className="truncate text-[11px] font-medium text-[var(--ink-soft)]">
107
+ active account
108
+ </span>
109
+ </span>
110
+ </button>
111
+ {action ? (
112
+ <div className="order-first shrink-0 min-[1100px]:order-none">
113
+ {action}
114
+ </div>
115
+ ) : null}
116
+ <button
117
+ type="button"
118
+ aria-expanded={open}
119
+ aria-haspopup="listbox"
120
+ aria-label="Switch account"
121
+ className="hidden size-8 shrink-0 items-center justify-center rounded-full text-[var(--ink-soft)] transition-colors duration-150 hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] focus-visible:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_srgb,var(--accent)_28%,transparent)] min-[1100px]:inline-flex"
122
+ onClick={() => setOpen((value) => !value)}
123
+ >
124
+ <ChevronDown
125
+ className={cx(
126
+ "size-4 transition-transform duration-150",
127
+ open && "rotate-180",
128
+ )}
129
+ strokeWidth={2.2}
130
+ aria-hidden="true"
131
+ />
132
+ </button>
133
+ </div>
134
+ {open ? (
135
+ <div
136
+ role="listbox"
137
+ aria-label="Active account"
138
+ className="absolute bottom-[calc(100%+8px)] left-1 z-50 w-[228px] overflow-hidden rounded-2xl border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--bg)_96%,black_4%)] py-1.5 shadow-[0_18px_48px_rgba(0,0,0,.28)] backdrop-blur min-[1100px]:left-2"
139
+ >
140
+ {accounts.map((account) => {
141
+ const selected = account.id === selectedAccount.id;
142
+ return (
143
+ <button
144
+ key={account.id}
145
+ type="button"
146
+ role="option"
147
+ aria-selected={selected}
148
+ className={cx(
149
+ "flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors duration-150 hover:bg-[var(--bg-hover)] focus-visible:bg-[var(--bg-active)] focus-visible:outline-none",
150
+ selected && "bg-[var(--accent-soft)]",
151
+ )}
152
+ onClick={() => {
153
+ setStoredAccountId(account.id);
154
+ setOpen(false);
155
+ }}
156
+ >
157
+ <AvatarChip
158
+ avatarUrl={account.avatarUrl}
159
+ hue={account.avatarHue ?? hueForAccount(account)}
160
+ name={avatarName(account)}
161
+ profileId={account.profileId}
162
+ size="small"
163
+ />
164
+ <span className="min-w-0 flex-1">
165
+ <span className="block truncate text-[14px] font-bold text-[var(--ink)]">
166
+ {accountLabel(account)}
167
+ </span>
168
+ <span className="block truncate text-[12px] text-[var(--ink-soft)]">
169
+ {account.name || account.id}
170
+ </span>
171
+ </span>
172
+ {selected ? (
173
+ <Check
174
+ className="size-4 shrink-0 text-[var(--accent)]"
175
+ strokeWidth={2.4}
176
+ aria-hidden="true"
177
+ />
178
+ ) : null}
179
+ </button>
180
+ );
181
+ })}
182
+ </div>
183
+ ) : null}
184
+ </div>
185
+ );
186
+ }
@@ -2,58 +2,79 @@ import { Link, useRouterState } from "@tanstack/react-router";
2
2
  import {
3
3
  Bell,
4
4
  Bookmark,
5
+ CalendarDays,
6
+ Database,
7
+ Gauge,
8
+ Globe2,
5
9
  Heart,
6
10
  Home,
7
11
  Inbox,
8
12
  Link as LinkIcon,
9
13
  Mail,
14
+ MessagesSquare,
10
15
  ShieldOff,
16
+ UserSearch,
11
17
  } from "lucide-react";
12
18
  import {
13
19
  cx,
14
20
  navLinkActiveClass,
15
21
  navLinkClass,
22
+ navLinkCompactClass,
16
23
  navLinkIconClass,
17
24
  navLinkLabelClass,
25
+ navLinkLabelCompactClass,
18
26
  sidebarBrandClass,
19
27
  sidebarBrandCopyClass,
28
+ sidebarBrandCopyCompactClass,
20
29
  sidebarBrandMarkClass,
21
30
  sidebarBrandTaglineClass,
22
31
  sidebarBrandTitleClass,
32
+ sidebarShellCompactClass,
23
33
  sidebarFooterClass,
24
34
  sidebarNavClass,
25
35
  sidebarShellClass,
26
36
  } from "#/lib/ui";
37
+ import { AccountSwitcher } from "./AccountSwitcher";
27
38
  import { BirdclawMark } from "./BrandMark";
28
39
  import { ThemeSlider } from "./ThemeSlider";
29
40
 
30
41
  const links = [
31
42
  { to: "/inbox", label: "Inbox", icon: Inbox },
43
+ { to: "/today", label: "Today", icon: CalendarDays },
44
+ { to: "/discuss", label: "Discuss", icon: MessagesSquare },
45
+ { to: "/profile-analyze", label: "Analyse", icon: UserSearch },
46
+ { to: "/network-map", label: "Map", icon: Globe2 },
47
+ { to: "/data-sources", label: "Sources", icon: Database },
32
48
  { to: "/", label: "Home", icon: Home },
33
49
  { to: "/mentions", label: "Mentions", icon: Bell },
34
50
  { to: "/likes", label: "Likes", icon: Heart },
35
51
  { to: "/bookmarks", label: "Bookmarks", icon: Bookmark },
36
52
  { to: "/links", label: "Links", icon: LinkIcon },
53
+ { to: "/rate-limits", label: "Rate Limits", icon: Gauge },
37
54
  { to: "/dms", label: "DMs", icon: Mail },
38
55
  { to: "/blocks", label: "Blocks", icon: ShieldOff },
39
56
  ] as const;
40
57
 
41
- export function AppNav() {
58
+ export function AppNav({ compact = false }: { compact?: boolean }) {
42
59
  const pathname = useRouterState({
43
60
  select: (state) => state.location.pathname,
44
61
  });
45
62
 
46
63
  return (
47
- <aside className={sidebarShellClass}>
64
+ <aside className={compact ? sidebarShellCompactClass : sidebarShellClass}>
48
65
  <div className="flex flex-col">
49
66
  <Link to="/" className={sidebarBrandClass}>
50
67
  <span className={sidebarBrandMarkClass}>
51
68
  <BirdclawMark className="size-10" />
52
69
  </span>
53
- <span className={sidebarBrandCopyClass}>
70
+ <span
71
+ className={
72
+ compact ? sidebarBrandCopyCompactClass : sidebarBrandCopyClass
73
+ }
74
+ >
54
75
  <span className={sidebarBrandTitleClass}>birdclaw</span>
55
76
  <span className={sidebarBrandTaglineClass}>
56
- Fast search for your Twitter archive.
77
+ Fast search for your archive.
57
78
  </span>
58
79
  </span>
59
80
  </Link>
@@ -66,7 +87,10 @@ export function AppNav() {
66
87
  key={link.to}
67
88
  to={link.to}
68
89
  aria-label={link.label}
69
- className={cx(navLinkClass, active && navLinkActiveClass)}
90
+ className={cx(
91
+ compact ? navLinkCompactClass : navLinkClass,
92
+ active && navLinkActiveClass,
93
+ )}
70
94
  >
71
95
  <Icon
72
96
  className={navLinkIconClass}
@@ -74,14 +98,20 @@ export function AppNav() {
74
98
  strokeWidth={active ? 2.4 : 1.8}
75
99
  aria-hidden="true"
76
100
  />
77
- <span className={navLinkLabelClass}>{link.label}</span>
101
+ <span
102
+ className={
103
+ compact ? navLinkLabelCompactClass : navLinkLabelClass
104
+ }
105
+ >
106
+ {link.label}
107
+ </span>
78
108
  </Link>
79
109
  );
80
110
  })}
81
111
  </nav>
82
112
  </div>
83
113
  <div className={sidebarFooterClass}>
84
- <ThemeSlider />
114
+ <AccountSwitcher action={<ThemeSlider compact />} />
85
115
  </div>
86
116
  </aside>
87
117
  );
@@ -21,9 +21,7 @@ export function AvatarChip({
21
21
  size?: "default" | "large" | "small";
22
22
  }) {
23
23
  const avatarSrc =
24
- profileId && avatarUrl
25
- ? `/api/avatar?profileId=${encodeURIComponent(profileId)}`
26
- : null;
24
+ profileId && avatarUrl ? avatarPath(profileId, avatarUrl) : null;
27
25
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
28
26
  const showImage = avatarSrc && failedSrc !== avatarSrc;
29
27
 
@@ -50,3 +48,11 @@ export function AvatarChip({
50
48
  </span>
51
49
  );
52
50
  }
51
+
52
+ function avatarPath(profileId: string, avatarUrl: string) {
53
+ const query = new URLSearchParams({
54
+ profileId,
55
+ v: avatarUrl,
56
+ });
57
+ return `/api/avatar?${query.toString()}`;
58
+ }
@@ -19,6 +19,7 @@ import {
19
19
  dmListPreviewClass,
20
20
  dmListTimestampClass,
21
21
  dmMessageBubbleClass,
22
+ dmMessageBubbleInboundClass,
22
23
  dmMessageBubbleOutboundClass,
23
24
  dmMessageMetaClass,
24
25
  dmMessageRowClass,
@@ -48,7 +49,7 @@ function MessageBubble({ message }: { message: DmMessageItem }) {
48
49
  <div
49
50
  className={cx(
50
51
  dmMessageBubbleClass,
51
- outbound && dmMessageBubbleOutboundClass,
52
+ outbound ? dmMessageBubbleOutboundClass : dmMessageBubbleInboundClass,
52
53
  )}
53
54
  >
54
55
  {message.text}
@@ -81,11 +82,11 @@ export function DmWorkspace({
81
82
  }) {
82
83
  const participant = selectedConversation?.participant ?? null;
83
84
  const subtitle = selectedConversation
84
- ? `${selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
85
+ ? `${selectedConversation.isMessageRequest ? "Message request" : selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
85
86
  : "No conversation selected";
86
87
 
87
88
  return (
88
- <section className={dmShellClass}>
89
+ <section aria-label="DM workspace" className={dmShellClass}>
89
90
  <aside className={dmListClass}>
90
91
  {conversations.length === 0 ? (
91
92
  <BirdclawEmpty
@@ -142,9 +143,16 @@ export function DmWorkspace({
142
143
  )}
143
144
  {conversation.needsReply ? "open" : "replied"}
144
145
  </span>
146
+ {conversation.isMessageRequest ? (
147
+ <span className={cx(pillClass, pillAlertClass)}>
148
+ request
149
+ </span>
150
+ ) : null}
145
151
  <span className={cx(pillClass, pillSoftClass)}>
146
- {conversation.influenceScore} ·{" "}
147
- {conversation.influenceLabel}
152
+ {formatCompactNumber(
153
+ conversation.participant.followersCount,
154
+ )}{" "}
155
+ followers
148
156
  </span>
149
157
  </div>
150
158
  </div>
@@ -203,9 +211,11 @@ export function DmWorkspace({
203
211
  <div className={contextStatRowClass}>
204
212
  <dt className={contextStatTermClass}>Reply state</dt>
205
213
  <dd className={contextStatValueClass}>
206
- {selectedConversation.needsReply
207
- ? "Reply open"
208
- : "We replied"}
214
+ {selectedConversation.isMessageRequest
215
+ ? "Message request"
216
+ : selectedConversation.needsReply
217
+ ? "Reply open"
218
+ : "We replied"}
209
219
  </dd>
210
220
  </div>
211
221
  <div className={contextStatRowClass}>
@@ -9,6 +9,7 @@ import {
9
9
  linkPreviewHostClass,
10
10
  linkPreviewTitleClass,
11
11
  } from "#/lib/ui";
12
+ import { safeHttpUrl } from "#/lib/url-safety";
12
13
 
13
14
  type LinkPreviewState = Pick<
14
15
  TweetUrlEntity,
@@ -21,7 +22,6 @@ type LinkPreviewState = Pick<
21
22
  >;
22
23
 
23
24
  const previewCache = new Map<string, Promise<LinkPreviewMetadata | null>>();
24
- const IMAGE_EXTENSION_PATTERN = /\.(?:avif|gif|jpe?g|png|webp)(?:[?#].*)?$/i;
25
25
  const MAX_CONCURRENT_PREVIEW_FETCHES = 2;
26
26
  let activePreviewFetches = 0;
27
27
  const queuedPreviewFetches: Array<() => void> = [];
@@ -72,16 +72,13 @@ function schedulePreviewFetch(task: () => Promise<LinkPreviewMetadata | null>) {
72
72
  }
73
73
 
74
74
  function fetchPreview(entry: TweetUrlEntity) {
75
- const targetUrl = entry.expandedUrl || entry.url;
75
+ const targetUrl = safeHttpUrl(entry.expandedUrl || entry.url);
76
76
  if (!targetUrl) return Promise.resolve(null);
77
77
  const key = `${entry.url} ${targetUrl}`;
78
78
  const cached = previewCache.get(key);
79
79
  if (cached) return cached;
80
80
 
81
81
  const params = new URLSearchParams({ url: targetUrl });
82
- if (entry.url && entry.url !== targetUrl) {
83
- params.set("shortUrl", entry.url);
84
- }
85
82
  const promise = schedulePreviewFetch(() =>
86
83
  fetch(`/api/link-preview?${params.toString()}`)
87
84
  .then(async (response) => {
@@ -109,14 +106,32 @@ function displayHost(url: string, fallback: string) {
109
106
  function isDirectImageUrl(url: string) {
110
107
  try {
111
108
  const parsed = new URL(url);
112
- if (IMAGE_EXTENSION_PATTERN.test(parsed.pathname)) return true;
113
109
  return (
110
+ parsed.protocol === "https:" &&
114
111
  parsed.hostname === "pbs.twimg.com" &&
115
112
  (parsed.pathname.startsWith("/media/") ||
116
113
  parsed.pathname.startsWith("/amplify_video_thumb/"))
117
114
  );
118
115
  } catch {
119
- return IMAGE_EXTENSION_PATTERN.test(url);
116
+ return false;
117
+ }
118
+ }
119
+
120
+ function safePreviewImageUrl(url: string | null | undefined) {
121
+ if (!url) return null;
122
+ try {
123
+ const parsed = new URL(url);
124
+ if (
125
+ parsed.protocol === "https:" &&
126
+ parsed.hostname === "pbs.twimg.com" &&
127
+ (parsed.pathname.startsWith("/media/") ||
128
+ parsed.pathname.startsWith("/amplify_video_thumb/"))
129
+ ) {
130
+ return parsed.toString();
131
+ }
132
+ return null;
133
+ } catch {
134
+ return null;
120
135
  }
121
136
  }
122
137
 
@@ -127,22 +142,25 @@ export function LinkPreviewCard({
127
142
  entry: TweetUrlEntity;
128
143
  index: number;
129
144
  }) {
130
- const targetUrl = entry.expandedUrl || entry.url;
131
- const displayUrl = entry.displayUrl || displayHost(targetUrl, targetUrl);
132
- const directImageUrl = isDirectImageUrl(targetUrl) ? targetUrl : null;
145
+ const targetUrl = safeHttpUrl(entry.expandedUrl || entry.url);
146
+ const previewUrl = targetUrl ?? "";
147
+ const displayUrl =
148
+ entry.displayUrl || (targetUrl ? displayHost(targetUrl, targetUrl) : "");
149
+ const directImageUrl =
150
+ targetUrl && isDirectImageUrl(targetUrl) ? targetUrl : null;
133
151
  const initialPreview = useMemo<LinkPreviewState>(
134
152
  () => ({
135
- expandedUrl: targetUrl,
153
+ expandedUrl: previewUrl,
136
154
  displayUrl,
137
155
  title:
138
156
  entry.title ??
139
- (directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
157
+ (directImageUrl ? displayHost(previewUrl, displayUrl) : undefined),
140
158
  description:
141
159
  entry.description ?? (directImageUrl ? displayUrl : undefined),
142
160
  imageUrl: entry.imageUrl ?? directImageUrl,
143
161
  siteName:
144
162
  entry.siteName ??
145
- (directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
163
+ (directImageUrl ? displayHost(previewUrl, displayUrl) : undefined),
146
164
  }),
147
165
  [
148
166
  directImageUrl,
@@ -151,7 +169,7 @@ export function LinkPreviewCard({
151
169
  entry.imageUrl,
152
170
  entry.siteName,
153
171
  entry.title,
154
- targetUrl,
172
+ previewUrl,
155
173
  ],
156
174
  );
157
175
  const [preview, setPreview] = useState(initialPreview);
@@ -199,7 +217,7 @@ export function LinkPreviewCard({
199
217
  void fetchPreview(entry).then((metadata) => {
200
218
  if (cancelled || !metadata) return;
201
219
  setPreview((current) => ({
202
- expandedUrl: metadata.url || current.expandedUrl,
220
+ expandedUrl: safeHttpUrl(metadata.url) ?? current.expandedUrl,
203
221
  displayUrl: current.displayUrl,
204
222
  title: metadata.title ?? current.title,
205
223
  description: metadata.description ?? current.description,
@@ -214,6 +232,8 @@ export function LinkPreviewCard({
214
232
  };
215
233
  }, [cacheKey, canHydrate, entry, hydratedKey, preview, targetUrl]);
216
234
 
235
+ if (!targetUrl) return null;
236
+
217
237
  const title = preview.title || entry.displayUrl;
218
238
  const description =
219
239
  preview.description && preview.description !== title
@@ -221,14 +241,16 @@ export function LinkPreviewCard({
221
241
  : preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
222
242
  const host =
223
243
  preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
224
- const showImage = Boolean(preview.imageUrl && !imageFailed);
244
+ const imageUrl = safePreviewImageUrl(preview.imageUrl);
245
+ const showImage = Boolean(imageUrl && !imageFailed);
246
+ const previewHref = safeHttpUrl(preview.expandedUrl) ?? targetUrl;
225
247
 
226
248
  return (
227
249
  <a
228
250
  key={`${entry.expandedUrl}-${String(index)}`}
229
251
  className={linkPreviewCardClass}
230
252
  data-perf="link-preview-card"
231
- href={preview.expandedUrl}
253
+ href={previewHref}
232
254
  ref={cardRef}
233
255
  rel="noreferrer"
234
256
  target="_blank"
@@ -255,7 +277,7 @@ export function LinkPreviewCard({
255
277
  className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
256
278
  loading="lazy"
257
279
  onError={() => setImageFailed(true)}
258
- src={preview.imageUrl ?? ""}
280
+ src={imageUrl ?? ""}
259
281
  />
260
282
  ) : (
261
283
  <ImageIcon