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
@@ -6,13 +6,30 @@ import type {
6
6
  TimelineItem,
7
7
  } from "#/lib/types";
8
8
  import {
9
+ fetchCachedQueryResponse,
9
10
  fetchQueryEnvelope,
10
- fetchQueryResponse,
11
+ invalidateCachedQueryResponse,
12
+ invalidateCachedQueryResponses,
11
13
  postAction,
14
+ readCachedQueryEnvelope,
12
15
  } from "#/lib/api-client";
16
+ import {
17
+ deleteClientCache,
18
+ deleteClientCacheByPrefix,
19
+ readClientCache,
20
+ writeClientCache,
21
+ } from "#/lib/client-cache";
13
22
  import { useSelectedAccountId } from "./account-selection";
23
+ import { useDebouncedValue } from "./useDebouncedValue";
14
24
 
15
25
  const PAGE_SIZE = 50;
26
+ const TIMELINE_VIEW_CACHE_PREFIX = "timeline-view:";
27
+ const TIMELINE_VIEW_CACHE_MAX_AGE_MS = 5 * 60_000;
28
+
29
+ interface TimelineViewSnapshot {
30
+ items: TimelineItem[];
31
+ hasMore: boolean;
32
+ }
16
33
 
17
34
  interface UseTimelineRouteDataOptions {
18
35
  resource: Exclude<ResourceKind, "dms">;
@@ -23,6 +40,53 @@ interface UseTimelineRouteDataOptions {
23
40
  bookmarkedOnly?: boolean;
24
41
  }
25
42
 
43
+ function buildTimelineQueryUrl({
44
+ resource,
45
+ search,
46
+ replyFilter,
47
+ likedOnly,
48
+ bookmarkedOnly,
49
+ selectedAccountId,
50
+ refreshTick,
51
+ until,
52
+ untilId,
53
+ }: {
54
+ resource: Exclude<ResourceKind, "dms">;
55
+ search: string;
56
+ replyFilter?: ReplyFilter;
57
+ likedOnly: boolean;
58
+ bookmarkedOnly: boolean;
59
+ selectedAccountId?: string;
60
+ refreshTick: number;
61
+ until?: string;
62
+ untilId?: string;
63
+ }) {
64
+ const params = new URLSearchParams({
65
+ resource,
66
+ limit: String(PAGE_SIZE),
67
+ });
68
+ if (selectedAccountId) params.set("account", selectedAccountId);
69
+ params.set("refresh", String(refreshTick));
70
+ if (replyFilter) params.set("replyFilter", replyFilter);
71
+ if (likedOnly) params.set("liked", "true");
72
+ if (bookmarkedOnly) params.set("bookmarked", "true");
73
+ if (search.trim()) params.set("search", search.trim());
74
+ if (until) params.set("until", until);
75
+ if (untilId) params.set("untilId", untilId);
76
+ params.sort();
77
+ const base =
78
+ typeof window === "undefined"
79
+ ? "http://birdclaw.local"
80
+ : window.location.origin;
81
+ return new URL(`/api/query?${params.toString()}`, base).toString();
82
+ }
83
+
84
+ function timelineViewCacheKey(requestUrl: string) {
85
+ const url = new URL(requestUrl, "http://birdclaw.local");
86
+ url.searchParams.delete("refresh");
87
+ return `${TIMELINE_VIEW_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
88
+ }
89
+
26
90
  export function useTimelineRouteData({
27
91
  resource,
28
92
  search,
@@ -31,75 +95,98 @@ export function useTimelineRouteData({
31
95
  likedOnly = false,
32
96
  bookmarkedOnly = false,
33
97
  }: UseTimelineRouteDataOptions) {
34
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
35
- const [items, setItems] = useState<TimelineItem[]>([]);
36
- const [loading, setLoading] = useState(true);
98
+ const [meta, setMeta] = useState<QueryEnvelope | null>(
99
+ () => readCachedQueryEnvelope() ?? null,
100
+ );
101
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
102
+ const debouncedSearch = useDebouncedValue(search, 180);
103
+ const initialRequestUrl = buildTimelineQueryUrl({
104
+ resource,
105
+ search: debouncedSearch,
106
+ replyFilter,
107
+ likedOnly,
108
+ bookmarkedOnly,
109
+ selectedAccountId,
110
+ refreshTick: 0,
111
+ });
112
+ const initialSnapshot = useRef(
113
+ readClientCache<TimelineViewSnapshot>(
114
+ timelineViewCacheKey(initialRequestUrl),
115
+ TIMELINE_VIEW_CACHE_MAX_AGE_MS,
116
+ ),
117
+ );
118
+ const [items, setItems] = useState<TimelineItem[]>(
119
+ () => initialSnapshot.current?.items ?? [],
120
+ );
121
+ const [loading, setLoading] = useState(() => !initialSnapshot.current);
37
122
  const [error, setError] = useState<string | null>(null);
38
123
  const [replyError, setReplyError] = useState<string | null>(null);
39
124
  const [refreshTick, setRefreshTick] = useState(0);
40
- const [hasMore, setHasMore] = useState(false);
125
+ const [hasMore, setHasMore] = useState(
126
+ () => initialSnapshot.current?.hasMore ?? false,
127
+ );
41
128
  const [loadingMore, setLoadingMore] = useState(false);
42
- const selectedAccountId = useSelectedAccountId(meta?.accounts);
43
129
  // Bumped whenever the active filters change. A `loadMore` request that
44
130
  // resolves against a stale generation is discarded so its older page is
45
131
  // never appended to a freshly loaded feed.
46
132
  const generationRef = useRef(0);
47
133
  const loadMoreControllerRef = useRef<AbortController | null>(null);
134
+ const activeRequestUrlRef = useRef(initialRequestUrl);
48
135
 
49
- async function loadStatus() {
50
- setMeta(await fetchQueryEnvelope());
136
+ async function loadStatus(force = false) {
137
+ setMeta(await fetchQueryEnvelope(undefined, { force }));
51
138
  }
52
139
 
53
140
  useEffect(() => {
54
141
  void loadStatus();
55
142
  }, []);
56
143
 
57
- // Build the /api/query URL for the current filters. Passing the last item's
58
- // (createdAt, id) requests the next (older) page via the server's keyset
59
- // cursor, which is deterministic across duplicate timestamps.
60
- function buildQueryUrl(until?: string, untilId?: string) {
61
- const url = new URL("/api/query", window.location.origin);
62
- url.searchParams.set("resource", resource);
63
- url.searchParams.set("refresh", String(refreshTick));
64
- url.searchParams.set("limit", String(PAGE_SIZE));
65
- if (selectedAccountId) {
66
- url.searchParams.set("account", selectedAccountId);
67
- }
68
- if (replyFilter) {
69
- url.searchParams.set("replyFilter", replyFilter);
70
- }
71
- if (likedOnly) {
72
- url.searchParams.set("liked", "true");
73
- }
74
- if (bookmarkedOnly) {
75
- url.searchParams.set("bookmarked", "true");
76
- }
77
- if (search.trim()) {
78
- url.searchParams.set("search", search.trim());
79
- }
80
- if (until) {
81
- url.searchParams.set("until", until);
82
- }
83
- if (untilId) {
84
- url.searchParams.set("untilId", untilId);
85
- }
86
- return url;
87
- }
88
-
89
144
  useEffect(() => {
90
145
  generationRef.current += 1;
91
146
  loadMoreControllerRef.current?.abort();
92
147
  const controller = new AbortController();
93
148
  let active = true;
149
+ const requestUrl = buildTimelineQueryUrl({
150
+ resource,
151
+ search: debouncedSearch,
152
+ replyFilter,
153
+ likedOnly,
154
+ bookmarkedOnly,
155
+ selectedAccountId,
156
+ refreshTick,
157
+ });
158
+ const viewCacheKey = timelineViewCacheKey(requestUrl);
159
+ activeRequestUrlRef.current = requestUrl;
94
160
  setError(null);
95
- setLoading(true);
96
161
  setLoadingMore(false);
97
- fetchQueryResponse(buildQueryUrl(), { signal: controller.signal })
162
+ const cached = readClientCache<TimelineViewSnapshot>(
163
+ viewCacheKey,
164
+ TIMELINE_VIEW_CACHE_MAX_AGE_MS,
165
+ );
166
+ if (cached) {
167
+ setItems(cached.items);
168
+ setHasMore(cached.hasMore);
169
+ setLoading(false);
170
+ return () => {
171
+ active = false;
172
+ controller.abort();
173
+ };
174
+ }
175
+
176
+ setItems([]);
177
+ setHasMore(false);
178
+ setLoading(true);
179
+ fetchCachedQueryResponse(requestUrl, { signal: controller.signal })
98
180
  .then((data) => {
99
181
  if (!active) return;
100
182
  const next = data.items as TimelineItem[];
101
183
  setItems(next);
102
- setHasMore(next.length >= PAGE_SIZE);
184
+ const nextHasMore = next.length >= PAGE_SIZE;
185
+ setHasMore(nextHasMore);
186
+ writeClientCache(viewCacheKey, {
187
+ items: next,
188
+ hasMore: nextHasMore,
189
+ });
103
190
  })
104
191
  .catch((fetchError: unknown) => {
105
192
  if (
@@ -127,12 +214,12 @@ export function useTimelineRouteData({
127
214
  };
128
215
  }, [
129
216
  bookmarkedOnly,
217
+ debouncedSearch,
130
218
  errorFallback,
131
219
  likedOnly,
132
220
  refreshTick,
133
221
  replyFilter,
134
222
  resource,
135
- search,
136
223
  selectedAccountId,
137
224
  ]);
138
225
 
@@ -147,7 +234,13 @@ export function useTimelineRouteData({
147
234
  loadMoreControllerRef.current = controller;
148
235
  setLoadingMore(true);
149
236
  try {
150
- const data = await fetchQueryResponse(buildQueryUrl(until, untilId), {
237
+ const nextPageUrl = new URL(
238
+ activeRequestUrlRef.current,
239
+ window.location.origin,
240
+ );
241
+ nextPageUrl.searchParams.set("until", until);
242
+ nextPageUrl.searchParams.set("untilId", untilId);
243
+ const data = await fetchCachedQueryResponse(nextPageUrl, {
151
244
  signal: controller.signal,
152
245
  });
153
246
  // Discard if the filters changed (new generation) while in flight.
@@ -155,7 +248,12 @@ export function useTimelineRouteData({
155
248
  const page = data.items as TimelineItem[];
156
249
  setItems((prev) => {
157
250
  const seen = new Set(prev.map((item) => item.id));
158
- return [...prev, ...page.filter((item) => !seen.has(item.id))];
251
+ const next = [...prev, ...page.filter((item) => !seen.has(item.id))];
252
+ writeClientCache(timelineViewCacheKey(activeRequestUrlRef.current), {
253
+ items: next,
254
+ hasMore: page.length >= PAGE_SIZE,
255
+ });
256
+ return next;
159
257
  });
160
258
  setHasMore(page.length >= PAGE_SIZE);
161
259
  } catch (loadError) {
@@ -175,12 +273,16 @@ export function useTimelineRouteData({
175
273
  }
176
274
 
177
275
  function retry() {
276
+ invalidateCachedQueryResponse(activeRequestUrlRef.current);
277
+ deleteClientCache(timelineViewCacheKey(activeRequestUrlRef.current));
178
278
  setRefreshTick((value) => value + 1);
179
279
  }
180
280
 
181
281
  function refreshLocalView() {
282
+ invalidateCachedQueryResponses();
283
+ deleteClientCacheByPrefix(TIMELINE_VIEW_CACHE_PREFIX);
182
284
  setRefreshTick((value) => value + 1);
183
- void loadStatus();
285
+ void loadStatus(true);
184
286
  }
185
287
 
186
288
  async function replyToTweet(tweetId: string) {
@@ -1,5 +1,11 @@
1
1
  import { Data, Effect } from "effect";
2
2
  import { z } from "zod";
3
+ import {
4
+ deleteClientCache,
5
+ deleteClientCacheByPrefix,
6
+ loadClientCache,
7
+ readClientCache,
8
+ } from "./client-cache";
3
9
  import { runEffectPromise } from "./effect-runtime";
4
10
  import type {
5
11
  DmConversationItem,
@@ -105,6 +111,15 @@ const webSyncJobSchema = z
105
111
 
106
112
  const actionResponseSchema = jsonRecordSchema;
107
113
  const SYNC_POLL_INTERVAL_MS = 500;
114
+ const STATUS_CACHE_KEY = "api:/status";
115
+ const QUERY_CACHE_PREFIX = "api:/query:";
116
+ const STATUS_CACHE_MAX_AGE_MS = 60_000;
117
+ const QUERY_CACHE_MAX_AGE_MS = 5 * 60_000;
118
+
119
+ interface ClientFetchCacheOptions {
120
+ force?: boolean;
121
+ maxAgeMs?: number;
122
+ }
108
123
 
109
124
  export class ApiFetchError extends Data.TaggedError("ApiFetchError")<{
110
125
  readonly message: string;
@@ -148,6 +163,55 @@ function runApiEffect<T, E>(effect: Effect.Effect<T, E>) {
148
163
  return runEffectPromise(effect);
149
164
  }
150
165
 
166
+ function splitSignal(init?: RequestInit) {
167
+ if (!init) return { requestInit: undefined, signal: undefined };
168
+ const { signal, ...requestInit } = init;
169
+ return { requestInit, signal: signal ?? undefined };
170
+ }
171
+
172
+ function waitForSignal<T>(promise: Promise<T>, signal?: AbortSignal) {
173
+ if (!signal) return promise;
174
+ if (signal.aborted) {
175
+ return Promise.reject(
176
+ new DOMException("The operation was aborted.", "AbortError"),
177
+ );
178
+ }
179
+
180
+ return new Promise<T>((resolve, reject) => {
181
+ const onAbort = () => {
182
+ reject(new DOMException("The operation was aborted.", "AbortError"));
183
+ };
184
+ signal.addEventListener("abort", onAbort, { once: true });
185
+ promise.then(
186
+ (value) => {
187
+ signal.removeEventListener("abort", onAbort);
188
+ resolve(value);
189
+ },
190
+ (error: unknown) => {
191
+ signal.removeEventListener("abort", onAbort);
192
+ reject(error);
193
+ },
194
+ );
195
+ });
196
+ }
197
+
198
+ function queryCacheKey(input: RequestInfo | URL) {
199
+ const raw =
200
+ typeof input === "string"
201
+ ? input
202
+ : input instanceof URL
203
+ ? input.toString()
204
+ : input.url;
205
+ const base =
206
+ typeof window === "undefined"
207
+ ? "http://birdclaw.local"
208
+ : window.location.origin;
209
+ const url = new URL(raw, base);
210
+ url.searchParams.delete("refresh");
211
+ url.searchParams.sort();
212
+ return `${QUERY_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
213
+ }
214
+
151
215
  export function fetchJsonEffect<T>(
152
216
  input: RequestInfo | URL,
153
217
  init: RequestInit | undefined,
@@ -191,8 +255,27 @@ export function fetchJson<T>(
191
255
  return runApiEffect(fetchJsonEffect(input, init, schema, fallbackMessage));
192
256
  }
193
257
 
194
- export function fetchQueryEnvelope(init?: RequestInit) {
195
- return runApiEffect(fetchQueryEnvelopeEffect(init));
258
+ export function readCachedQueryEnvelope() {
259
+ return readClientCache<QueryEnvelope>(
260
+ STATUS_CACHE_KEY,
261
+ STATUS_CACHE_MAX_AGE_MS,
262
+ );
263
+ }
264
+
265
+ export function fetchQueryEnvelope(
266
+ init?: RequestInit,
267
+ {
268
+ force = false,
269
+ maxAgeMs = STATUS_CACHE_MAX_AGE_MS,
270
+ }: ClientFetchCacheOptions = {},
271
+ ) {
272
+ const { requestInit, signal } = splitSignal(init);
273
+ const request = loadClientCache(
274
+ STATUS_CACHE_KEY,
275
+ () => runApiEffect(fetchQueryEnvelopeEffect(requestInit)),
276
+ { force, maxAgeMs },
277
+ );
278
+ return waitForSignal(request, signal);
196
279
  }
197
280
 
198
281
  export function fetchQueryEnvelopeEffect(init?: RequestInit) {
@@ -211,6 +294,38 @@ export function fetchQueryResponse(
211
294
  return runApiEffect(fetchQueryResponseEffect(input, init));
212
295
  }
213
296
 
297
+ export function readCachedQueryResponse(input: RequestInfo | URL) {
298
+ return readClientCache<QueryResponse>(
299
+ queryCacheKey(input),
300
+ QUERY_CACHE_MAX_AGE_MS,
301
+ );
302
+ }
303
+
304
+ export function fetchCachedQueryResponse(
305
+ input: RequestInfo | URL,
306
+ init?: RequestInit,
307
+ {
308
+ force = false,
309
+ maxAgeMs = QUERY_CACHE_MAX_AGE_MS,
310
+ }: ClientFetchCacheOptions = {},
311
+ ) {
312
+ const { requestInit, signal } = splitSignal(init);
313
+ const request = loadClientCache(
314
+ queryCacheKey(input),
315
+ () => runApiEffect(fetchQueryResponseEffect(input, requestInit)),
316
+ { force, maxAgeMs },
317
+ );
318
+ return waitForSignal(request, signal);
319
+ }
320
+
321
+ export function invalidateCachedQueryResponse(input: RequestInfo | URL) {
322
+ deleteClientCache(queryCacheKey(input));
323
+ }
324
+
325
+ export function invalidateCachedQueryResponses() {
326
+ deleteClientCacheByPrefix(QUERY_CACHE_PREFIX);
327
+ }
328
+
214
329
  export function fetchQueryResponseEffect(
215
330
  input: RequestInfo | URL,
216
331
  init?: RequestInit,
@@ -8,11 +8,16 @@ import { runEffectPromise, tryPromise } from "./effect-runtime";
8
8
  import type { ArchiveCandidate } from "./types";
9
9
 
10
10
  const execAsync = promisify(exec);
11
+ const ARCHIVE_DISCOVERY_CACHE_MS = 5 * 60_000;
11
12
  const ARCHIVE_NAME_PATTERNS = [
12
13
  /^twitter-.*\.zip$/i,
13
14
  /^x-.*\.zip$/i,
14
15
  /archive.*\.zip$/i,
15
16
  ];
17
+ let archiveDiscoveryCache:
18
+ | { value: ArchiveCandidate[]; updatedAt: number }
19
+ | undefined;
20
+ let archiveDiscoveryInFlight: Promise<ArchiveCandidate[]> | undefined;
16
21
 
17
22
  function formatFileSize(bytes: number): string {
18
23
  const units = ["B", "KB", "MB", "GB"];
@@ -142,6 +147,39 @@ export function findArchivesEffect(): Effect.Effect<
142
147
  });
143
148
  }
144
149
 
150
+ export function findArchivesCachedEffect(): Effect.Effect<
151
+ ArchiveCandidate[],
152
+ unknown
153
+ > {
154
+ return Effect.suspend(() => {
155
+ const cached = archiveDiscoveryCache;
156
+ if (cached && Date.now() - cached.updatedAt < ARCHIVE_DISCOVERY_CACHE_MS) {
157
+ return Effect.succeed(cached.value);
158
+ }
159
+ if (archiveDiscoveryInFlight) {
160
+ return Effect.promise(() => archiveDiscoveryInFlight!);
161
+ }
162
+
163
+ const request = runEffectPromise(findArchivesEffect())
164
+ .then((value) => {
165
+ archiveDiscoveryCache = { value, updatedAt: Date.now() };
166
+ return value;
167
+ })
168
+ .finally(() => {
169
+ if (archiveDiscoveryInFlight === request) {
170
+ archiveDiscoveryInFlight = undefined;
171
+ }
172
+ });
173
+ archiveDiscoveryInFlight = request;
174
+ return Effect.promise(() => request);
175
+ });
176
+ }
177
+
145
178
  export function findArchives(): Promise<ArchiveCandidate[]> {
146
179
  return runEffectPromise(findArchivesEffect());
147
180
  }
181
+
182
+ export function clearArchiveFinderCacheForTests() {
183
+ archiveDiscoveryCache = undefined;
184
+ archiveDiscoveryInFlight = undefined;
185
+ }
@@ -3,6 +3,7 @@ import { Effect } from "effect";
3
3
  import { getNativeDb } from "./db";
4
4
  import { runEffectPromise, tryPromise } from "./effect-runtime";
5
5
  import { readSyncCache, writeSyncCache } from "./sync-cache";
6
+ import { tweetEntitiesFromXurl } from "./tweet-render";
6
7
  import type {
7
8
  TweetEntities,
8
9
  TweetMediaItem,
@@ -523,68 +524,7 @@ function toMentionData(tweet: XurlUserTweet, fallbackAuthorId: string) {
523
524
  }
524
525
 
525
526
  function toLocalEntities(tweet: XurlMentionData): TweetEntities {
526
- const raw = tweet.entities;
527
- if (!raw || typeof raw !== "object") {
528
- return {};
529
- }
530
-
531
- const entities = raw as Record<string, unknown>;
532
- const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
533
- const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
534
- const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
535
-
536
- return {
537
- ...(rawMentions.length
538
- ? {
539
- mentions: rawMentions.map((mention) => {
540
- const value =
541
- mention && typeof mention === "object"
542
- ? (mention as Record<string, unknown>)
543
- : {};
544
- return {
545
- username: String(value.username ?? ""),
546
- id: typeof value.id === "string" ? String(value.id) : undefined,
547
- start: Number(value.start ?? 0),
548
- end: Number(value.end ?? 0),
549
- };
550
- }),
551
- }
552
- : {}),
553
- ...(rawUrls.length
554
- ? {
555
- urls: rawUrls.map((url) => {
556
- const value =
557
- url && typeof url === "object"
558
- ? (url as Record<string, unknown>)
559
- : {};
560
- return {
561
- url: String(value.url ?? ""),
562
- expandedUrl: String(value.expanded_url ?? value.url ?? ""),
563
- displayUrl: String(
564
- value.display_url ?? value.expanded_url ?? value.url ?? "",
565
- ),
566
- start: Number(value.start ?? 0),
567
- end: Number(value.end ?? 0),
568
- };
569
- }),
570
- }
571
- : {}),
572
- ...(rawHashtags.length
573
- ? {
574
- hashtags: rawHashtags.map((hashtag) => {
575
- const value =
576
- hashtag && typeof hashtag === "object"
577
- ? (hashtag as Record<string, unknown>)
578
- : {};
579
- return {
580
- tag: String(value.tag ?? ""),
581
- start: Number(value.start ?? 0),
582
- end: Number(value.end ?? 0),
583
- };
584
- }),
585
- }
586
- : {}),
587
- };
527
+ return tweetEntitiesFromXurl(tweet.entities);
588
528
  }
589
529
 
590
530
  function toMediaType(type: string): TweetMediaItem["type"] {
package/src/lib/bird.ts CHANGED
@@ -29,6 +29,12 @@ interface BirdTweetAuthor {
29
29
  name?: string;
30
30
  }
31
31
 
32
+ interface BirdTweetArticle {
33
+ title?: string;
34
+ previewText?: string;
35
+ coverImageUrl?: string;
36
+ }
37
+
32
38
  interface BirdTweetItem {
33
39
  id: string;
34
40
  text: string;
@@ -45,6 +51,7 @@ interface BirdTweetItem {
45
51
  author?: BirdTweetAuthor;
46
52
  authorId?: string;
47
53
  media?: BirdTweetMedia[];
54
+ article?: BirdTweetArticle | null;
48
55
  }
49
56
 
50
57
  export interface BirdDmUser {
@@ -385,6 +392,29 @@ function toMediaEntities(media: BirdTweetMedia[] | undefined) {
385
392
  };
386
393
  }
387
394
 
395
+ function toTweetEntities(item: BirdTweetItem) {
396
+ const mediaEntities = toMediaEntities(item.media);
397
+ const title = item.article?.title?.trim();
398
+ if (!title) return mediaEntities;
399
+ const handle = item.author?.username?.replace(/^@/, "");
400
+ const url = handle
401
+ ? `https://x.com/${handle}/status/${item.id}`
402
+ : `https://x.com/i/status/${item.id}`;
403
+ return {
404
+ ...mediaEntities,
405
+ article: {
406
+ title,
407
+ url,
408
+ ...(item.article?.previewText?.trim()
409
+ ? { previewText: item.article.previewText.trim() }
410
+ : {}),
411
+ ...(item.article?.coverImageUrl?.trim()
412
+ ? { coverImageUrl: item.article.coverImageUrl.trim() }
413
+ : {}),
414
+ },
415
+ };
416
+ }
417
+
388
418
  function toReferencedTweets(item: BirdTweetItem) {
389
419
  const references: XurlReferencedTweet[] = [];
390
420
  if (typeof item.inReplyToStatusId === "string" && item.inReplyToStatusId) {
@@ -434,7 +464,7 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
434
464
  text: item.text,
435
465
  created_at: toIsoTimestamp(item.createdAt),
436
466
  conversation_id: item.conversationId ?? item.id,
437
- entities: toMediaEntities(item.media),
467
+ entities: toTweetEntities(item),
438
468
  referenced_tweets: toReferencedTweets(item),
439
469
  public_metrics: {
440
470
  reply_count: Number(item.replyCount ?? 0),
@@ -1104,6 +1134,7 @@ export const __test__ = {
1104
1134
  getBirdTweetItems,
1105
1135
  getBirdTweetItem,
1106
1136
  toMediaEntities,
1137
+ toTweetEntities,
1107
1138
  toReferencedTweets,
1108
1139
  normalizeBirdTweets,
1109
1140
  };