birdclaw 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +32 -2
  3. package/package.json +29 -30
  4. package/scripts/build-docs-site.mjs +39 -12
  5. package/src/cli.ts +457 -26
  6. package/src/components/AppNav.tsx +10 -0
  7. package/src/components/MarkdownViewer.tsx +438 -72
  8. package/src/components/ProfileAnalysisStream.tsx +428 -0
  9. package/src/components/ProfilePreview.tsx +120 -9
  10. package/src/components/SavedTimelineView.tsx +30 -8
  11. package/src/components/SyncNowButton.tsx +5 -2
  12. package/src/components/TimelineCard.tsx +20 -4
  13. package/src/components/TimelineRouteFrame.tsx +16 -0
  14. package/src/components/TweetRichText.tsx +36 -12
  15. package/src/components/useTimelineRouteData.ts +74 -6
  16. package/src/lib/account-sync-job.ts +15 -3
  17. package/src/lib/archive-finder.ts +1 -1
  18. package/src/lib/archive-import.ts +245 -7
  19. package/src/lib/authored-live.ts +1 -0
  20. package/src/lib/avatar-cache.ts +50 -0
  21. package/src/lib/backup.ts +4 -3
  22. package/src/lib/bird.ts +33 -0
  23. package/src/lib/config.ts +35 -2
  24. package/src/lib/data-sources.ts +219 -0
  25. package/src/lib/db.ts +62 -1
  26. package/src/lib/geocoding.ts +296 -0
  27. package/src/lib/link-insights.ts +2 -0
  28. package/src/lib/location.ts +137 -0
  29. package/src/lib/mention-threads-live.ts +94 -1
  30. package/src/lib/mentions-live.ts +187 -40
  31. package/src/lib/network-map.ts +382 -0
  32. package/src/lib/period-digest.ts +468 -29
  33. package/src/lib/profile-analysis.ts +1272 -0
  34. package/src/lib/profile-bio-entities.ts +1 -1
  35. package/src/lib/queries.ts +14 -4
  36. package/src/lib/search-discussion.ts +1016 -0
  37. package/src/lib/timeline-live.ts +272 -19
  38. package/src/lib/tweet-account-edges.ts +2 -0
  39. package/src/lib/tweet-render.ts +141 -1
  40. package/src/lib/tweet-search-live.ts +565 -0
  41. package/src/lib/types.ts +37 -2
  42. package/src/lib/ui.ts +1 -1
  43. package/src/lib/web-sync.ts +7 -2
  44. package/src/lib/xurl-rate-limits.ts +267 -0
  45. package/src/lib/xurl.ts +551 -41
  46. package/src/routeTree.gen.ts +231 -0
  47. package/src/routes/__root.tsx +5 -6
  48. package/src/routes/api/data-sources.tsx +24 -0
  49. package/src/routes/api/network-map.tsx +55 -0
  50. package/src/routes/api/period-digest.tsx +29 -3
  51. package/src/routes/api/profile-analysis.tsx +152 -0
  52. package/src/routes/api/query.tsx +1 -0
  53. package/src/routes/api/search-discussion.tsx +169 -0
  54. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  55. package/src/routes/data-sources.tsx +257 -0
  56. package/src/routes/discuss.tsx +419 -0
  57. package/src/routes/network-map.tsx +1035 -0
  58. package/src/routes/profile-analyze.tsx +112 -0
  59. package/src/routes/profiles.$handle.tsx +228 -0
  60. package/src/routes/rate-limits.tsx +309 -0
  61. package/src/routes/today.tsx +22 -8
  62. package/src/styles.css +22 -0
@@ -0,0 +1,565 @@
1
+ import { Effect } from "effect";
2
+ import { searchTweetsViaBirdEffect } from "./bird";
3
+ import type { Database } from "./sqlite";
4
+ import { getNativeDb } from "./db";
5
+ import { runEffectPromise } from "./effect-runtime";
6
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ 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
+
13
+ export type TweetSearchMode = "auto" | "bird" | "xurl" | "local";
14
+
15
+ export interface SyncTweetSearchOptions {
16
+ query: string;
17
+ account?: string;
18
+ mode?: TweetSearchMode;
19
+ limit?: number;
20
+ maxPages?: number;
21
+ since?: string;
22
+ until?: string;
23
+ refresh?: boolean;
24
+ cacheTtlMs?: number;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ export type SyncTweetSearchResult =
29
+ | {
30
+ ok: true;
31
+ source: "bird" | "xurl" | "bird+xurl" | "cache";
32
+ accountId: string;
33
+ query: string;
34
+ count: number;
35
+ pageCount: number;
36
+ tweetIds: string[];
37
+ }
38
+ | {
39
+ ok: false;
40
+ source: "bird" | "xurl" | "auto";
41
+ accountId: string;
42
+ query: string;
43
+ error: string;
44
+ };
45
+
46
+ const DEFAULT_SEARCH_LIMIT = 20_000;
47
+ const DEFAULT_MAX_PAGES = 200;
48
+ const DEFAULT_CACHE_TTL_MS = 2 * 60_000;
49
+ const XURL_PAGE_SIZE = 100;
50
+
51
+ function toError(error: unknown) {
52
+ return error instanceof Error ? error : new Error(String(error));
53
+ }
54
+
55
+ function trySync<T>(try_: () => T) {
56
+ return Effect.try({
57
+ try: try_,
58
+ catch: toError,
59
+ });
60
+ }
61
+
62
+ function normalizeLimit(limit: number | undefined) {
63
+ if (limit === undefined) return DEFAULT_SEARCH_LIMIT;
64
+ if (!Number.isFinite(limit) || limit < 1) {
65
+ throw new Error("--limit must be at least 1");
66
+ }
67
+ return Math.floor(limit);
68
+ }
69
+
70
+ function normalizeMaxPages(maxPages: number | undefined) {
71
+ if (maxPages === undefined) return DEFAULT_MAX_PAGES;
72
+ if (!Number.isFinite(maxPages) || maxPages < 1) {
73
+ throw new Error("--max-pages must be at least 1");
74
+ }
75
+ return Math.floor(maxPages);
76
+ }
77
+
78
+ function normalizeTime(value: string | undefined, optionName: string) {
79
+ if (!value?.trim()) return undefined;
80
+ const date = new Date(value);
81
+ if (!Number.isFinite(date.getTime())) {
82
+ throw new Error(`${optionName} must be a valid date`);
83
+ }
84
+ return date.toISOString();
85
+ }
86
+
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
+ function mergeTweetSearchIntoLocalStore(
129
+ db: Database,
130
+ accountId: string,
131
+ payload: XurlMentionsResponse,
132
+ source: "bird" | "xurl" | "cache",
133
+ ) {
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;
211
+ }
212
+
213
+ function toMentionsResponse(payload: XurlTweetsResponse): XurlMentionsResponse {
214
+ return {
215
+ data: payload.data,
216
+ includes: payload.includes,
217
+ meta: payload.meta,
218
+ };
219
+ }
220
+
221
+ function mergeResponses(
222
+ responses: XurlMentionsResponse[],
223
+ ): XurlMentionsResponse {
224
+ const seenTweetIds = new Set<string>();
225
+ const data = [];
226
+ const usersById = new Map();
227
+ const mediaByKey = new Map();
228
+ let pageCount = 0;
229
+
230
+ for (const response of responses) {
231
+ pageCount += Number(response.meta?.page_count ?? 1);
232
+ for (const user of response.includes?.users ?? []) {
233
+ usersById.set(user.id, user);
234
+ }
235
+ for (const media of response.includes?.media ?? []) {
236
+ mediaByKey.set(media.media_key, media);
237
+ }
238
+ for (const tweet of response.data) {
239
+ if (seenTweetIds.has(tweet.id)) continue;
240
+ seenTweetIds.add(tweet.id);
241
+ data.push(tweet);
242
+ }
243
+ }
244
+
245
+ return {
246
+ data,
247
+ includes: {
248
+ users: [...usersById.values()],
249
+ media: [...mediaByKey.values()],
250
+ },
251
+ meta: {
252
+ result_count: data.length,
253
+ page_count: pageCount,
254
+ },
255
+ };
256
+ }
257
+
258
+ function limitResponse(
259
+ response: XurlMentionsResponse,
260
+ limit: number,
261
+ ): XurlMentionsResponse {
262
+ if (response.data.length <= limit) {
263
+ return response;
264
+ }
265
+ return {
266
+ ...response,
267
+ data: response.data.slice(0, limit),
268
+ meta: {
269
+ ...response.meta,
270
+ result_count: limit,
271
+ },
272
+ };
273
+ }
274
+
275
+ function cacheKey({
276
+ query,
277
+ accountId,
278
+ mode,
279
+ limit,
280
+ maxPages,
281
+ since,
282
+ until,
283
+ }: {
284
+ query: string;
285
+ accountId: string;
286
+ mode: Exclude<TweetSearchMode, "auto" | "local">;
287
+ limit: number;
288
+ maxPages: number;
289
+ since?: string;
290
+ until?: string;
291
+ }) {
292
+ return `tweet-search:${mode}:${accountId}:${encodeURIComponent(query)}:${String(limit)}:${String(maxPages)}:${since ?? "no-since"}:${until ?? "no-until"}`;
293
+ }
294
+
295
+ function fetchBirdSearchEffect({
296
+ query,
297
+ limit,
298
+ maxPages,
299
+ }: {
300
+ query: string;
301
+ limit: number;
302
+ maxPages: number;
303
+ }) {
304
+ return searchTweetsViaBirdEffect(query, {
305
+ maxResults: Math.min(limit, XURL_PAGE_SIZE),
306
+ all: maxPages > 1 || limit > XURL_PAGE_SIZE,
307
+ maxPages,
308
+ });
309
+ }
310
+
311
+ function fetchXurlSearchEffect({
312
+ query,
313
+ limit,
314
+ maxPages,
315
+ timeoutMs,
316
+ since,
317
+ until,
318
+ }: {
319
+ query: string;
320
+ limit: number;
321
+ maxPages: number;
322
+ timeoutMs?: number;
323
+ since?: string;
324
+ until?: string;
325
+ }): Effect.Effect<XurlMentionsResponse, Error> {
326
+ return Effect.gen(function* () {
327
+ const responses: XurlMentionsResponse[] = [];
328
+ let nextToken: string | undefined;
329
+ for (let page = 0; page < maxPages; page += 1) {
330
+ const remaining =
331
+ limit -
332
+ responses.reduce((total, response) => total + response.data.length, 0);
333
+ 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
+ });
341
+ responses.push(toMentionsResponse(response));
342
+ nextToken =
343
+ typeof response.meta?.next_token === "string"
344
+ ? String(response.meta.next_token)
345
+ : undefined;
346
+ if (!nextToken) break;
347
+ }
348
+ return mergeResponses(responses);
349
+ });
350
+ }
351
+
352
+ function runModeEffect(
353
+ mode: Exclude<TweetSearchMode, "auto" | "local">,
354
+ options: {
355
+ query: string;
356
+ accountId: string;
357
+ username: string;
358
+ limit: number;
359
+ maxPages: number;
360
+ since?: string;
361
+ until?: string;
362
+ refresh: boolean;
363
+ cacheTtlMs: number;
364
+ timeoutMs?: number;
365
+ },
366
+ ): Effect.Effect<SyncTweetSearchResult, Error> {
367
+ return Effect.gen(function* () {
368
+ const db = getNativeDb();
369
+ 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(
384
+ 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
+ );
399
+
400
+ return {
401
+ ok: true,
402
+ source:
403
+ !options.refresh && cached && ageMs <= options.cacheTtlMs
404
+ ? "cache"
405
+ : mode,
406
+ accountId: options.accountId,
407
+ query: options.query,
408
+ count: tweetIds.length,
409
+ pageCount: Number(payload.meta?.page_count ?? 1),
410
+ tweetIds,
411
+ } as const;
412
+ });
413
+ }
414
+
415
+ function combineTweetSearchResults(
416
+ left: SyncTweetSearchResult,
417
+ right: SyncTweetSearchResult,
418
+ limit: number,
419
+ ): SyncTweetSearchResult {
420
+ if (left.ok && right.ok) {
421
+ const tweetIds = [...new Set([...left.tweetIds, ...right.tweetIds])].slice(
422
+ 0,
423
+ limit,
424
+ );
425
+ const liveSources = new Set(
426
+ [left.source, right.source].filter((source) => source !== "cache"),
427
+ );
428
+ return {
429
+ ok: true,
430
+ source:
431
+ liveSources.has("bird") && liveSources.has("xurl")
432
+ ? "bird+xurl"
433
+ : liveSources.has("bird")
434
+ ? "bird"
435
+ : liveSources.has("xurl")
436
+ ? "xurl"
437
+ : "cache",
438
+ accountId: left.accountId,
439
+ query: left.query,
440
+ count: tweetIds.length,
441
+ pageCount: left.pageCount + right.pageCount,
442
+ tweetIds,
443
+ };
444
+ }
445
+ if (left.ok) return left;
446
+ if (right.ok) return right;
447
+ return {
448
+ ok: false,
449
+ source: "auto",
450
+ accountId: left.accountId,
451
+ query: left.query,
452
+ error: `${left.error}; ${right.error}`,
453
+ };
454
+ }
455
+
456
+ export function syncTweetSearchEffect({
457
+ query,
458
+ account,
459
+ mode = "auto",
460
+ limit,
461
+ maxPages,
462
+ since,
463
+ until,
464
+ refresh = false,
465
+ cacheTtlMs,
466
+ timeoutMs,
467
+ }: SyncTweetSearchOptions): Effect.Effect<SyncTweetSearchResult, Error> {
468
+ return Effect.gen(function* () {
469
+ const normalizedQuery = query.trim();
470
+ if (!normalizedQuery) {
471
+ return yield* Effect.fail(new Error("Search query is required"));
472
+ }
473
+ const normalizedLimit = normalizeLimit(limit);
474
+ const normalizedMaxPages = normalizeMaxPages(maxPages);
475
+ const normalizedSince = yield* trySync(() =>
476
+ normalizeTime(since, "--since"),
477
+ );
478
+ const normalizedUntil = yield* trySync(() =>
479
+ normalizeTime(until, "--until"),
480
+ );
481
+ const ttlMs = normalizeCacheTtlMs(cacheTtlMs);
482
+ const db = getNativeDb();
483
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
484
+ const accountId = resolvedAccount.accountId;
485
+ if (mode === "local") {
486
+ return {
487
+ ok: true,
488
+ source: "cache",
489
+ accountId,
490
+ query: normalizedQuery,
491
+ count: 0,
492
+ pageCount: 0,
493
+ tweetIds: [],
494
+ } as const;
495
+ }
496
+
497
+ const runOptions = {
498
+ query: normalizedQuery,
499
+ accountId,
500
+ username: resolvedAccount.username,
501
+ limit: normalizedLimit,
502
+ maxPages: normalizedMaxPages,
503
+ since: normalizedSince,
504
+ until: normalizedUntil,
505
+ refresh,
506
+ cacheTtlMs: ttlMs,
507
+ timeoutMs,
508
+ };
509
+ if (mode === "bird" || mode === "xurl") {
510
+ return yield* runModeEffect(mode, runOptions).pipe(
511
+ Effect.catchAll((error) =>
512
+ Effect.succeed({
513
+ ok: false,
514
+ source: mode,
515
+ accountId,
516
+ query: normalizedQuery,
517
+ error: error.message,
518
+ } as const),
519
+ ),
520
+ );
521
+ }
522
+
523
+ if (normalizedSince || normalizedUntil) {
524
+ return yield* runModeEffect("xurl", runOptions).pipe(
525
+ Effect.catchAll((error) =>
526
+ Effect.succeed({
527
+ ok: false,
528
+ source: "auto",
529
+ accountId,
530
+ query: normalizedQuery,
531
+ error: error.message,
532
+ } as const),
533
+ ),
534
+ );
535
+ }
536
+
537
+ const birdResult = yield* runModeEffect("bird", runOptions).pipe(
538
+ Effect.catchAll((error) =>
539
+ Effect.succeed({
540
+ ok: false,
541
+ source: "bird",
542
+ accountId,
543
+ query: normalizedQuery,
544
+ error: error.message,
545
+ } as const),
546
+ ),
547
+ );
548
+ const xurlResult = yield* runModeEffect("xurl", runOptions).pipe(
549
+ Effect.catchAll((error) =>
550
+ Effect.succeed({
551
+ ok: false,
552
+ source: "xurl",
553
+ accountId,
554
+ query: normalizedQuery,
555
+ error: error.message,
556
+ } as const),
557
+ ),
558
+ );
559
+ return combineTweetSearchResults(birdResult, xurlResult, normalizedLimit);
560
+ });
561
+ }
562
+
563
+ export function syncTweetSearch(options: SyncTweetSearchOptions) {
564
+ return runEffectPromise(syncTweetSearchEffect(options));
565
+ }
package/src/lib/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ResourceKind = "home" | "mentions" | "authored" | "dms";
1
+ export type ResourceKind = "home" | "mentions" | "authored" | "search" | "dms";
2
2
  export type InboxKind = "mixed" | "mentions" | "dms";
3
3
 
4
4
  export type ReplyFilter = "all" | "replied" | "unreplied";
@@ -165,7 +165,7 @@ export interface TimelineItem {
165
165
  id: string;
166
166
  accountId: string;
167
167
  accountHandle: string;
168
- kind: "home" | "mention" | "authored" | "like" | "bookmark";
168
+ kind: "home" | "mention" | "authored" | "search" | "like" | "bookmark";
169
169
  text: string;
170
170
  searchSnippet?: string;
171
171
  createdAt: string;
@@ -356,6 +356,7 @@ export interface TimelineQuery {
356
356
  replyFilter?: ReplyFilter;
357
357
  since?: string;
358
358
  until?: string;
359
+ untilId?: string;
359
360
  includeReplies?: boolean;
360
361
  qualityFilter?: TimelineQualityFilter;
361
362
  lowQualityThreshold?: number;
@@ -390,6 +391,40 @@ export interface TransportStatus {
390
391
  rawStatus?: string;
391
392
  }
392
393
 
394
+ export type LiveDataSourceKind = "birdclaw" | "bird" | "xurl";
395
+
396
+ export interface LiveDataSourceAccount {
397
+ id?: string;
398
+ username?: string;
399
+ handle?: string;
400
+ app?: string;
401
+ isDefault?: boolean;
402
+ }
403
+
404
+ export interface LiveDataSourceStatus {
405
+ source: LiveDataSourceKind;
406
+ label: string;
407
+ works: boolean;
408
+ installed?: boolean;
409
+ status: "ok" | "warning" | "error";
410
+ detail: string;
411
+ accounts: LiveDataSourceAccount[];
412
+ }
413
+
414
+ export interface LiveDataSourceCapability {
415
+ key: string;
416
+ label: string;
417
+ primary: LiveDataSourceKind;
418
+ fallbacks: LiveDataSourceKind[];
419
+ notes?: string;
420
+ }
421
+
422
+ export interface LiveDataSourcesResponse {
423
+ generatedAt: string;
424
+ sources: LiveDataSourceStatus[];
425
+ capabilities: LiveDataSourceCapability[];
426
+ }
427
+
393
428
  export type ModerationAction = "block" | "unblock" | "mute" | "unmute";
394
429
  export type ModerationTransportKind = "bird" | "xurl" | "x-web";
395
430
 
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 top-[calc(100%+8px)] z-30 grid w-[280px] translate-y-1 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:translate-y-0 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0 group-focus-within:opacity-100";
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";
315
315
 
316
316
  export const profilePreviewHeaderClass = "flex items-center gap-3";
317
317
 
@@ -131,12 +131,17 @@ function summarizeSteps(steps: WebSyncStep[]) {
131
131
  const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
132
132
  timeline: {
133
133
  label: "Home timeline",
134
- accountAware: false,
134
+ accountAware: true,
135
135
  run: (account) =>
136
136
  Effect.gen(function* () {
137
137
  const result = yield* syncHomeTimelineEffect({
138
138
  account,
139
+ mode:
140
+ !account || account === resolveDefaultSyncAccountId()
141
+ ? "auto"
142
+ : "xurl",
139
143
  limit: 100,
144
+ maxPages: 3,
140
145
  following: true,
141
146
  refresh: true,
142
147
  });
@@ -157,7 +162,7 @@ const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
157
162
  Effect.gen(function* () {
158
163
  const mentions = yield* syncMentionsEffect({
159
164
  account,
160
- mode: "xurl",
165
+ mode: "auto",
161
166
  limit: 100,
162
167
  maxPages: 3,
163
168
  refresh: true,