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
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ TweetArticle,
2
3
  TweetEntities,
3
4
  TweetHashtagEntity,
4
5
  TweetMentionEntity,
@@ -57,6 +58,34 @@ export function displayUrlForLink(url: string) {
57
58
  }
58
59
  }
59
60
 
61
+ function comparableUrl(value: string) {
62
+ try {
63
+ const parsed = new URL(value);
64
+ return `${parsed.protocol}//${parsed.hostname.replace(/^www\./, "")}${parsed.pathname}`;
65
+ } catch {
66
+ return value.split("?")[0] ?? value;
67
+ }
68
+ }
69
+
70
+ export function isTweetArticleUrlEntity(
71
+ entry: TweetUrlEntity,
72
+ article: TweetArticle,
73
+ ) {
74
+ if (comparableUrl(entry.expandedUrl) === comparableUrl(article.url)) {
75
+ return true;
76
+ }
77
+ try {
78
+ const parsed = new URL(entry.expandedUrl);
79
+ const host = parsed.hostname.replace(/^www\./, "");
80
+ return (
81
+ (host === "x.com" || host === "twitter.com") &&
82
+ parsed.pathname.startsWith("/i/article/")
83
+ );
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
60
89
  function asRecord(value: unknown) {
61
90
  return value && typeof value === "object"
62
91
  ? (value as Record<string, unknown>)
@@ -68,6 +97,9 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
68
97
  const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
69
98
  const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
70
99
  const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
100
+ const rawArticle = asRecord(entities.article);
101
+ const articleTitle = String(rawArticle.title ?? "").trim();
102
+ const articleUrl = String(rawArticle.url ?? "").trim();
71
103
 
72
104
  return {
73
105
  ...(rawMentions.length
@@ -102,6 +134,23 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
102
134
  ),
103
135
  start: Number(value.start ?? 0),
104
136
  end: Number(value.end ?? 0),
137
+ ...(typeof value.title === "string"
138
+ ? { title: value.title }
139
+ : {}),
140
+ ...(typeof value.description === "string" ||
141
+ value.description === null
142
+ ? { description: value.description }
143
+ : {}),
144
+ ...(typeof value.imageUrl === "string"
145
+ ? { imageUrl: value.imageUrl }
146
+ : typeof value.image_url === "string"
147
+ ? { imageUrl: value.image_url }
148
+ : {}),
149
+ ...(typeof value.siteName === "string"
150
+ ? { siteName: value.siteName }
151
+ : typeof value.site_name === "string"
152
+ ? { siteName: value.site_name }
153
+ : {}),
105
154
  };
106
155
  }),
107
156
  }
@@ -118,6 +167,35 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
118
167
  }),
119
168
  }
120
169
  : {}),
170
+ ...(articleTitle && articleUrl
171
+ ? {
172
+ article: {
173
+ title: articleTitle,
174
+ url: articleUrl,
175
+ ...(typeof (rawArticle.previewText ?? rawArticle.preview_text) ===
176
+ "string" &&
177
+ String(rawArticle.previewText ?? rawArticle.preview_text).trim()
178
+ ? {
179
+ previewText: String(
180
+ rawArticle.previewText ?? rawArticle.preview_text,
181
+ ).trim(),
182
+ }
183
+ : {}),
184
+ ...(typeof (
185
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url
186
+ ) === "string" &&
187
+ String(
188
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
189
+ ).trim()
190
+ ? {
191
+ coverImageUrl: String(
192
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
193
+ ).trim(),
194
+ }
195
+ : {}),
196
+ },
197
+ }
198
+ : {}),
121
199
  };
122
200
  }
123
201
 
@@ -0,0 +1,156 @@
1
+ import type { Database } from "./sqlite";
2
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
3
+ import { tweetEntitiesFromXurl } from "./tweet-render";
4
+ import type { XurlMentionData, XurlMentionsResponse } from "./types";
5
+ import {
6
+ type TweetAccountEdgeKind,
7
+ upsertTweetAccountEdge,
8
+ } from "./tweet-account-edges";
9
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
10
+
11
+ export type CanonicalTweetKind =
12
+ | "authored"
13
+ | "bookmark"
14
+ | "home"
15
+ | "like"
16
+ | "mention"
17
+ | "search"
18
+ | "thread"
19
+ | "thread_context";
20
+
21
+ export interface IngestTweetPayloadOptions {
22
+ accountId: string;
23
+ payload: XurlMentionsResponse;
24
+ kind: CanonicalTweetKind;
25
+ source: string;
26
+ edgeKind?: TweetAccountEdgeKind;
27
+ collectionKind?: "likes" | "bookmarks";
28
+ markRepliesAsReplied?: boolean;
29
+ replaceSecondaryKind?: boolean;
30
+ }
31
+
32
+ function getReferencedTweetId(tweet: XurlMentionData, type: string) {
33
+ return (
34
+ tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
35
+ );
36
+ }
37
+
38
+ export function replaceTweetFts(db: Database, tweetId: string, text: string) {
39
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
40
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
41
+ tweetId,
42
+ text,
43
+ );
44
+ }
45
+
46
+ export function ingestTweetPayload(
47
+ db: Database,
48
+ {
49
+ accountId,
50
+ payload,
51
+ kind,
52
+ source,
53
+ edgeKind,
54
+ collectionKind,
55
+ markRepliesAsReplied = false,
56
+ replaceSecondaryKind = false,
57
+ }: IngestTweetPayloadOptions,
58
+ ) {
59
+ const usersById = new Map(
60
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
61
+ );
62
+ const upsertTweet = db.prepare(`
63
+ insert into tweets (
64
+ id, account_id, author_profile_id, kind, text, created_at,
65
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
66
+ entities_json, media_json, quoted_tweet_id
67
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
68
+ on conflict(id) do update set
69
+ account_id = tweets.account_id,
70
+ author_profile_id = excluded.author_profile_id,
71
+ kind = case
72
+ when ? = 1
73
+ and tweets.kind not in ('authored', 'home', 'mention')
74
+ then excluded.kind
75
+ else tweets.kind
76
+ end,
77
+ text = excluded.text,
78
+ created_at = excluded.created_at,
79
+ is_replied = max(tweets.is_replied, excluded.is_replied),
80
+ reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
81
+ like_count = excluded.like_count,
82
+ media_count = max(tweets.media_count, excluded.media_count),
83
+ entities_json = excluded.entities_json,
84
+ media_json = case
85
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
86
+ else tweets.media_json
87
+ end,
88
+ bookmarked = tweets.bookmarked,
89
+ liked = tweets.liked,
90
+ quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
91
+ `);
92
+ const upsertCollection = collectionKind
93
+ ? db.prepare(`
94
+ insert into tweet_collections (
95
+ account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
96
+ ) values (?, ?, ?, null, ?, ?, ?)
97
+ on conflict(account_id, tweet_id, kind) do update set
98
+ source = excluded.source,
99
+ raw_json = excluded.raw_json,
100
+ updated_at = excluded.updated_at
101
+ `)
102
+ : undefined;
103
+ const tweetIds: string[] = [];
104
+
105
+ db.transaction(() => {
106
+ const observedAt = new Date().toISOString();
107
+ for (const tweet of payload.data) {
108
+ const author = usersById.get(tweet.author_id);
109
+ const profile = author
110
+ ? upsertProfileFromXUser(db, author)
111
+ : ensureStubProfileForXUser(db, tweet.author_id);
112
+ const replyToId = getReferencedTweetId(tweet, "replied_to");
113
+ const quotedTweetId = getReferencedTweetId(tweet, "quoted");
114
+ upsertTweet.run(
115
+ tweet.id,
116
+ accountId,
117
+ profile.profile.id,
118
+ kind,
119
+ tweet.text,
120
+ tweet.created_at,
121
+ markRepliesAsReplied && replyToId ? 1 : 0,
122
+ replyToId,
123
+ Number(tweet.public_metrics?.like_count ?? 0),
124
+ countTweetMedia(tweet),
125
+ collectionKind === "bookmarks" ? 1 : 0,
126
+ collectionKind === "likes" ? 1 : 0,
127
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
128
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
129
+ quotedTweetId,
130
+ replaceSecondaryKind ? 1 : 0,
131
+ );
132
+ if (edgeKind) {
133
+ upsertTweetAccountEdge(db, {
134
+ accountId,
135
+ tweetId: tweet.id,
136
+ kind: edgeKind,
137
+ source,
138
+ seenAt: observedAt,
139
+ rawJson: JSON.stringify(tweet),
140
+ });
141
+ }
142
+ upsertCollection?.run(
143
+ accountId,
144
+ tweet.id,
145
+ collectionKind,
146
+ source,
147
+ JSON.stringify(tweet),
148
+ observedAt,
149
+ );
150
+ replaceTweetFts(db, tweet.id, tweet.text);
151
+ tweetIds.push(tweet.id);
152
+ }
153
+ })();
154
+
155
+ return tweetIds;
156
+ }
@@ -1,14 +1,15 @@
1
1
  import { Effect } from "effect";
2
- import { searchTweetsViaBirdEffect } from "./bird";
3
2
  import type { Database } from "./sqlite";
4
3
  import { getNativeDb } from "./db";
5
4
  import { runEffectPromise } from "./effect-runtime";
6
- import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
- import { readSyncCache, writeSyncCache } from "./sync-cache";
5
+ import { liveTransportGateway } from "./live-transport-gateway";
6
+ import {
7
+ normalizeCacheTtlMs,
8
+ resolveLiveSyncAccount,
9
+ runCachedLiveSyncEffect,
10
+ } from "./live-sync-engine";
8
11
  import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
9
- import { upsertTweetAccountEdge } from "./tweet-account-edges";
10
- import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
11
- import { searchRecentTweetsEffect } from "./xurl";
12
+ import { ingestTweetPayload } from "./tweet-repository";
12
13
 
13
14
  export type TweetSearchMode = "auto" | "bird" | "xurl" | "local";
14
15
 
@@ -84,130 +85,19 @@ function normalizeTime(value: string | undefined, optionName: string) {
84
85
  return date.toISOString();
85
86
  }
86
87
 
87
- function normalizeCacheTtlMs(value: number | undefined) {
88
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
89
- return DEFAULT_CACHE_TTL_MS;
90
- }
91
- return Math.floor(value);
92
- }
93
-
94
- function resolveAccount(db: Database, accountId?: string) {
95
- const row = accountId
96
- ? (db
97
- .prepare("select id, handle from accounts where id = ?")
98
- .get(accountId) as { id: string; handle: string } | undefined)
99
- : (db
100
- .prepare(
101
- `
102
- select id, handle
103
- from accounts
104
- order by is_default desc, created_at asc
105
- limit 1
106
- `,
107
- )
108
- .get() as { id: string; handle: string } | undefined);
109
-
110
- if (!row) {
111
- throw new Error(`Unknown account: ${accountId ?? "default"}`);
112
- }
113
-
114
- return {
115
- accountId: row.id,
116
- username: row.handle.replace(/^@/, ""),
117
- };
118
- }
119
-
120
- function replaceTweetFts(db: Database, tweetId: string, text: string) {
121
- db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
122
- db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
123
- tweetId,
124
- text,
125
- );
126
- }
127
-
128
88
  function mergeTweetSearchIntoLocalStore(
129
89
  db: Database,
130
90
  accountId: string,
131
91
  payload: XurlMentionsResponse,
132
92
  source: "bird" | "xurl" | "cache",
133
93
  ) {
134
- const usersById = new Map(
135
- (payload.includes?.users ?? []).map((user) => [user.id, user]),
136
- );
137
- const upsertTweet = db.prepare(
138
- `
139
- insert into tweets (
140
- id, account_id, author_profile_id, kind, text, created_at,
141
- is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
142
- entities_json, media_json, quoted_tweet_id
143
- ) values (?, ?, ?, 'search', ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, ?)
144
- on conflict(id) do update set
145
- account_id = tweets.account_id,
146
- author_profile_id = excluded.author_profile_id,
147
- kind = tweets.kind,
148
- text = excluded.text,
149
- created_at = excluded.created_at,
150
- reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
151
- like_count = excluded.like_count,
152
- media_count = max(tweets.media_count, excluded.media_count),
153
- entities_json = excluded.entities_json,
154
- media_json = case
155
- when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
156
- else tweets.media_json
157
- end,
158
- quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id),
159
- bookmarked = tweets.bookmarked,
160
- liked = tweets.liked
161
- `,
162
- );
163
- const tweetIds: string[] = [];
164
-
165
- db.transaction(() => {
166
- const seenAt = new Date().toISOString();
167
- for (const tweet of payload.data) {
168
- const author =
169
- usersById.get(tweet.author_id) ??
170
- ({
171
- id: tweet.author_id,
172
- username: `user_${tweet.author_id}`,
173
- name: `user_${tweet.author_id}`,
174
- } as const);
175
- const profile = usersById.has(tweet.author_id)
176
- ? upsertProfileFromXUser(db, author)
177
- : ensureStubProfileForXUser(db, tweet.author_id);
178
- const replyToId =
179
- tweet.referenced_tweets?.find((item) => item.type === "replied_to")
180
- ?.id ?? null;
181
- const quotedTweetId =
182
- tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
183
- null;
184
- upsertTweet.run(
185
- tweet.id,
186
- accountId,
187
- profile.profile.id,
188
- tweet.text,
189
- tweet.created_at,
190
- replyToId,
191
- Number(tweet.public_metrics?.like_count ?? 0),
192
- countTweetMedia(tweet),
193
- JSON.stringify(tweet.entities ?? {}),
194
- buildMediaJsonFromIncludes(tweet, payload.includes?.media),
195
- quotedTweetId,
196
- );
197
- upsertTweetAccountEdge(db, {
198
- accountId,
199
- tweetId: tweet.id,
200
- kind: "search",
201
- source,
202
- seenAt,
203
- rawJson: JSON.stringify(tweet),
204
- });
205
- replaceTweetFts(db, tweet.id, tweet.text);
206
- tweetIds.push(tweet.id);
207
- }
208
- })();
209
-
210
- return tweetIds;
94
+ return ingestTweetPayload(db, {
95
+ accountId,
96
+ payload,
97
+ kind: "search",
98
+ edgeKind: "search",
99
+ source,
100
+ });
211
101
  }
212
102
 
213
103
  function toMentionsResponse(payload: XurlTweetsResponse): XurlMentionsResponse {
@@ -301,7 +191,7 @@ function fetchBirdSearchEffect({
301
191
  limit: number;
302
192
  maxPages: number;
303
193
  }) {
304
- return searchTweetsViaBirdEffect(query, {
194
+ return liveTransportGateway.bird.searchTweets(query, {
305
195
  maxResults: Math.min(limit, XURL_PAGE_SIZE),
306
196
  all: maxPages > 1 || limit > XURL_PAGE_SIZE,
307
197
  maxPages,
@@ -331,13 +221,16 @@ function fetchXurlSearchEffect({
331
221
  limit -
332
222
  responses.reduce((total, response) => total + response.data.length, 0);
333
223
  if (remaining <= 0) break;
334
- const response = yield* searchRecentTweetsEffect(query, {
335
- maxResults: Math.max(10, Math.min(XURL_PAGE_SIZE, remaining)),
336
- paginationToken: nextToken,
337
- startTime: since,
338
- endTime: until,
339
- timeoutMs,
340
- });
224
+ const response = yield* liveTransportGateway.xurl.searchRecentTweets(
225
+ query,
226
+ {
227
+ maxResults: Math.max(10, Math.min(XURL_PAGE_SIZE, remaining)),
228
+ paginationToken: nextToken,
229
+ startTime: since,
230
+ endTime: until,
231
+ timeoutMs,
232
+ },
233
+ );
341
234
  responses.push(toMentionsResponse(response));
342
235
  nextToken =
343
236
  typeof response.meta?.next_token === "string"
@@ -367,42 +260,44 @@ function runModeEffect(
367
260
  return Effect.gen(function* () {
368
261
  const db = getNativeDb();
369
262
  const key = cacheKey({ ...options, mode });
370
- const cached = yield* trySync(() =>
371
- readSyncCache<XurlMentionsResponse>(key, db),
372
- );
373
- const ageMs = cached
374
- ? Date.now() - new Date(cached.updatedAt).getTime()
375
- : Number.POSITIVE_INFINITY;
376
- const payload =
377
- !options.refresh && cached && ageMs <= options.cacheTtlMs
378
- ? cached.value
379
- : yield* (
380
- mode === "bird"
381
- ? fetchBirdSearchEffect(options).pipe(Effect.mapError(toError))
382
- : fetchXurlSearchEffect(options)
383
- ).pipe(
263
+ const fetch =
264
+ mode === "bird"
265
+ ? fetchBirdSearchEffect(options).pipe(Effect.mapError(toError))
266
+ : fetchXurlSearchEffect(options);
267
+ const syncResult = yield* runCachedLiveSyncEffect({
268
+ db,
269
+ cacheKey: key,
270
+ refresh: options.refresh,
271
+ cacheTtlMs: options.cacheTtlMs,
272
+ transports: [
273
+ {
274
+ source: mode,
275
+ fetch: fetch.pipe(
384
276
  Effect.map((response) => limitResponse(response, options.limit)),
385
- );
386
- if (!cached || options.refresh || ageMs > options.cacheTtlMs) {
387
- yield* trySync(() => writeSyncCache(key, payload, db));
388
- }
389
- const tweetIds = yield* trySync(() =>
390
- mergeTweetSearchIntoLocalStore(
391
- db,
392
- options.accountId,
393
- payload,
394
- !options.refresh && cached && ageMs <= options.cacheTtlMs
395
- ? "cache"
396
- : mode,
397
- ),
398
- );
277
+ ),
278
+ },
279
+ ],
280
+ persistLive: (writeDb, payload, source) =>
281
+ mergeTweetSearchIntoLocalStore(
282
+ writeDb,
283
+ options.accountId,
284
+ payload,
285
+ source,
286
+ ),
287
+ persistCached: (writeDb, payload) =>
288
+ mergeTweetSearchIntoLocalStore(
289
+ writeDb,
290
+ options.accountId,
291
+ payload,
292
+ "cache",
293
+ ),
294
+ });
295
+ const { payload, source } = syncResult;
296
+ const tweetIds = syncResult.persisted ?? [];
399
297
 
400
298
  return {
401
299
  ok: true,
402
- source:
403
- !options.refresh && cached && ageMs <= options.cacheTtlMs
404
- ? "cache"
405
- : mode,
300
+ source,
406
301
  accountId: options.accountId,
407
302
  query: options.query,
408
303
  count: tweetIds.length,
@@ -478,9 +373,11 @@ export function syncTweetSearchEffect({
478
373
  const normalizedUntil = yield* trySync(() =>
479
374
  normalizeTime(until, "--until"),
480
375
  );
481
- const ttlMs = normalizeCacheTtlMs(cacheTtlMs);
376
+ const ttlMs = normalizeCacheTtlMs(cacheTtlMs, DEFAULT_CACHE_TTL_MS);
482
377
  const db = getNativeDb();
483
- const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
378
+ const resolvedAccount = yield* trySync(() =>
379
+ resolveLiveSyncAccount(db, account),
380
+ );
484
381
  const accountId = resolvedAccount.accountId;
485
382
  if (mode === "local") {
486
383
  return {
package/src/lib/types.ts CHANGED
@@ -101,10 +101,18 @@ export interface TweetHashtagEntity {
101
101
  end: number;
102
102
  }
103
103
 
104
+ export interface TweetArticle {
105
+ title: string;
106
+ previewText?: string;
107
+ url: string;
108
+ coverImageUrl?: string;
109
+ }
110
+
104
111
  export interface TweetEntities {
105
112
  mentions?: TweetMentionEntity[];
106
113
  urls?: TweetUrlEntity[];
107
114
  hashtags?: TweetHashtagEntity[];
115
+ article?: TweetArticle;
108
116
  }
109
117
 
110
118
  export interface TweetMediaItem {
package/src/lib/ui.ts CHANGED
@@ -311,7 +311,7 @@ export const profilePreviewTriggerClass =
311
311
  "profile-preview-trigger inline-flex text-inherit";
312
312
 
313
313
  export const profilePreviewCardClass =
314
- "pointer-events-none absolute left-0 z-30 grid w-[280px] gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
314
+ "fixed z-40 w-[280px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 shadow-[0_8px_28px_var(--shadow-strong)]";
315
315
 
316
316
  export const profilePreviewHeaderClass = "flex items-center gap-3";
317
317