birdclaw 0.8.0 → 0.8.2

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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -8
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/components/ConversationThread.tsx +9 -4
  5. package/src/components/DmWorkspace.tsx +26 -9
  6. package/src/components/EmbeddedTweetCard.tsx +9 -4
  7. package/src/components/FloatingPreview.tsx +382 -0
  8. package/src/components/InboxCard.tsx +6 -4
  9. package/src/components/MarkdownViewer.tsx +59 -100
  10. package/src/components/ProfilePreview.tsx +39 -81
  11. package/src/components/SmartTimestamp.tsx +72 -0
  12. package/src/components/TimelineCard.tsx +24 -6
  13. package/src/components/TweetArticleCard.tsx +66 -0
  14. package/src/components/TweetRichText.tsx +33 -2
  15. package/src/components/useDebouncedValue.ts +12 -0
  16. package/src/components/useTimelineRouteData.ts +149 -47
  17. package/src/lib/api-client.ts +117 -2
  18. package/src/lib/archive-finder.ts +38 -0
  19. package/src/lib/authored-live.ts +2 -62
  20. package/src/lib/bird.ts +32 -1
  21. package/src/lib/client-cache.ts +109 -0
  22. package/src/lib/db.ts +57 -4
  23. package/src/lib/mention-threads-live.ts +2 -1
  24. package/src/lib/mentions-live.ts +2 -46
  25. package/src/lib/ndjson-stream.ts +106 -0
  26. package/src/lib/period-digest.ts +184 -24
  27. package/src/lib/present.ts +99 -1
  28. package/src/lib/profile-analysis.ts +1 -1
  29. package/src/lib/queries.ts +68 -6
  30. package/src/lib/sqlite.ts +5 -2
  31. package/src/lib/timeline-collections-live.ts +2 -1
  32. package/src/lib/timeline-live.ts +2 -1
  33. package/src/lib/tweet-render.ts +78 -0
  34. package/src/lib/tweet-search-live.ts +2 -1
  35. package/src/lib/types.ts +8 -0
  36. package/src/lib/ui.ts +1 -1
  37. package/src/router.tsx +1 -1
  38. package/src/routes/__root.tsx +0 -13
  39. package/src/routes/api/period-digest.tsx +21 -69
  40. package/src/routes/api/profile-analysis.tsx +22 -77
  41. package/src/routes/api/search-discussion.tsx +19 -75
  42. package/src/routes/api/status.tsx +3 -1
  43. package/src/routes/dms.tsx +40 -22
  44. package/src/routes/links.tsx +75 -9
  45. package/src/routes/today.tsx +67 -11
  46. package/vite.config.ts +0 -2
@@ -1,12 +1,5 @@
1
- import {
2
- Fragment,
3
- type MouseEventHandler,
4
- type ReactNode,
5
- useLayoutEffect,
6
- useRef,
7
- useState,
8
- } from "react";
9
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
1
+ import { Fragment, type MouseEventHandler, type ReactNode } from "react";
2
+ import { formatCompactNumber } from "#/lib/present";
10
3
  import type { PeriodDigestContext } from "#/lib/period-digest";
11
4
  import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
12
5
  import { renderTweetPlainText } from "#/lib/tweet-render";
@@ -14,34 +7,17 @@ import type { ProfileRecord } from "#/lib/types";
14
7
  import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
15
8
  import { safeHttpUrl } from "#/lib/url-safety";
16
9
  import { AvatarChip } from "./AvatarChip";
10
+ import { useFloatingPreview } from "./FloatingPreview";
17
11
  import { ProfilePreview } from "./ProfilePreview";
12
+ import { SmartTimestamp } from "./SmartTimestamp";
18
13
 
19
14
  type CitationTweet = PeriodDigestContext["tweets"][number];
20
15
  type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
21
- type VerticalBounds = { top: number; bottom: number };
22
-
23
16
  type InlineLookup = {
24
17
  tweetsById: Map<string, CitationTweet>;
25
18
  profilesByHandle: Map<string, ProfileRecord>;
26
19
  };
27
20
 
28
- function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
29
- let top = 0;
30
- let bottom = window.innerHeight;
31
- for (
32
- let current = element.parentElement;
33
- current;
34
- current = current.parentElement
35
- ) {
36
- const style = window.getComputedStyle(current);
37
- if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
38
- const rect = current.getBoundingClientRect();
39
- top = Math.max(top, rect.top);
40
- bottom = Math.min(bottom, rect.bottom);
41
- }
42
- return { top, bottom };
43
- }
44
-
45
21
  function normalizeTweetReference(value: string) {
46
22
  return value
47
23
  .trim()
@@ -134,13 +110,16 @@ function TweetSourceLink({
134
110
  children,
135
111
  href,
136
112
  onClick,
113
+ describedBy,
137
114
  }: {
138
115
  children: ReactNode;
139
116
  href: string;
140
117
  onClick?: MouseEventHandler<HTMLAnchorElement>;
118
+ describedBy?: string;
141
119
  }) {
142
120
  return (
143
121
  <a
122
+ aria-describedby={describedBy}
144
123
  className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
145
124
  href={href}
146
125
  onClick={onClick}
@@ -159,93 +138,73 @@ function TweetPreviewToken({
159
138
  tweet: CitationTweet;
160
139
  children: ReactNode;
161
140
  }) {
162
- const [open, setOpen] = useState(false);
163
- const [placeAbove, setPlaceAbove] = useState(false);
164
- const shellRef = useRef<HTMLSpanElement | null>(null);
165
- const cardRef = useRef<HTMLSpanElement | null>(null);
166
-
167
- useLayoutEffect(() => {
168
- if (!open) return;
169
- const updatePlacement = () => {
170
- const shell = shellRef.current;
171
- if (!shell) return;
172
- const shellRect = shell.getBoundingClientRect();
173
- const card = cardRef.current;
174
- const cardRect = card?.getBoundingClientRect();
175
- const cardHeight = Math.max(
176
- card?.offsetHeight ?? 0,
177
- cardRect?.height ?? 0,
178
- 220,
179
- );
180
- const bounds = nearestVerticalClipBounds(shell);
181
- const belowSpace = bounds.bottom - shellRect.bottom;
182
- const aboveSpace = shellRect.top - bounds.top;
183
- setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
184
- };
185
- updatePlacement();
186
- const frame = window.requestAnimationFrame(updatePlacement);
187
- return () => window.cancelAnimationFrame(frame);
188
- }, [open]);
189
-
190
- function closePreview() {
191
- setOpen(false);
192
- }
193
-
194
- const previewText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
141
+ const preview = useFloatingPreview();
142
+
143
+ const article = tweet.entities?.article;
144
+ const renderedText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
145
+ const previewText = article
146
+ ? [article.title, article.previewText]
147
+ .filter(
148
+ (value, index, values): value is string =>
149
+ Boolean(value) && values.indexOf(value) === index,
150
+ )
151
+ .join("\n\n")
152
+ : renderedText;
195
153
 
196
154
  return (
197
155
  <span
198
- ref={shellRef}
199
- className="relative inline align-baseline"
200
- onBlur={closePreview}
201
- onFocus={() => setOpen(true)}
202
- onPointerEnter={() => setOpen(true)}
203
- onPointerLeave={closePreview}
156
+ ref={preview.referenceRef}
157
+ className="inline align-baseline"
158
+ {...preview.referenceProps}
204
159
  >
205
160
  <TweetSourceLink
161
+ describedBy={preview.open ? preview.floatingId : undefined}
206
162
  href={getTweetUrl(tweet)}
207
163
  onClick={(event) => {
208
- closePreview();
164
+ preview.closePreview();
209
165
  event.currentTarget.blur();
210
166
  }}
211
167
  >
212
168
  {children}
213
169
  </TweetSourceLink>
214
- <span
215
- ref={cardRef}
216
- aria-hidden={!open}
217
- className={cx(
218
- "absolute left-1/2 z-40 w-[360px] -translate-x-1/2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]",
219
- placeAbove ? "bottom-[calc(100%+10px)]" : "top-[calc(100%+10px)]",
220
- open ? "block" : "hidden",
221
- )}
222
- >
223
- <span className="mb-2 flex items-center gap-2">
224
- <AvatarChip
225
- avatarUrl={tweet.authorProfile.avatarUrl}
226
- hue={tweet.authorProfile.avatarHue}
227
- name={tweet.name}
228
- profileId={tweet.authorProfile.id}
229
- size="small"
230
- />
231
- <span className="min-w-0">
232
- <span className="block truncate font-bold">{tweet.name}</span>
233
- <span className="block truncate text-[12px] text-[var(--ink-soft)]">
234
- @{tweet.author} · {formatShortTimestamp(tweet.createdAt)}
170
+ {preview.open ? (
171
+ <span
172
+ id={preview.floatingId}
173
+ ref={preview.floatingRef}
174
+ className="fixed z-40 w-[360px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]"
175
+ role="tooltip"
176
+ style={preview.floatingStyle}
177
+ {...preview.floatingProps}
178
+ >
179
+ <span className="block" data-floating-preview-content>
180
+ <span className="mb-2 flex items-center gap-2">
181
+ <AvatarChip
182
+ avatarUrl={tweet.authorProfile.avatarUrl}
183
+ hue={tweet.authorProfile.avatarHue}
184
+ name={tweet.name}
185
+ profileId={tweet.authorProfile.id}
186
+ size="small"
187
+ />
188
+ <span className="min-w-0">
189
+ <span className="block truncate font-bold">{tweet.name}</span>
190
+ <span className="block truncate text-[12px] text-[var(--ink-soft)]">
191
+ @{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
192
+ </span>
193
+ </span>
194
+ </span>
195
+ <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
196
+ {previewText}
197
+ </span>
198
+ <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
199
+ <span>{tweet.source}</span>
200
+ {tweet.likeCount > 0 ? (
201
+ <span>{formatCompactNumber(tweet.likeCount)} likes</span>
202
+ ) : null}
203
+ {tweet.needsReply ? <span>reply open</span> : null}
235
204
  </span>
236
205
  </span>
237
206
  </span>
238
- <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
239
- {previewText}
240
- </span>
241
- <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
242
- <span>{tweet.source}</span>
243
- {tweet.likeCount > 0 ? (
244
- <span>{formatCompactNumber(tweet.likeCount)} likes</span>
245
- ) : null}
246
- {tweet.needsReply ? <span>reply open</span> : null}
247
- </span>
248
- </span>
207
+ ) : null}
249
208
  </span>
250
209
  );
251
210
  }
@@ -1,10 +1,4 @@
1
- import {
2
- Fragment,
3
- type ReactNode,
4
- useLayoutEffect,
5
- useRef,
6
- useState,
7
- } from "react";
1
+ import { Fragment, type ReactNode } from "react";
8
2
  import { formatCompactNumber } from "#/lib/present";
9
3
  import {
10
4
  collectTweetSegmentsForText,
@@ -25,25 +19,7 @@ import {
25
19
  } from "#/lib/ui";
26
20
  import { safeHttpUrl } from "#/lib/url-safety";
27
21
  import { AvatarChip } from "./AvatarChip";
28
-
29
- type VerticalBounds = { top: number; bottom: number };
30
-
31
- function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
32
- let top = 0;
33
- let bottom = window.innerHeight;
34
- for (
35
- let current = element.parentElement;
36
- current;
37
- current = current.parentElement
38
- ) {
39
- const style = window.getComputedStyle(current);
40
- if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
41
- const rect = current.getBoundingClientRect();
42
- top = Math.max(top, rect.top);
43
- bottom = Math.min(bottom, rect.bottom);
44
- }
45
- return { top, bottom };
46
- }
22
+ import { useFloatingPreview } from "./FloatingPreview";
47
23
 
48
24
  function ProfilePreviewBio({ profile }: { profile: ProfileRecord }) {
49
25
  const segments = collectTweetSegmentsForText(
@@ -98,74 +74,56 @@ export function ProfilePreview({
98
74
  children: ReactNode;
99
75
  className?: string;
100
76
  }) {
101
- const [placeAbove, setPlaceAbove] = useState(false);
102
- const shellRef = useRef<HTMLSpanElement | null>(null);
103
- const cardRef = useRef<HTMLSpanElement | null>(null);
104
-
105
- function updatePlacement() {
106
- const shell = shellRef.current;
107
- if (!shell) return;
108
- const shellRect = shell.getBoundingClientRect();
109
- const card = cardRef.current;
110
- const cardRect = card?.getBoundingClientRect();
111
- const cardHeight = Math.max(
112
- card?.offsetHeight ?? 0,
113
- cardRect?.height ?? 0,
114
- 180,
115
- );
116
- const bounds = nearestVerticalClipBounds(shell);
117
- const belowSpace = bounds.bottom - shellRect.bottom;
118
- const aboveSpace = shellRect.top - bounds.top;
119
- setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
120
- }
121
-
122
- useLayoutEffect(() => {
123
- updatePlacement();
124
- const frame = window.requestAnimationFrame(updatePlacement);
125
- return () => window.cancelAnimationFrame(frame);
126
- }, []);
77
+ const preview = useFloatingPreview();
127
78
 
128
79
  return (
129
80
  <span
130
- ref={shellRef}
131
- className={cx(profilePreviewClass, "group", className)}
132
- onFocus={updatePlacement}
133
- onPointerEnter={updatePlacement}
81
+ ref={preview.referenceRef}
82
+ className={cx(profilePreviewClass, className)}
83
+ {...preview.referenceProps}
134
84
  >
135
85
  <a
86
+ aria-controls={preview.open ? preview.floatingId : undefined}
87
+ aria-expanded={preview.open}
136
88
  className={profilePreviewTriggerClass}
137
89
  href={`/profiles/${encodeURIComponent(profile.handle)}`}
138
90
  >
139
91
  {children}
140
92
  </a>
141
- <span
142
- ref={cardRef}
143
- className={cx(
144
- profilePreviewCardClass,
145
- placeAbove
146
- ? "bottom-[calc(100%+8px)] -translate-y-1 group-hover:translate-y-0 group-focus-within:translate-y-0"
147
- : "top-[calc(100%+8px)] translate-y-1 group-hover:translate-y-0 group-focus-within:translate-y-0",
148
- )}
149
- >
150
- <span className={profilePreviewHeaderClass}>
151
- <AvatarChip
152
- avatarUrl={profile.avatarUrl}
153
- hue={profile.avatarHue}
154
- name={profile.displayName}
155
- profileId={profile.id}
156
- />
157
- <span className="flex min-w-0 flex-col">
158
- <span className={profilePreviewNameClass}>
159
- {profile.displayName}
93
+ {preview.open ? (
94
+ <span
95
+ aria-label={`${profile.displayName} profile preview`}
96
+ id={preview.floatingId}
97
+ ref={preview.floatingRef}
98
+ className={profilePreviewCardClass}
99
+ role="group"
100
+ style={preview.floatingStyle}
101
+ {...preview.floatingProps}
102
+ >
103
+ <span className="grid gap-2" data-floating-preview-content>
104
+ <span className={profilePreviewHeaderClass}>
105
+ <AvatarChip
106
+ avatarUrl={profile.avatarUrl}
107
+ hue={profile.avatarHue}
108
+ name={profile.displayName}
109
+ profileId={profile.id}
110
+ />
111
+ <span className="flex min-w-0 flex-col">
112
+ <span className={profilePreviewNameClass}>
113
+ {profile.displayName}
114
+ </span>
115
+ <span className={profilePreviewHandleClass}>
116
+ @{profile.handle}
117
+ </span>
118
+ </span>
119
+ </span>
120
+ {profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
121
+ <span className={profilePreviewMetaClass}>
122
+ {formatCompactNumber(profile.followersCount)} followers
160
123
  </span>
161
- <span className={profilePreviewHandleClass}>@{profile.handle}</span>
162
124
  </span>
163
125
  </span>
164
- {profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
165
- <span className={profilePreviewMetaClass}>
166
- {formatCompactNumber(profile.followersCount)} followers
167
- </span>
168
- </span>
126
+ ) : null}
169
127
  </span>
170
128
  );
171
129
  }
@@ -0,0 +1,72 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import {
3
+ formatExactTimestamp,
4
+ formatShortTimestamp,
5
+ formatSmartTimestamp,
6
+ } from "#/lib/present";
7
+
8
+ const CLOCK_INTERVAL_MS = 30_000;
9
+
10
+ type ClockSnapshot = number | null;
11
+
12
+ const listeners = new Set<() => void>();
13
+ let currentNow = Date.now();
14
+ let clockInterval: ReturnType<typeof setInterval> | null = null;
15
+
16
+ function updateClock() {
17
+ currentNow = Date.now();
18
+ for (const listener of listeners) listener();
19
+ }
20
+
21
+ function subscribeToClock(listener: () => void) {
22
+ listeners.add(listener);
23
+
24
+ if (clockInterval === null) {
25
+ currentNow = Date.now();
26
+ clockInterval = setInterval(updateClock, CLOCK_INTERVAL_MS);
27
+ }
28
+
29
+ return () => {
30
+ listeners.delete(listener);
31
+ if (listeners.size === 0 && clockInterval !== null) {
32
+ clearInterval(clockInterval);
33
+ clockInterval = null;
34
+ }
35
+ };
36
+ }
37
+
38
+ function getClockSnapshot(): ClockSnapshot {
39
+ return currentNow;
40
+ }
41
+
42
+ function getServerClockSnapshot(): ClockSnapshot {
43
+ return null;
44
+ }
45
+
46
+ export function SmartTimestamp({
47
+ className,
48
+ value,
49
+ }: {
50
+ className?: string;
51
+ value: string;
52
+ }) {
53
+ const now = useSyncExternalStore(
54
+ subscribeToClock,
55
+ getClockSnapshot,
56
+ getServerClockSnapshot,
57
+ );
58
+ const exactTimestamp = formatExactTimestamp(value);
59
+
60
+ return (
61
+ <time
62
+ aria-label={exactTimestamp}
63
+ className={className}
64
+ dateTime={value}
65
+ title={exactTimestamp}
66
+ >
67
+ {now === null
68
+ ? formatShortTimestamp(value)
69
+ : formatSmartTimestamp(value, now)}
70
+ </time>
71
+ );
72
+ }
@@ -8,8 +8,11 @@ import {
8
8
  Repeat2,
9
9
  UserSearch,
10
10
  } from "lucide-react";
11
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
12
- import { normalizeTweetUrlEntityRangeForText } from "#/lib/tweet-render";
11
+ import { formatCompactNumber } from "#/lib/present";
12
+ import {
13
+ isTweetArticleUrlEntity,
14
+ normalizeTweetUrlEntityRangeForText,
15
+ } from "#/lib/tweet-render";
13
16
  import type {
14
17
  TimelineItem,
15
18
  TweetEntities,
@@ -41,6 +44,8 @@ import { ConversationThread } from "./ConversationThread";
41
44
  import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
42
45
  import { LinkPreviewCard } from "./LinkPreviewCard";
43
46
  import { ProfilePreview } from "./ProfilePreview";
47
+ import { SmartTimestamp } from "./SmartTimestamp";
48
+ import { TweetArticleCard } from "./TweetArticleCard";
44
49
  import { TweetMediaGrid } from "./TweetMediaGrid";
45
50
  import { TweetRichText } from "./TweetRichText";
46
51
 
@@ -212,6 +217,9 @@ function getVisibleUrlCards(
212
217
  ) {
213
218
  return (entities.urls ?? []).filter((entry) => {
214
219
  if (isUnresolvedShortUrlEntity(entry)) return false;
220
+ if (entities.article && isTweetArticleUrlEntity(entry, entities.article)) {
221
+ return false;
222
+ }
215
223
  if (!quotedTweetId) return true;
216
224
  return !entry.expandedUrl.includes(quotedTweetId);
217
225
  });
@@ -276,7 +284,10 @@ export function TimelineCard({
276
284
 
277
285
  return (
278
286
  <article
279
- className={cx(feedRowClass, "cursor-pointer")}
287
+ className={cx(
288
+ feedRowClass,
289
+ "cursor-pointer [content-visibility:auto] [contain-intrinsic-size:auto_280px]",
290
+ )}
280
291
  data-perf="timeline-card"
281
292
  onFocus={conversation.prefetch}
282
293
  onMouseEnter={conversation.prefetch}
@@ -312,9 +323,10 @@ export function TimelineCard({
312
323
  </span>
313
324
  </ProfilePreview>
314
325
  <span className={feedRowDotClass}>·</span>
315
- <span className={feedRowTimestampClass}>
316
- {formatShortTimestamp(displayTweet.createdAt)}
317
- </span>
326
+ <SmartTimestamp
327
+ className={feedRowTimestampClass}
328
+ value={displayTweet.createdAt}
329
+ />
318
330
  {canReply || hasConversation ? (
319
331
  <span className="ml-auto inline-flex items-center gap-1">
320
332
  {hasConversation ? (
@@ -361,6 +373,9 @@ export function TimelineCard({
361
373
  text={displayTweet.text}
362
374
  />
363
375
  <TweetMediaGrid items={displayTweet.media} />
376
+ {displayTweet.entities.article ? (
377
+ <TweetArticleCard article={displayTweet.entities.article} />
378
+ ) : null}
364
379
  {visibleUrlCards.map((entry, index) => (
365
380
  <LinkPreviewCard
366
381
  key={`${entry.expandedUrl}-${String(index)}`}
@@ -378,6 +393,9 @@ export function TimelineCard({
378
393
  text={item.text}
379
394
  />
380
395
  <TweetMediaGrid items={item.media} />
396
+ {item.entities.article ? (
397
+ <TweetArticleCard article={item.entities.article} />
398
+ ) : null}
381
399
  {item.replyToTweet ? (
382
400
  <div className={embeddedCardClass}>
383
401
  <EmbeddedTweetCard
@@ -0,0 +1,66 @@
1
+ import { BookOpen, ExternalLink } from "lucide-react";
2
+ import type { TweetArticle } from "#/lib/types";
3
+ import {
4
+ linkPreviewCardClass,
5
+ linkPreviewDescClass,
6
+ linkPreviewHostClass,
7
+ linkPreviewTitleClass,
8
+ } from "#/lib/ui";
9
+ import { safeHttpUrl } from "#/lib/url-safety";
10
+
11
+ function safeArticleImageUrl(value: string | undefined) {
12
+ const url = safeHttpUrl(value);
13
+ if (!url) return null;
14
+ try {
15
+ return new URL(url).hostname === "pbs.twimg.com" ? url : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function TweetArticleCard({ article }: { article: TweetArticle }) {
22
+ const href = safeHttpUrl(article.url);
23
+ if (!href) return null;
24
+ const imageUrl = safeArticleImageUrl(article.coverImageUrl);
25
+
26
+ return (
27
+ <a
28
+ aria-label={`Read article: ${article.title}`}
29
+ className={linkPreviewCardClass}
30
+ data-perf="tweet-article-card"
31
+ href={href}
32
+ rel="noreferrer"
33
+ target="_blank"
34
+ >
35
+ <div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
36
+ <div className="flex min-w-0 items-center gap-2">
37
+ <BookOpen
38
+ aria-hidden="true"
39
+ className="size-3.5 shrink-0 text-[var(--ink-soft)]"
40
+ strokeWidth={1.8}
41
+ />
42
+ <span className={linkPreviewHostClass}>Article on X</span>
43
+ <ExternalLink
44
+ aria-hidden="true"
45
+ className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
46
+ strokeWidth={1.8}
47
+ />
48
+ </div>
49
+ <span className={linkPreviewTitleClass}>{article.title}</span>
50
+ {article.previewText ? (
51
+ <span className={linkPreviewDescClass}>{article.previewText}</span>
52
+ ) : null}
53
+ </div>
54
+ {imageUrl ? (
55
+ <div className="flex aspect-[1.45] w-40 shrink-0 overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
56
+ <img
57
+ alt=""
58
+ className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
59
+ loading="lazy"
60
+ src={imageUrl}
61
+ />
62
+ </div>
63
+ ) : null}
64
+ </a>
65
+ );
66
+ }
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
3
3
  import {
4
4
  collectTweetSegmentsForText,
5
5
  enrichFallbackUrlEntities,
6
+ isTweetArticleUrlEntity,
6
7
  normalizeTweetUrlEntityRangeForText,
7
8
  } from "#/lib/tweet-render";
8
9
  import type { TweetEntities } from "#/lib/types";
@@ -19,6 +20,14 @@ function rangeKey(range: { start: number; end: number }) {
19
20
  return `${range.start}:${range.end}`;
20
21
  }
21
22
 
23
+ function isShortUrl(value: string) {
24
+ try {
25
+ return new URL(value).hostname.replace(/^www\./, "") === "t.co";
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
22
31
  export function TweetRichText({
23
32
  text,
24
33
  entities,
@@ -37,6 +46,25 @@ export function TweetRichText({
37
46
  const richEntities = enrichFallbackUrlEntities(text, entities);
38
47
  const segments = collectTweetSegmentsForText(text, richEntities);
39
48
  const hiddenRawRangeKeys = new Set(hiddenUrlRanges.map(rangeKey));
49
+ const article = entities.article;
50
+ if (article) {
51
+ const urlEntries = richEntities.urls ?? [];
52
+ const articleUrlEntries = urlEntries.filter((entry) =>
53
+ isTweetArticleUrlEntity(entry, article),
54
+ );
55
+ const onlyUrlEntry = urlEntries[0];
56
+ if (
57
+ articleUrlEntries.length === 0 &&
58
+ urlEntries.length === 1 &&
59
+ onlyUrlEntry &&
60
+ (isShortUrl(onlyUrlEntry.url) || isShortUrl(onlyUrlEntry.expandedUrl))
61
+ ) {
62
+ articleUrlEntries.push(onlyUrlEntry);
63
+ }
64
+ for (const entry of articleUrlEntries) {
65
+ hiddenRawRangeKeys.add(rangeKey(entry));
66
+ }
67
+ }
40
68
  const hiddenRangeKeys = new Set(hiddenRawRangeKeys);
41
69
  for (const entry of richEntities.urls ?? []) {
42
70
  if (!hiddenRawRangeKeys.has(rangeKey(entry))) continue;
@@ -46,10 +74,13 @@ export function TweetRichText({
46
74
  }
47
75
  const Wrapper = as;
48
76
  let cursor = 0;
77
+ const hideArticleTitle =
78
+ entities.article && text.trim() === entities.article.title.trim();
79
+ const visibleSegments = hideArticleTitle ? [] : segments;
49
80
 
50
81
  return (
51
82
  <Wrapper className={className === "body-copy" ? bodyCopyClass : className}>
52
- {segments.map((segment, index) => {
83
+ {visibleSegments.map((segment, index) => {
53
84
  if (
54
85
  segment.start < cursor ||
55
86
  segment.end <= segment.start ||
@@ -122,7 +153,7 @@ export function TweetRichText({
122
153
  </Fragment>
123
154
  );
124
155
  })}
125
- {text.slice(cursor)}
156
+ {hideArticleTitle ? null : text.slice(cursor)}
126
157
  </Wrapper>
127
158
  );
128
159
  }
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebouncedValue<T>(value: T, delayMs: number) {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timeout = window.setTimeout(() => setDebouncedValue(value), delayMs);
8
+ return () => window.clearTimeout(timeout);
9
+ }, [delayMs, value]);
10
+
11
+ return debouncedValue;
12
+ }