birdclaw 0.8.1 → 0.8.3

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 (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
@@ -0,0 +1,191 @@
1
+ import type {
2
+ ProfileAnalysisContext,
3
+ ProfileAnalysisRunResult,
4
+ } from "#/lib/profile-analysis";
5
+ import type { ProfileRecord } from "#/lib/types";
6
+
7
+ export interface ProfileAnalysisRequestOptions {
8
+ refresh: boolean;
9
+ maxTweets: number;
10
+ maxPages: number;
11
+ maxConversations: number;
12
+ maxConversationPages: number;
13
+ }
14
+
15
+ export const DEFAULT_PROFILE_ANALYSIS_LIMITS = {
16
+ maxTweets: 10000,
17
+ maxPages: 100,
18
+ maxConversations: 80,
19
+ maxConversationPages: 3,
20
+ } as const;
21
+
22
+ const PROFILE_HYDRATION_LIMIT = 50;
23
+ const PROFILE_MENTION_RE = /(^|[^\w@./])@([A-Za-z0-9_]{1,15})\b/g;
24
+
25
+ export function normalizeProfileHandle(value: string) {
26
+ return value.trim().replace(/^@/, "").toLowerCase();
27
+ }
28
+
29
+ export function handlesFromText(value: string) {
30
+ return Array.from(value.matchAll(PROFILE_MENTION_RE)).map(
31
+ (match) => match[2],
32
+ );
33
+ }
34
+
35
+ export function knownProfileHandles(context: ProfileAnalysisContext) {
36
+ const handles = new Set<string>();
37
+ handles.add(normalizeProfileHandle(context.profile.handle));
38
+ for (const profile of context.profiles ?? []) {
39
+ handles.add(normalizeProfileHandle(profile.handle));
40
+ }
41
+ for (const tweet of context.conversations) {
42
+ handles.add(normalizeProfileHandle(tweet.author));
43
+ }
44
+ return handles;
45
+ }
46
+
47
+ export function collectProfileAnalysisHydrationHandles({
48
+ context,
49
+ analysis,
50
+ markdown,
51
+ }: {
52
+ context: ProfileAnalysisContext;
53
+ analysis?: ProfileAnalysisRunResult["analysis"];
54
+ markdown?: string;
55
+ }) {
56
+ const handles = new Set<string>();
57
+ const known = knownProfileHandles(context);
58
+ const add = (value: string | undefined) => {
59
+ if (!value) return;
60
+ const handle = normalizeProfileHandle(value);
61
+ if (!/^[a-z0-9_]{1,15}$/.test(handle) || known.has(handle)) return;
62
+ handles.add(handle);
63
+ };
64
+
65
+ for (const handle of analysis?.sourceHandles ?? []) add(handle);
66
+ for (const theme of analysis?.themes ?? []) {
67
+ for (const handle of theme.handles) add(handle);
68
+ }
69
+ if (markdown) {
70
+ for (const handle of handlesFromText(markdown)) add(handle);
71
+ }
72
+ for (const handle of handlesFromText(context.profile.bio)) add(handle);
73
+ for (const tweet of context.tweets) {
74
+ for (const handle of handlesFromText(tweet.text)) add(handle);
75
+ }
76
+ for (const tweet of context.conversations) {
77
+ for (const handle of handlesFromText(tweet.text)) add(handle);
78
+ for (const handle of handlesFromText(tweet.bio)) add(handle);
79
+ }
80
+
81
+ return [...handles].slice(0, PROFILE_HYDRATION_LIMIT);
82
+ }
83
+
84
+ export function applyHydratedProfilesToProfileAnalysisContext(
85
+ context: ProfileAnalysisContext,
86
+ profiles: ProfileRecord[],
87
+ ) {
88
+ const existing = new Map<string, ProfileRecord>();
89
+ for (const profile of context.profiles ?? []) {
90
+ existing.set(normalizeProfileHandle(profile.handle), profile);
91
+ }
92
+ for (const profile of profiles) {
93
+ existing.set(normalizeProfileHandle(profile.handle), profile);
94
+ }
95
+ return {
96
+ ...context,
97
+ profiles: [...existing.values()],
98
+ };
99
+ }
100
+
101
+ export async function hydrateProfileAnalysisContext({
102
+ context,
103
+ analysis,
104
+ markdown,
105
+ requestedHandles,
106
+ }: {
107
+ context: ProfileAnalysisContext;
108
+ analysis?: ProfileAnalysisRunResult["analysis"];
109
+ markdown?: string;
110
+ requestedHandles?: Set<string>;
111
+ }) {
112
+ const handles = collectProfileAnalysisHydrationHandles({
113
+ context,
114
+ analysis,
115
+ markdown,
116
+ }).filter((handle) => !requestedHandles?.has(handle));
117
+ if (handles.length === 0) return context;
118
+ for (const handle of handles) {
119
+ requestedHandles?.add(handle);
120
+ }
121
+ const url = new URL("/api/profile-hydrate", window.location.origin);
122
+ url.searchParams.set("handles", handles.join(","));
123
+ const response = await fetch(url);
124
+ if (!response.ok) return context;
125
+ const payload = (await response.json()) as {
126
+ results?: Array<{ status?: string; profile?: ProfileRecord }>;
127
+ };
128
+ const profiles = (payload.results ?? [])
129
+ .filter((item) => item.status === "hit" && item.profile)
130
+ .map((item) => item.profile as ProfileRecord);
131
+ return profiles.length > 0
132
+ ? applyHydratedProfilesToProfileAnalysisContext(context, profiles)
133
+ : context;
134
+ }
135
+
136
+ export function profileAnalysisUrl(
137
+ handle: string,
138
+ options: ProfileAnalysisRequestOptions,
139
+ ) {
140
+ const params = new URLSearchParams();
141
+ params.set("handle", handle);
142
+ params.set("maxTweets", String(options.maxTweets));
143
+ params.set("maxPages", String(options.maxPages));
144
+ params.set("maxConversations", String(options.maxConversations));
145
+ params.set("maxConversationPages", String(options.maxConversationPages));
146
+ if (options.refresh) {
147
+ params.set("refresh", "true");
148
+ }
149
+ return `/api/profile-analysis?${params.toString()}`;
150
+ }
151
+
152
+ export async function profileAnalysisRequestError(response: Response) {
153
+ const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
154
+ let detail = "";
155
+ try {
156
+ const contentType = response.headers.get("content-type") ?? "";
157
+ if (contentType.includes("application/json")) {
158
+ const payload = (await response.json()) as {
159
+ error?: unknown;
160
+ message?: unknown;
161
+ };
162
+ if (typeof payload.message === "string") detail = payload.message;
163
+ else if (typeof payload.error === "string") detail = payload.error;
164
+ } else {
165
+ detail = (await response.text()).trim();
166
+ }
167
+ } catch {
168
+ detail = "";
169
+ }
170
+ return new Error(
171
+ detail
172
+ ? `Profile analysis failed (${status}): ${detail}`
173
+ : `Profile analysis failed (${status})`,
174
+ );
175
+ }
176
+
177
+ export function formatProfileAnalysisCounts(
178
+ context: ProfileAnalysisContext | null,
179
+ ) {
180
+ if (!context) return "xurl profile backfill with cached AI analysis.";
181
+ return [
182
+ context.fetchCached ? "cached backfill" : "fresh xurl backfill",
183
+ `${String(context.counts.tweets)} tweets`,
184
+ `${String(context.counts.conversationTweets)} conversation tweets`,
185
+ `${String(context.counts.conversationsScanned)} conversations`,
186
+ ].join(" · ");
187
+ }
188
+
189
+ export function cleanProfileHandle(value: string) {
190
+ return value.trim().replace(/^@/, "");
191
+ }
@@ -9,13 +9,22 @@ import type {
9
9
  import type { ProfileRecord } from "#/lib/types";
10
10
  import { errorCopyClass } from "#/lib/ui";
11
11
 
12
- export interface ProfileAnalysisRequestOptions {
13
- refresh: boolean;
14
- maxTweets: number;
15
- maxPages: number;
16
- maxConversations: number;
17
- maxConversationPages: number;
18
- }
12
+ import {
13
+ DEFAULT_PROFILE_ANALYSIS_LIMITS,
14
+ applyHydratedProfilesToProfileAnalysisContext,
15
+ cleanProfileHandle,
16
+ hydrateProfileAnalysisContext,
17
+ normalizeProfileHandle,
18
+ profileAnalysisRequestError,
19
+ profileAnalysisUrl,
20
+ } from "#/components/ProfileAnalysisClient";
21
+ export {
22
+ DEFAULT_PROFILE_ANALYSIS_LIMITS,
23
+ cleanProfileHandle,
24
+ formatProfileAnalysisCounts,
25
+ profileAnalysisRequestError,
26
+ profileAnalysisUrl,
27
+ } from "#/components/ProfileAnalysisClient";
19
28
 
20
29
  export interface ProfileAnalysisState {
21
30
  context: ProfileAnalysisContext | null;
@@ -27,184 +36,6 @@ export interface ProfileAnalysisState {
27
36
  status: string;
28
37
  }
29
38
 
30
- export const DEFAULT_PROFILE_ANALYSIS_LIMITS = {
31
- maxTweets: 10000,
32
- maxPages: 100,
33
- maxConversations: 80,
34
- maxConversationPages: 3,
35
- } as const;
36
-
37
- const PROFILE_HYDRATION_LIMIT = 50;
38
- const PROFILE_MENTION_RE = /(^|[^\w@./])@([A-Za-z0-9_]{1,15})\b/g;
39
-
40
- function normalizeProfileHandle(value: string) {
41
- return value.trim().replace(/^@/, "").toLowerCase();
42
- }
43
-
44
- function handlesFromText(value: string) {
45
- return Array.from(value.matchAll(PROFILE_MENTION_RE)).map(
46
- (match) => match[2],
47
- );
48
- }
49
-
50
- function knownProfileHandles(context: ProfileAnalysisContext) {
51
- const handles = new Set<string>();
52
- handles.add(normalizeProfileHandle(context.profile.handle));
53
- for (const profile of context.profiles ?? []) {
54
- handles.add(normalizeProfileHandle(profile.handle));
55
- }
56
- for (const tweet of context.conversations) {
57
- handles.add(normalizeProfileHandle(tweet.author));
58
- }
59
- return handles;
60
- }
61
-
62
- function collectProfileAnalysisHydrationHandles({
63
- context,
64
- analysis,
65
- markdown,
66
- }: {
67
- context: ProfileAnalysisContext;
68
- analysis?: ProfileAnalysisRunResult["analysis"];
69
- markdown?: string;
70
- }) {
71
- const handles = new Set<string>();
72
- const known = knownProfileHandles(context);
73
- const add = (value: string | undefined) => {
74
- if (!value) return;
75
- const handle = normalizeProfileHandle(value);
76
- if (!/^[a-z0-9_]{1,15}$/.test(handle) || known.has(handle)) return;
77
- handles.add(handle);
78
- };
79
-
80
- for (const handle of analysis?.sourceHandles ?? []) add(handle);
81
- for (const theme of analysis?.themes ?? []) {
82
- for (const handle of theme.handles) add(handle);
83
- }
84
- if (markdown) {
85
- for (const handle of handlesFromText(markdown)) add(handle);
86
- }
87
- for (const handle of handlesFromText(context.profile.bio)) add(handle);
88
- for (const tweet of context.tweets) {
89
- for (const handle of handlesFromText(tweet.text)) add(handle);
90
- }
91
- for (const tweet of context.conversations) {
92
- for (const handle of handlesFromText(tweet.text)) add(handle);
93
- for (const handle of handlesFromText(tweet.bio)) add(handle);
94
- }
95
-
96
- return [...handles].slice(0, PROFILE_HYDRATION_LIMIT);
97
- }
98
-
99
- function applyHydratedProfilesToProfileAnalysisContext(
100
- context: ProfileAnalysisContext,
101
- profiles: ProfileRecord[],
102
- ) {
103
- const existing = new Map<string, ProfileRecord>();
104
- for (const profile of context.profiles ?? []) {
105
- existing.set(normalizeProfileHandle(profile.handle), profile);
106
- }
107
- for (const profile of profiles) {
108
- existing.set(normalizeProfileHandle(profile.handle), profile);
109
- }
110
- return {
111
- ...context,
112
- profiles: [...existing.values()],
113
- };
114
- }
115
-
116
- async function hydrateProfileAnalysisContext({
117
- context,
118
- analysis,
119
- markdown,
120
- requestedHandles,
121
- }: {
122
- context: ProfileAnalysisContext;
123
- analysis?: ProfileAnalysisRunResult["analysis"];
124
- markdown?: string;
125
- requestedHandles?: Set<string>;
126
- }) {
127
- const handles = collectProfileAnalysisHydrationHandles({
128
- context,
129
- analysis,
130
- markdown,
131
- }).filter((handle) => !requestedHandles?.has(handle));
132
- if (handles.length === 0) return context;
133
- for (const handle of handles) {
134
- requestedHandles?.add(handle);
135
- }
136
- const url = new URL("/api/profile-hydrate", window.location.origin);
137
- url.searchParams.set("handles", handles.join(","));
138
- const response = await fetch(url);
139
- if (!response.ok) return context;
140
- const payload = (await response.json()) as {
141
- results?: Array<{ status?: string; profile?: ProfileRecord }>;
142
- };
143
- const profiles = (payload.results ?? [])
144
- .filter((item) => item.status === "hit" && item.profile)
145
- .map((item) => item.profile as ProfileRecord);
146
- return profiles.length > 0
147
- ? applyHydratedProfilesToProfileAnalysisContext(context, profiles)
148
- : context;
149
- }
150
-
151
- export function profileAnalysisUrl(
152
- handle: string,
153
- options: ProfileAnalysisRequestOptions,
154
- ) {
155
- const params = new URLSearchParams();
156
- params.set("handle", handle);
157
- params.set("maxTweets", String(options.maxTweets));
158
- params.set("maxPages", String(options.maxPages));
159
- params.set("maxConversations", String(options.maxConversations));
160
- params.set("maxConversationPages", String(options.maxConversationPages));
161
- if (options.refresh) {
162
- params.set("refresh", "true");
163
- }
164
- return `/api/profile-analysis?${params.toString()}`;
165
- }
166
-
167
- export async function profileAnalysisRequestError(response: Response) {
168
- const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
169
- let detail = "";
170
- try {
171
- const contentType = response.headers.get("content-type") ?? "";
172
- if (contentType.includes("application/json")) {
173
- const payload = (await response.json()) as {
174
- error?: unknown;
175
- message?: unknown;
176
- };
177
- if (typeof payload.message === "string") detail = payload.message;
178
- else if (typeof payload.error === "string") detail = payload.error;
179
- } else {
180
- detail = (await response.text()).trim();
181
- }
182
- } catch {
183
- detail = "";
184
- }
185
- return new Error(
186
- detail
187
- ? `Profile analysis failed (${status}): ${detail}`
188
- : `Profile analysis failed (${status})`,
189
- );
190
- }
191
-
192
- export function formatProfileAnalysisCounts(
193
- context: ProfileAnalysisContext | null,
194
- ) {
195
- if (!context) return "xurl profile backfill with cached AI analysis.";
196
- return [
197
- context.fetchCached ? "cached backfill" : "fresh xurl backfill",
198
- `${String(context.counts.tweets)} tweets`,
199
- `${String(context.counts.conversationTweets)} conversation tweets`,
200
- `${String(context.counts.conversationsScanned)} conversations`,
201
- ].join(" · ");
202
- }
203
-
204
- export function cleanProfileHandle(value: string) {
205
- return value.trim().replace(/^@/, "");
206
- }
207
-
208
39
  export function useProfileAnalysisStream(handle: string): ProfileAnalysisState {
209
40
  const [markdown, setMarkdown] = useState("");
210
41
  const [context, setContext] = useState<ProfileAnalysisContext | null>(null);
@@ -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,8 @@ 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 { useAvatarPreload } from "./AvatarPreload";
23
+ import { useFloatingPreview } from "./FloatingPreview";
47
24
 
48
25
  function ProfilePreviewBio({ profile }: { profile: ProfileRecord }) {
49
26
  const segments = collectTweetSegmentsForText(
@@ -98,74 +75,57 @@ export function ProfilePreview({
98
75
  children: ReactNode;
99
76
  className?: string;
100
77
  }) {
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
- }, []);
78
+ const preview = useFloatingPreview();
79
+ useAvatarPreload(preview.referenceRef, profile.id, profile.avatarUrl);
127
80
 
128
81
  return (
129
82
  <span
130
- ref={shellRef}
131
- className={cx(profilePreviewClass, "group", className)}
132
- onFocus={updatePlacement}
133
- onPointerEnter={updatePlacement}
83
+ ref={preview.referenceRef}
84
+ className={cx(profilePreviewClass, className)}
85
+ {...preview.referenceProps}
134
86
  >
135
87
  <a
88
+ aria-controls={preview.open ? preview.floatingId : undefined}
89
+ aria-expanded={preview.open}
136
90
  className={profilePreviewTriggerClass}
137
91
  href={`/profiles/${encodeURIComponent(profile.handle)}`}
138
92
  >
139
93
  {children}
140
94
  </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}
95
+ {preview.open ? (
96
+ <span
97
+ aria-label={`${profile.displayName} profile preview`}
98
+ id={preview.floatingId}
99
+ ref={preview.floatingRef}
100
+ className={profilePreviewCardClass}
101
+ role="group"
102
+ style={preview.floatingStyle}
103
+ {...preview.floatingProps}
104
+ >
105
+ <span className="grid gap-2" data-floating-preview-content>
106
+ <span className={profilePreviewHeaderClass}>
107
+ <AvatarChip
108
+ avatarUrl={profile.avatarUrl}
109
+ hue={profile.avatarHue}
110
+ name={profile.displayName}
111
+ profileId={profile.id}
112
+ />
113
+ <span className="flex min-w-0 flex-col">
114
+ <span className={profilePreviewNameClass}>
115
+ {profile.displayName}
116
+ </span>
117
+ <span className={profilePreviewHandleClass}>
118
+ @{profile.handle}
119
+ </span>
120
+ </span>
121
+ </span>
122
+ {profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
123
+ <span className={profilePreviewMetaClass}>
124
+ {formatCompactNumber(profile.followersCount)} followers
160
125
  </span>
161
- <span className={profilePreviewHandleClass}>@{profile.handle}</span>
162
126
  </span>
163
127
  </span>
164
- {profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
165
- <span className={profilePreviewMetaClass}>
166
- {formatCompactNumber(profile.followersCount)} followers
167
- </span>
168
- </span>
128
+ ) : null}
169
129
  </span>
170
130
  );
171
131
  }
@@ -9,7 +9,10 @@ import {
9
9
  UserSearch,
10
10
  } from "lucide-react";
11
11
  import { formatCompactNumber } from "#/lib/present";
12
- import { normalizeTweetUrlEntityRangeForText } from "#/lib/tweet-render";
12
+ import {
13
+ isTweetArticleUrlEntity,
14
+ normalizeTweetUrlEntityRangeForText,
15
+ } from "#/lib/tweet-render";
13
16
  import type {
14
17
  TimelineItem,
15
18
  TweetEntities,
@@ -42,6 +45,7 @@ import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
42
45
  import { LinkPreviewCard } from "./LinkPreviewCard";
43
46
  import { ProfilePreview } from "./ProfilePreview";
44
47
  import { SmartTimestamp } from "./SmartTimestamp";
48
+ import { TweetArticleCard } from "./TweetArticleCard";
45
49
  import { TweetMediaGrid } from "./TweetMediaGrid";
46
50
  import { TweetRichText } from "./TweetRichText";
47
51
 
@@ -213,6 +217,9 @@ function getVisibleUrlCards(
213
217
  ) {
214
218
  return (entities.urls ?? []).filter((entry) => {
215
219
  if (isUnresolvedShortUrlEntity(entry)) return false;
220
+ if (entities.article && isTweetArticleUrlEntity(entry, entities.article)) {
221
+ return false;
222
+ }
216
223
  if (!quotedTweetId) return true;
217
224
  return !entry.expandedUrl.includes(quotedTweetId);
218
225
  });
@@ -277,7 +284,10 @@ export function TimelineCard({
277
284
 
278
285
  return (
279
286
  <article
280
- className={cx(feedRowClass, "cursor-pointer")}
287
+ className={cx(
288
+ feedRowClass,
289
+ "cursor-pointer [content-visibility:auto] [contain-intrinsic-size:auto_280px]",
290
+ )}
281
291
  data-perf="timeline-card"
282
292
  onFocus={conversation.prefetch}
283
293
  onMouseEnter={conversation.prefetch}
@@ -363,6 +373,9 @@ export function TimelineCard({
363
373
  text={displayTweet.text}
364
374
  />
365
375
  <TweetMediaGrid items={displayTweet.media} />
376
+ {displayTweet.entities.article ? (
377
+ <TweetArticleCard article={displayTweet.entities.article} />
378
+ ) : null}
366
379
  {visibleUrlCards.map((entry, index) => (
367
380
  <LinkPreviewCard
368
381
  key={`${entry.expandedUrl}-${String(index)}`}
@@ -380,6 +393,9 @@ export function TimelineCard({
380
393
  text={item.text}
381
394
  />
382
395
  <TweetMediaGrid items={item.media} />
396
+ {item.entities.article ? (
397
+ <TweetArticleCard article={item.entities.article} />
398
+ ) : null}
383
399
  {item.replyToTweet ? (
384
400
  <div className={embeddedCardClass}>
385
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
+ }