birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
2
3
  import type { Database } from "./sqlite";
3
- import { findArchives } from "./archive-finder";
4
+ import { findArchivesEffect } from "./archive-finder";
4
5
  import { getDb, getNativeDb } from "./db";
6
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
5
7
  import { fetchProfileAffiliations } from "./profile-affiliations";
6
8
  import { displayUrlForLink, enrichFallbackUrlEntities } from "./tweet-render";
7
9
  import type {
@@ -23,16 +25,92 @@ import type {
23
25
  TweetUrlEntity,
24
26
  } from "./types";
25
27
  import {
26
- dmViaXurl,
27
- getTransportStatus,
28
- postViaXurl,
29
- replyViaXurl,
28
+ dmViaXurlEffect,
29
+ getTransportStatusEffect,
30
+ lookupAuthenticatedUserFresh,
31
+ postViaXurlEffect,
32
+ replyViaXurlEffect,
30
33
  } from "./xurl";
31
34
 
35
+ function toError(error: unknown) {
36
+ return error instanceof Error ? error : new Error(String(error));
37
+ }
38
+
39
+ function trySync<T>(try_: () => T) {
40
+ return Effect.try({
41
+ try: try_,
42
+ catch: toError,
43
+ });
44
+ }
45
+
46
+ function e2eFakeLiveWritesEnabled() {
47
+ return (
48
+ process.env.BIRDCLAW_E2E === "1" &&
49
+ process.env.BIRDCLAW_E2E_FAKE_LIVE_WRITES === "1"
50
+ );
51
+ }
52
+
53
+ function liveWritesDisabled() {
54
+ return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
55
+ }
56
+
57
+ function verifySelectedXurlAccountEffect(accountId: string) {
58
+ return Effect.gen(function* () {
59
+ if (liveWritesDisabled()) return;
60
+ if (e2eFakeLiveWritesEnabled()) return;
61
+ const db = yield* trySync(() => getNativeDb());
62
+ const account = yield* trySync(
63
+ () =>
64
+ db
65
+ .prepare("select handle, external_user_id from accounts where id = ?")
66
+ .get(accountId) as
67
+ | { handle: string; external_user_id: string | null }
68
+ | undefined,
69
+ );
70
+ if (!account) {
71
+ return yield* Effect.fail(new Error(`Unknown account: ${accountId}`));
72
+ }
73
+ const authenticated = yield* tryPromise(() =>
74
+ lookupAuthenticatedUserFresh(),
75
+ );
76
+ const authenticatedId =
77
+ typeof authenticated?.id === "string" ? authenticated.id : "";
78
+ const authenticatedHandle =
79
+ typeof authenticated?.username === "string"
80
+ ? authenticated.username.replace(/^@/, "")
81
+ : "";
82
+ const expectedHandle = account.handle.replace(/^@/, "");
83
+ if (
84
+ (account.external_user_id &&
85
+ account.external_user_id !== authenticatedId) ||
86
+ (!account.external_user_id &&
87
+ (!authenticatedHandle ||
88
+ authenticatedHandle.toLowerCase() !== expectedHandle.toLowerCase()))
89
+ ) {
90
+ return yield* Effect.fail(
91
+ new Error(
92
+ `xurl is authenticated as @${authenticatedHandle || authenticatedId}, not @${expectedHandle}`,
93
+ ),
94
+ );
95
+ }
96
+ });
97
+ }
98
+
32
99
  function getInfluenceScore(followersCount: number) {
33
100
  return Math.round(Math.log10(followersCount + 10) * 24);
34
101
  }
35
102
 
103
+ function getMinFollowersForInfluenceScore(score: number) {
104
+ if (!Number.isFinite(score)) return undefined;
105
+ return Math.max(0, Math.ceil(10 ** ((score - 0.5) / 24) - 10));
106
+ }
107
+
108
+ function getMaxFollowersForInfluenceScore(score: number) {
109
+ if (!Number.isFinite(score)) return undefined;
110
+ if (score < getInfluenceScore(0)) return -1;
111
+ return Math.max(0, Math.ceil(10 ** ((score + 0.5) / 24) - 10) - 1);
112
+ }
113
+
36
114
  function getInfluenceLabel(score: number) {
37
115
  if (score >= 150) return "very high";
38
116
  if (score >= 120) return "high";
@@ -82,6 +160,124 @@ function parseJsonField<T>(value: unknown, fallback: T): T {
82
160
  }
83
161
  }
84
162
 
163
+ function normalizeProfileHandle(handle: string) {
164
+ return handle.replace(/^@/, "").toLowerCase();
165
+ }
166
+
167
+ function avatarHueForHandle(handle: string) {
168
+ let hash = 0;
169
+ for (const character of handle) {
170
+ hash = (hash * 31 + character.charCodeAt(0)) % 360;
171
+ }
172
+ return hash;
173
+ }
174
+
175
+ function fallbackProfileForHandle(handle: string): ProfileRecord {
176
+ const normalized = normalizeProfileHandle(handle);
177
+ return {
178
+ id: `profile_handle_${normalized}`,
179
+ handle: normalized,
180
+ displayName: `@${normalized}`,
181
+ bio: "",
182
+ followersCount: 0,
183
+ avatarHue: avatarHueForHandle(normalized),
184
+ createdAt: new Date(0).toISOString(),
185
+ };
186
+ }
187
+
188
+ type ProfileByHandleCache = Map<string, ProfileRecord | null>;
189
+
190
+ function getProfileByHandle(
191
+ db: Database,
192
+ cache: ProfileByHandleCache,
193
+ handle: string,
194
+ profiles: Record<string, ProfileRecord> = {},
195
+ ) {
196
+ const normalized = normalizeProfileHandle(handle);
197
+ const inlineProfile = Object.values(profiles).find(
198
+ (profile) => normalizeProfileHandle(profile.handle) === normalized,
199
+ );
200
+ if (inlineProfile) {
201
+ return inlineProfile;
202
+ }
203
+
204
+ if (cache.has(normalized)) {
205
+ return cache.get(normalized) ?? fallbackProfileForHandle(normalized);
206
+ }
207
+
208
+ const row = db
209
+ .prepare(
210
+ `
211
+ select *
212
+ from profiles
213
+ where lower(handle) = lower(?)
214
+ limit 1
215
+ `,
216
+ )
217
+ .get(normalized) as Record<string, unknown> | undefined;
218
+ const profile = row ? toProfile(row) : null;
219
+ cache.set(normalized, profile);
220
+ return profile ?? fallbackProfileForHandle(normalized);
221
+ }
222
+
223
+ function spansOverlap(
224
+ leftStart: number,
225
+ leftEnd: number,
226
+ rightStart: number,
227
+ rightEnd: number,
228
+ ) {
229
+ return leftStart < rightEnd && rightStart < leftEnd;
230
+ }
231
+
232
+ function enrichFallbackMentionEntities(
233
+ text: string,
234
+ entities: TweetEntities,
235
+ resolveProfileByHandle: (handle: string) => ProfileRecord,
236
+ ): TweetEntities {
237
+ const existingMentions = entities.mentions ?? [];
238
+ const occupied = [
239
+ ...existingMentions,
240
+ ...(entities.urls ?? []),
241
+ ...(entities.hashtags ?? []),
242
+ ].map((entry) => ({ start: entry.start, end: entry.end }));
243
+ const fallbackMentions = [];
244
+ const mentionPattern = /(^|[^\w@])@([A-Za-z0-9_]{1,15})/g;
245
+
246
+ for (const match of text.matchAll(mentionPattern)) {
247
+ const prefix = match[1] ?? "";
248
+ const username = match[2];
249
+ if (!username) continue;
250
+ const start = (match.index ?? 0) + prefix.length;
251
+ const end = start + username.length + 1;
252
+ if (
253
+ occupied.some((entry) => spansOverlap(start, end, entry.start, entry.end))
254
+ ) {
255
+ continue;
256
+ }
257
+
258
+ const profile = resolveProfileByHandle(username);
259
+ fallbackMentions.push({
260
+ username,
261
+ id: profile.id,
262
+ start,
263
+ end,
264
+ profile,
265
+ });
266
+ occupied.push({ start, end });
267
+ }
268
+
269
+ if (fallbackMentions.length === 0) {
270
+ return entities;
271
+ }
272
+
273
+ return {
274
+ ...entities,
275
+ mentions: [...existingMentions, ...fallbackMentions].sort(
276
+ (left, right) => left.start - right.start,
277
+ ),
278
+ };
279
+ }
280
+
85
281
  function toFtsSearchQuery(value: string) {
86
282
  const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
87
283
  return terms
@@ -94,13 +290,17 @@ function toFtsSearchQuery(value: string) {
94
290
  function enrichEntities(
95
291
  entities: TweetEntities,
96
292
  profiles: Record<string, ProfileRecord>,
293
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
97
294
  ): TweetEntities {
98
295
  const mentions = entities.mentions?.map((mention) => {
99
296
  const profile =
100
297
  (mention.id ? profiles[mention.id] : undefined) ??
101
298
  Object.values(profiles).find(
102
- (candidate) => candidate.handle === mention.username,
103
- );
299
+ (candidate) =>
300
+ normalizeProfileHandle(candidate.handle) ===
301
+ normalizeProfileHandle(mention.username),
302
+ ) ??
303
+ resolveProfileByHandle?.(mention.username);
104
304
  return profile ? { ...mention, profile } : mention;
105
305
  });
106
306
 
@@ -171,12 +371,16 @@ function enrichTimelineEntities(
171
371
  text: string,
172
372
  entities: TweetEntities,
173
373
  profiles: Record<string, ProfileRecord>,
374
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
174
375
  ): TweetEntities {
175
- return enrichFallbackUrlEntities(
376
+ const withUrls = enrichFallbackUrlEntities(
176
377
  text,
177
- enrichEntities(entities, profiles),
378
+ enrichEntities(entities, profiles, resolveProfileByHandle),
178
379
  (rawUrl) => getUrlExpansion(db, urlExpansionCache, rawUrl),
179
380
  );
381
+ return resolveProfileByHandle
382
+ ? enrichFallbackMentionEntities(text, withUrls, resolveProfileByHandle)
383
+ : withUrls;
180
384
  }
181
385
 
182
386
  function buildEmbeddedTweet(
@@ -184,6 +388,7 @@ function buildEmbeddedTweet(
184
388
  urlExpansionCache: UrlExpansionCache,
185
389
  row: Record<string, unknown>,
186
390
  prefix: string,
391
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
187
392
  ): EmbeddedTweet | null {
188
393
  if (!row[`${prefix}id`]) {
189
394
  return null;
@@ -210,6 +415,21 @@ function buildEmbeddedTweet(
210
415
  typeof row[`${prefix}reply_to_id`] === "string"
211
416
  ? String(row[`${prefix}reply_to_id`])
212
417
  : null,
418
+ ...(row[`${prefix}is_replied`] === undefined
419
+ ? {}
420
+ : { isReplied: Boolean(row[`${prefix}is_replied`]) }),
421
+ ...(row[`${prefix}like_count`] === undefined
422
+ ? {}
423
+ : { likeCount: Number(row[`${prefix}like_count`]) }),
424
+ ...(row[`${prefix}media_count`] === undefined
425
+ ? {}
426
+ : { mediaCount: Number(row[`${prefix}media_count`]) }),
427
+ ...(row[`${prefix}bookmarked`] === undefined
428
+ ? {}
429
+ : { bookmarked: Boolean(row[`${prefix}bookmarked`]) }),
430
+ ...(row[`${prefix}liked`] === undefined
431
+ ? {}
432
+ : { liked: Boolean(row[`${prefix}liked`]) }),
213
433
  author,
214
434
  entities: enrichTimelineEntities(
215
435
  db,
@@ -219,11 +439,113 @@ function buildEmbeddedTweet(
219
439
  {
220
440
  [author.id]: author,
221
441
  },
442
+ resolveProfileByHandle,
222
443
  ),
223
444
  media: parseJsonField<TweetMediaItem[]>(row[`${prefix}media_json`], []),
224
445
  };
225
446
  }
226
447
 
448
+ function getRetweetedTweetIdFromRaw(rawJson: unknown) {
449
+ const raw = parseJsonField<Record<string, unknown>>(rawJson, {});
450
+ const directCandidates = [
451
+ raw.retweeted_tweet_id,
452
+ raw.retweetedTweetId,
453
+ raw.retweetedStatusId,
454
+ raw.retweeted_status_id_str,
455
+ ];
456
+ for (const candidate of directCandidates) {
457
+ if (typeof candidate === "string" && candidate.length > 0) {
458
+ return candidate;
459
+ }
460
+ }
461
+
462
+ const nestedCandidates = [raw.retweetedTweet, raw.retweeted_status];
463
+ for (const nested of nestedCandidates) {
464
+ if (nested && typeof nested === "object") {
465
+ const record = nested as Record<string, unknown>;
466
+ for (const key of ["id", "id_str"]) {
467
+ if (typeof record[key] === "string" && record[key].length > 0) {
468
+ return record[key];
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ const references = [raw.referenced_tweets, raw.referencedTweets].find(
475
+ (value): value is unknown[] => Array.isArray(value),
476
+ );
477
+ for (const reference of references ?? []) {
478
+ if (!reference || typeof reference !== "object") continue;
479
+ const record = reference as Record<string, unknown>;
480
+ if (record.type === "retweeted" && typeof record.id === "string") {
481
+ return record.id;
482
+ }
483
+ }
484
+
485
+ return null;
486
+ }
487
+
488
+ function parseManualRetweet(text: string) {
489
+ const match = text.match(/^RT\s+@([A-Za-z0-9_]{1,15}):\s*([\s\S]+)$/);
490
+ if (!match?.[1] || !match[2]) {
491
+ return null;
492
+ }
493
+ return {
494
+ handle: match[1],
495
+ text: match[2].trim(),
496
+ };
497
+ }
498
+
499
+ function buildRetweetedTweet(
500
+ db: Database,
501
+ urlExpansionCache: UrlExpansionCache,
502
+ row: Record<string, unknown>,
503
+ resolveProfileByHandle: (handle: string) => ProfileRecord,
504
+ ) {
505
+ const retweetedId = getRetweetedTweetIdFromRaw(row.edge_raw_json);
506
+ const accountId = String(row.account_id);
507
+ if (retweetedId) {
508
+ const tweet = getTweetById(
509
+ db,
510
+ urlExpansionCache,
511
+ retweetedId,
512
+ resolveProfileByHandle,
513
+ accountId,
514
+ );
515
+ if (tweet) {
516
+ return tweet;
517
+ }
518
+ }
519
+
520
+ const manualRetweet = parseManualRetweet(String(row.text ?? ""));
521
+ if (!manualRetweet) {
522
+ return null;
523
+ }
524
+
525
+ const author = resolveProfileByHandle(manualRetweet.handle);
526
+ return {
527
+ id: `${String(row.id)}:retweeted`,
528
+ text: manualRetweet.text,
529
+ createdAt: String(row.created_at ?? new Date(0).toISOString()),
530
+ replyToId: null,
531
+ isReplied: Boolean(row.is_replied),
532
+ likeCount: Number(row.like_count ?? 0),
533
+ mediaCount: 0,
534
+ bookmarked: Boolean(row.bookmarked),
535
+ liked: Boolean(row.liked),
536
+ author,
537
+ entities: enrichTimelineEntities(
538
+ db,
539
+ urlExpansionCache,
540
+ manualRetweet.text,
541
+ {},
542
+ { [author.id]: author },
543
+ resolveProfileByHandle,
544
+ ),
545
+ media: [],
546
+ };
547
+ }
548
+
227
549
  function buildReplyClause(replyFilter: ReplyFilter) {
228
550
  if (replyFilter === "replied") {
229
551
  return " and is_replied = 1";
@@ -321,12 +643,18 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
321
643
  const row = db
322
644
  .prepare(
323
645
  `
324
- with timeline_edges as (
325
- select account_id, tweet_id, kind
326
- from tweet_account_edges
327
- where kind = ?
646
+ select count(distinct tweet_id) as count
647
+ from (
648
+ select edge.tweet_id
649
+ from tweet_account_edges edge
650
+ where edge.kind = ?
651
+ and exists (
652
+ select 1
653
+ from tweets t
654
+ where t.id = edge.tweet_id
655
+ )
328
656
  union all
329
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
657
+ select legacy.id as tweet_id
330
658
  from tweets legacy
331
659
  where legacy.kind = ?
332
660
  and not exists (
@@ -337,61 +665,118 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
337
665
  and edge.kind = legacy.kind
338
666
  )
339
667
  )
340
- select count(*) as count
341
- from timeline_edges e
342
- join tweets t on t.id = e.tweet_id
343
- where e.kind = ?
344
668
  `,
345
669
  )
346
- .get(kind, kind, kind) as { count: number | bigint } | undefined;
670
+ .get(kind, kind) as { count: number | bigint } | undefined;
347
671
  return Number(row?.count ?? 0);
348
672
  }
349
673
 
350
- export async function getQueryEnvelope(): Promise<QueryEnvelope> {
351
- const db = getDb();
352
- const nativeDb = getNativeDb();
353
- const homeCount = countTimelineEdges(nativeDb, "home");
354
- const mentionCount = countTimelineEdges(nativeDb, "mention");
355
- const counts = await Promise.all([
356
- db
357
- .selectFrom("dm_conversations")
358
- .select((eb) => eb.fn.countAll().as("count"))
359
- .executeTakeFirstOrThrow(),
360
- db
361
- .selectFrom("dm_conversations")
362
- .select((eb) => eb.fn.countAll().as("count"))
363
- .where("needs_reply", "=", 1)
364
- .executeTakeFirstOrThrow(),
365
- db
366
- .selectFrom("accounts")
367
- .selectAll()
368
- .orderBy("is_default", "desc")
369
- .orderBy("name", "asc")
370
- .execute(),
371
- findArchives(),
372
- getTransportStatus(),
373
- ]);
674
+ const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
374
675
 
375
- return {
376
- stats: {
377
- home: homeCount,
378
- mentions: mentionCount,
379
- dms: Number(counts[0].count),
380
- needsReply: Number(counts[1].count),
381
- inbox: mentionCount + Number(counts[1].count),
382
- },
383
- accounts: counts[2].map((row) => ({
384
- id: row.id,
385
- name: row.name,
386
- handle: row.handle,
387
- externalUserId: row.external_user_id,
388
- transport: row.transport,
389
- isDefault: row.is_default,
390
- createdAt: row.created_at,
391
- })) satisfies AccountRecord[],
392
- archives: counts[3],
393
- transport: counts[4],
394
- };
676
+ function getAccountProfileMeta(
677
+ db: Database,
678
+ account: { handle: string; external_user_id: string | null },
679
+ ) {
680
+ const handle = account.handle.replace(/^@/, "");
681
+ const externalProfileId = account.external_user_id
682
+ ? `profile_user_${account.external_user_id}`
683
+ : "";
684
+ return db
685
+ .prepare(
686
+ `
687
+ select id, avatar_hue, avatar_url
688
+ from profiles
689
+ where id = ?
690
+ or lower(handle) = lower(?)
691
+ order by case
692
+ when id = 'profile_me' then 0
693
+ when id = ? then 1
694
+ else 2
695
+ end
696
+ limit 1
697
+ `,
698
+ )
699
+ .get(externalProfileId, handle, externalProfileId) as
700
+ | { id: string; avatar_hue: number; avatar_url: string | null }
701
+ | undefined;
702
+ }
703
+
704
+ export function getQueryEnvelopeEffect(): Effect.Effect<
705
+ QueryEnvelope,
706
+ unknown
707
+ > {
708
+ return Effect.gen(function* () {
709
+ const db = yield* trySync(() => getDb());
710
+ const nativeDb = yield* trySync(() => getNativeDb());
711
+ const homeCount = yield* trySync(() =>
712
+ countTimelineEdges(nativeDb, "home"),
713
+ );
714
+ const mentionCount = yield* trySync(() =>
715
+ countTimelineEdges(nativeDb, "mention"),
716
+ );
717
+ const counts = yield* Effect.all({
718
+ dms: tryPromise(() =>
719
+ db
720
+ .selectFrom("dm_conversations")
721
+ .select((eb) => eb.fn.countAll().as("count"))
722
+ .executeTakeFirstOrThrow(),
723
+ ),
724
+ needsReply: tryPromise(() =>
725
+ db
726
+ .selectFrom("dm_conversations")
727
+ .select((eb) => eb.fn.countAll().as("count"))
728
+ .where("needs_reply", "=", 1)
729
+ .executeTakeFirstOrThrow(),
730
+ ),
731
+ accounts: tryPromise(() =>
732
+ db
733
+ .selectFrom("accounts")
734
+ .selectAll()
735
+ .orderBy("is_default", "desc")
736
+ .orderBy("name", "asc")
737
+ .execute(),
738
+ ),
739
+ archives: findArchivesEffect(),
740
+ transport: getTransportStatusEffect(),
741
+ });
742
+
743
+ return {
744
+ stats: {
745
+ home: homeCount,
746
+ mentions: mentionCount,
747
+ dms: Number(counts.dms.count),
748
+ needsReply: Number(counts.needsReply.count),
749
+ inbox: mentionCount + Number(counts.needsReply.count),
750
+ },
751
+ accounts: counts.accounts.map((row) => {
752
+ const profile = getAccountProfileMeta(nativeDb, row);
753
+ return {
754
+ id: row.id,
755
+ name: row.name,
756
+ handle: row.handle,
757
+ externalUserId: row.external_user_id,
758
+ ...(profile
759
+ ? {
760
+ profileId: profile.id,
761
+ avatarHue: Number(profile.avatar_hue),
762
+ ...(profile.avatar_url
763
+ ? { avatarUrl: profile.avatar_url }
764
+ : {}),
765
+ }
766
+ : {}),
767
+ transport: row.transport,
768
+ isDefault: row.is_default,
769
+ createdAt: row.created_at,
770
+ };
771
+ }) satisfies AccountRecord[],
772
+ archives: counts.archives,
773
+ transport: counts.transport,
774
+ };
775
+ });
776
+ }
777
+
778
+ export function getQueryEnvelope(): Promise<QueryEnvelope> {
779
+ return runEffectPromise(getQueryEnvelopeEffect());
395
780
  }
396
781
 
397
782
  export function listTimelineItems({
@@ -414,13 +799,14 @@ export function listTimelineItems({
414
799
  const params: Array<string | number> = [];
415
800
  const normalizedLowQualityThreshold =
416
801
  normalizeLowQualityThreshold(lowQualityThreshold);
802
+ const shouldDedupeAcrossAccounts = !account || account === "all";
417
803
  let timelineEdgesCte = `
418
804
  with timeline_edges as (
419
- select account_id, tweet_id, kind
805
+ select account_id, tweet_id, kind, raw_json
420
806
  from tweet_account_edges
421
807
  where kind = ?
422
808
  union all
423
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
809
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
424
810
  from tweets legacy
425
811
  where legacy.kind = ?
426
812
  and not exists (
@@ -432,15 +818,28 @@ export function listTimelineItems({
432
818
  )
433
819
  )
434
820
  `;
821
+ const unwindowedTimelineEdgesCte = timelineEdgesCte;
822
+ let usedRecentEdgeWindow = false;
435
823
  let join = "";
436
824
  let where = "where t.kind = ?";
437
825
  let searchSnippetSelect = "";
438
826
 
827
+ const canUseRecentEdgeWindow =
828
+ !likedOnly &&
829
+ !bookmarkedOnly &&
830
+ !account &&
831
+ !search?.trim() &&
832
+ replyFilter === "all" &&
833
+ !since?.trim() &&
834
+ !until?.trim() &&
835
+ includeReplies &&
836
+ qualityFilter === "all";
837
+
439
838
  if (likedOnly || bookmarkedOnly) {
440
839
  if (likedOnly && bookmarkedOnly) {
441
840
  timelineEdgesCte = `
442
841
  with timeline_edges as (
443
- select likes.account_id, likes.tweet_id, 'home' as kind
842
+ select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json
444
843
  from tweet_collections likes
445
844
  join tweet_collections bookmarks
446
845
  on bookmarks.account_id = likes.account_id
@@ -448,7 +847,7 @@ export function listTimelineItems({
448
847
  and bookmarks.kind = 'bookmarks'
449
848
  where likes.kind = 'likes'
450
849
  union all
451
- select legacy.account_id, legacy.id as tweet_id, 'home' as kind
850
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
452
851
  from tweets legacy
453
852
  where legacy.liked = 1
454
853
  and legacy.bookmarked = 1
@@ -466,11 +865,11 @@ export function listTimelineItems({
466
865
  const legacyColumn = likedOnly ? "liked" : "bookmarked";
467
866
  timelineEdgesCte = `
468
867
  with timeline_edges as (
469
- select account_id, tweet_id, 'home' as kind
868
+ select account_id, tweet_id, 'home' as kind, raw_json
470
869
  from tweet_collections
471
870
  where kind = ?
472
871
  union all
473
- select legacy.account_id, legacy.id as tweet_id, 'home' as kind
872
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
474
873
  from tweets legacy
475
874
  where legacy.${legacyColumn} = 1
476
875
  and not exists (
@@ -481,10 +880,49 @@ export function listTimelineItems({
481
880
  and collection.kind = ?
482
881
  )
483
882
  )
484
- `;
883
+ `;
485
884
  params.push(collectionKind, collectionKind);
486
885
  }
487
886
  where = "where 1 = 1";
887
+ } else if (canUseRecentEdgeWindow) {
888
+ usedRecentEdgeWindow = true;
889
+ timelineEdgesCte = `
890
+ with timeline_edges as (
891
+ select account_id, tweet_id, kind, raw_json
892
+ from tweet_account_edges
893
+ where kind = ?
894
+ and tweet_id in (
895
+ select id
896
+ from tweets
897
+ order by created_at desc
898
+ limit ?
899
+ )
900
+ union all
901
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
902
+ from tweets legacy
903
+ where legacy.kind = ?
904
+ and legacy.id in (
905
+ select id
906
+ from tweets
907
+ order by created_at desc
908
+ limit ?
909
+ )
910
+ and not exists (
911
+ select 1
912
+ from tweet_account_edges edge
913
+ where edge.account_id = legacy.account_id
914
+ and edge.tweet_id = legacy.id
915
+ and edge.kind = legacy.kind
916
+ )
917
+ )
918
+ `;
919
+ const candidateLimit = Math.max(
920
+ RECENT_TIMELINE_EDGE_CANDIDATES,
921
+ limit * 50,
922
+ );
923
+ params.push(kind, candidateLimit, kind, candidateLimit);
924
+ where = "where e.kind = ?";
925
+ params.push(kind);
488
926
  } else {
489
927
  params.push(kind, kind);
490
928
  where = "where e.kind = ?";
@@ -496,6 +934,20 @@ export function listTimelineItems({
496
934
  params.push(account);
497
935
  }
498
936
 
937
+ if (shouldDedupeAcrossAccounts) {
938
+ where += `
939
+ and e.account_id = (
940
+ select e2.account_id
941
+ from timeline_edges e2
942
+ join accounts a2 on a2.id = e2.account_id
943
+ where e2.tweet_id = e.tweet_id
944
+ and e2.kind = e.kind
945
+ order by a2.is_default desc, e2.account_id asc
946
+ limit 1
947
+ )
948
+ `;
949
+ }
950
+
499
951
  where += buildReplyClause(replyFilter).replaceAll(
500
952
  "is_replied",
501
953
  "t.is_replied",
@@ -532,15 +984,14 @@ export function listTimelineItems({
532
984
 
533
985
  params.push(limit);
534
986
 
535
- const rows = db
536
- .prepare(
537
- `
538
- ${timelineEdgesCte}
987
+ const buildTimelineSelectSql = (timelineEdgesSql: string) => `
988
+ ${timelineEdgesSql}
539
989
  select
540
990
  t.id,
541
991
  e.account_id,
542
992
  a.handle as account_handle,
543
993
  e.kind,
994
+ e.raw_json as edge_raw_json,
544
995
  t.text,
545
996
  t.created_at,
546
997
  t.reply_to_id,
@@ -626,11 +1077,20 @@ export function listTimelineItems({
626
1077
  ${where}
627
1078
  order by t.created_at desc
628
1079
  limit ?
629
- `,
630
- )
1080
+ `;
1081
+
1082
+ let rows = db
1083
+ .prepare(buildTimelineSelectSql(timelineEdgesCte))
631
1084
  .all(...params) as Array<Record<string, unknown>>;
632
1085
 
1086
+ if (usedRecentEdgeWindow && rows.length < limit) {
1087
+ rows = db
1088
+ .prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
1089
+ .all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
1090
+ }
1091
+
633
1092
  const urlExpansionCache: UrlExpansionCache = new Map();
1093
+ const profileByHandleCache: ProfileByHandleCache = new Map();
634
1094
  return rows.map((row) => {
635
1095
  const author = {
636
1096
  id: String(row.profile_id),
@@ -644,45 +1104,49 @@ export function listTimelineItems({
644
1104
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
645
1105
  createdAt: String(row.profile_created_at),
646
1106
  };
1107
+ const rowProfiles: Record<string, ProfileRecord> = {
1108
+ [author.id]: author,
1109
+ ...(row.reply_profile_id
1110
+ ? {
1111
+ [String(row.reply_profile_id)]: toProfile({
1112
+ id: row.reply_profile_id,
1113
+ handle: row.reply_handle,
1114
+ display_name: row.reply_display_name,
1115
+ bio: row.reply_bio,
1116
+ followers_count: row.reply_followers_count,
1117
+ following_count: row.reply_following_count,
1118
+ avatar_hue: row.reply_avatar_hue,
1119
+ avatar_url: row.reply_avatar_url,
1120
+ created_at: row.reply_profile_created_at,
1121
+ }),
1122
+ }
1123
+ : {}),
1124
+ ...(row.quoted_profile_id
1125
+ ? {
1126
+ [String(row.quoted_profile_id)]: toProfile({
1127
+ id: row.quoted_profile_id,
1128
+ handle: row.quoted_handle,
1129
+ display_name: row.quoted_display_name,
1130
+ bio: row.quoted_bio,
1131
+ followers_count: row.quoted_followers_count,
1132
+ following_count: row.quoted_following_count,
1133
+ avatar_hue: row.quoted_avatar_hue,
1134
+ avatar_url: row.quoted_avatar_url,
1135
+ created_at: row.quoted_profile_created_at,
1136
+ }),
1137
+ }
1138
+ : {}),
1139
+ };
1140
+ const resolveProfileByHandle = (handle: string) =>
1141
+ getProfileByHandle(db, profileByHandleCache, handle, rowProfiles);
647
1142
  const text = String(row.text);
648
1143
  const entities = enrichTimelineEntities(
649
1144
  db,
650
1145
  urlExpansionCache,
651
1146
  text,
652
1147
  parseJsonField<TweetEntities>(row.entities_json, {}),
653
- {
654
- [author.id]: author,
655
- ...(row.reply_profile_id
656
- ? {
657
- [String(row.reply_profile_id)]: toProfile({
658
- id: row.reply_profile_id,
659
- handle: row.reply_handle,
660
- display_name: row.reply_display_name,
661
- bio: row.reply_bio,
662
- followers_count: row.reply_followers_count,
663
- following_count: row.reply_following_count,
664
- avatar_hue: row.reply_avatar_hue,
665
- avatar_url: row.reply_avatar_url,
666
- created_at: row.reply_profile_created_at,
667
- }),
668
- }
669
- : {}),
670
- ...(row.quoted_profile_id
671
- ? {
672
- [String(row.quoted_profile_id)]: toProfile({
673
- id: row.quoted_profile_id,
674
- handle: row.quoted_handle,
675
- display_name: row.quoted_display_name,
676
- bio: row.quoted_bio,
677
- followers_count: row.quoted_followers_count,
678
- following_count: row.quoted_following_count,
679
- avatar_hue: row.quoted_avatar_hue,
680
- avatar_url: row.quoted_avatar_url,
681
- created_at: row.quoted_profile_created_at,
682
- }),
683
- }
684
- : {}),
685
- },
1148
+ rowProfiles,
1149
+ resolveProfileByHandle,
686
1150
  );
687
1151
  const item = {
688
1152
  id: String(row.id),
@@ -704,8 +1168,26 @@ export function listTimelineItems({
704
1168
  author,
705
1169
  entities,
706
1170
  media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
707
- replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
708
- quotedTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "quoted_"),
1171
+ replyToTweet: buildEmbeddedTweet(
1172
+ db,
1173
+ urlExpansionCache,
1174
+ row,
1175
+ "reply_",
1176
+ resolveProfileByHandle,
1177
+ ),
1178
+ quotedTweet: buildEmbeddedTweet(
1179
+ db,
1180
+ urlExpansionCache,
1181
+ row,
1182
+ "quoted_",
1183
+ resolveProfileByHandle,
1184
+ ),
1185
+ retweetedTweet: buildRetweetedTweet(
1186
+ db,
1187
+ urlExpansionCache,
1188
+ row,
1189
+ resolveProfileByHandle,
1190
+ ),
709
1191
  };
710
1192
  return includeQualityReason
711
1193
  ? {
@@ -719,12 +1201,42 @@ export function listTimelineItems({
719
1201
  });
720
1202
  }
721
1203
 
722
- const conversationTweetSelect = `
1204
+ function conversationTweetSelect(accountId?: string) {
1205
+ const collectionStateSelect = accountId
1206
+ ? `
1207
+ case
1208
+ when exists (
1209
+ select 1 from tweet_collections collection
1210
+ where collection.account_id = ?
1211
+ and collection.tweet_id = t.id
1212
+ and collection.kind = 'bookmarks'
1213
+ ) then 1
1214
+ when t.account_id = ? and t.bookmarked = 1 then 1
1215
+ else 0
1216
+ end as bookmarked,
1217
+ case
1218
+ when exists (
1219
+ select 1 from tweet_collections collection
1220
+ where collection.account_id = ?
1221
+ and collection.tweet_id = t.id
1222
+ and collection.kind = 'likes'
1223
+ ) then 1
1224
+ when t.account_id = ? and t.liked = 1 then 1
1225
+ else 0
1226
+ end as liked,`
1227
+ : `
1228
+ t.bookmarked,
1229
+ t.liked,`;
1230
+ return `
723
1231
  select
724
1232
  t.id,
725
1233
  t.text,
726
1234
  t.created_at,
727
1235
  t.reply_to_id,
1236
+ t.is_replied,
1237
+ t.like_count,
1238
+ t.media_count,
1239
+ ${collectionStateSelect}
728
1240
  t.entities_json,
729
1241
  t.media_json,
730
1242
  p.id as profile_id,
@@ -739,17 +1251,29 @@ const conversationTweetSelect = `
739
1251
  from tweets t
740
1252
  join profiles p on p.id = t.author_profile_id
741
1253
  `;
1254
+ }
742
1255
 
743
1256
  function getTweetById(
744
1257
  db: Database,
745
1258
  urlExpansionCache: UrlExpansionCache,
746
1259
  tweetId: string,
1260
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
1261
+ accountId?: string,
747
1262
  ): EmbeddedTweet | null {
1263
+ const stateParams = accountId
1264
+ ? [accountId, accountId, accountId, accountId]
1265
+ : [];
748
1266
  const row = db
749
- .prepare(`${conversationTweetSelect} where t.id = ?`)
750
- .get(tweetId) as Record<string, unknown> | undefined;
1267
+ .prepare(`${conversationTweetSelect(accountId)} where t.id = ?`)
1268
+ .get(...stateParams, tweetId) as Record<string, unknown> | undefined;
751
1269
  if (!row) return null;
752
- return buildEmbeddedTweet(db, urlExpansionCache, row, "");
1270
+ return buildEmbeddedTweet(
1271
+ db,
1272
+ urlExpansionCache,
1273
+ row,
1274
+ "",
1275
+ resolveProfileByHandle,
1276
+ );
753
1277
  }
754
1278
 
755
1279
  function listTweetDescendants(
@@ -757,6 +1281,7 @@ function listTweetDescendants(
757
1281
  urlExpansionCache: UrlExpansionCache,
758
1282
  rootId: string,
759
1283
  limit: number,
1284
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
760
1285
  ) {
761
1286
  if (limit <= 0) return [];
762
1287
  const rows = db
@@ -772,7 +1297,7 @@ function listTweetDescendants(
772
1297
  join branch on child.reply_to_id = branch.id
773
1298
  where branch.depth < 8
774
1299
  )
775
- ${conversationTweetSelect}
1300
+ ${conversationTweetSelect()}
776
1301
  join branch on branch.id = t.id
777
1302
  where t.id != ?
778
1303
  order by t.created_at asc
@@ -782,7 +1307,15 @@ function listTweetDescendants(
782
1307
  .all(rootId, rootId, limit) as Array<Record<string, unknown>>;
783
1308
 
784
1309
  return rows
785
- .map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
1310
+ .map((row) =>
1311
+ buildEmbeddedTweet(
1312
+ db,
1313
+ urlExpansionCache,
1314
+ row,
1315
+ "",
1316
+ resolveProfileByHandle,
1317
+ ),
1318
+ )
786
1319
  .filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
787
1320
  }
788
1321
 
@@ -805,13 +1338,26 @@ export function getTweetConversation(
805
1338
  ): TweetConversationResponse | null {
806
1339
  const db = getNativeDb();
807
1340
  const urlExpansionCache: UrlExpansionCache = new Map();
808
- const anchor = getTweetById(db, urlExpansionCache, tweetId);
1341
+ const profileByHandleCache: ProfileByHandleCache = new Map();
1342
+ const resolveProfileByHandle = (handle: string) =>
1343
+ getProfileByHandle(db, profileByHandleCache, handle);
1344
+ const anchor = getTweetById(
1345
+ db,
1346
+ urlExpansionCache,
1347
+ tweetId,
1348
+ resolveProfileByHandle,
1349
+ );
809
1350
  if (!anchor) return null;
810
1351
 
811
1352
  const ancestors: EmbeddedTweet[] = [];
812
1353
  let current = anchor;
813
1354
  for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
814
- const parent = getTweetById(db, urlExpansionCache, current.replyToId);
1355
+ const parent = getTweetById(
1356
+ db,
1357
+ urlExpansionCache,
1358
+ current.replyToId,
1359
+ resolveProfileByHandle,
1360
+ );
815
1361
  if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
816
1362
  ancestors.push(parent);
817
1363
  current = parent;
@@ -832,6 +1378,7 @@ export function getTweetConversation(
832
1378
  urlExpansionCache,
833
1379
  anchor.id,
834
1380
  remainingAfterRequired,
1381
+ resolveProfileByHandle,
835
1382
  );
836
1383
  appendConversationTweets(items, seen, focusedDescendants, limit);
837
1384
 
@@ -841,6 +1388,7 @@ export function getTweetConversation(
841
1388
  urlExpansionCache,
842
1389
  root.id,
843
1390
  limit,
1391
+ resolveProfileByHandle,
844
1392
  );
845
1393
  appendConversationTweets(items, seen, ambientDescendants, limit);
846
1394
  }
@@ -856,9 +1404,12 @@ export function getTweetConversation(
856
1404
  export function listDmConversations({
857
1405
  account,
858
1406
  conversationIds,
1407
+ inbox = "all",
859
1408
  participant,
860
1409
  search,
861
1410
  replyFilter = "all",
1411
+ since,
1412
+ until,
862
1413
  minFollowers,
863
1414
  maxFollowers,
864
1415
  minInfluenceScore,
@@ -875,6 +1426,31 @@ export function listDmConversations({
875
1426
  let where = "where 1 = 1";
876
1427
  let searchSnippetSelect = "";
877
1428
  const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
1429
+ const influenceMinFollowers =
1430
+ typeof minInfluenceScore === "number"
1431
+ ? getMinFollowersForInfluenceScore(minInfluenceScore)
1432
+ : undefined;
1433
+ const influenceMaxFollowers =
1434
+ typeof maxInfluenceScore === "number"
1435
+ ? getMaxFollowersForInfluenceScore(maxInfluenceScore)
1436
+ : undefined;
1437
+ const effectiveMinFollowers =
1438
+ typeof minFollowers === "number" ||
1439
+ typeof influenceMinFollowers === "number"
1440
+ ? Math.max(minFollowers ?? 0, influenceMinFollowers ?? 0)
1441
+ : undefined;
1442
+ const effectiveMaxFollowers =
1443
+ typeof maxFollowers === "number" ||
1444
+ typeof influenceMaxFollowers === "number"
1445
+ ? Math.min(
1446
+ maxFollowers ?? Number.MAX_SAFE_INTEGER,
1447
+ influenceMaxFollowers ?? Number.MAX_SAFE_INTEGER,
1448
+ )
1449
+ : undefined;
1450
+ const orderBy =
1451
+ sort === "followers" || sort === "influence"
1452
+ ? "p.followers_count desc, c.last_message_at desc"
1453
+ : "c.last_message_at desc";
878
1454
 
879
1455
  if (account && account !== "all") {
880
1456
  where += " and a.id = ?";
@@ -886,6 +1462,12 @@ export function listDmConversations({
886
1462
  params.push(...conversationIds);
887
1463
  }
888
1464
 
1465
+ if (inbox === "accepted") {
1466
+ where += " and c.inbox_kind = 'accepted'";
1467
+ } else if (inbox === "requests") {
1468
+ where += " and c.inbox_kind = 'request'";
1469
+ }
1470
+
889
1471
  if (participant?.trim()) {
890
1472
  where += " and (p.handle like ? or p.display_name like ?)";
891
1473
  params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
@@ -897,14 +1479,23 @@ export function listDmConversations({
897
1479
  where += " and c.needs_reply = 1";
898
1480
  }
899
1481
 
900
- if (typeof minFollowers === "number") {
1482
+ if (since?.trim()) {
1483
+ where += " and c.last_message_at >= ?";
1484
+ params.push(since);
1485
+ }
1486
+ if (until?.trim()) {
1487
+ where += " and c.last_message_at < ?";
1488
+ params.push(until);
1489
+ }
1490
+
1491
+ if (typeof effectiveMinFollowers === "number") {
901
1492
  where += " and p.followers_count >= ?";
902
- params.push(minFollowers);
1493
+ params.push(effectiveMinFollowers);
903
1494
  }
904
1495
 
905
- if (typeof maxFollowers === "number") {
1496
+ if (typeof effectiveMaxFollowers === "number") {
906
1497
  where += " and p.followers_count <= ?";
907
- params.push(maxFollowers);
1498
+ params.push(effectiveMaxFollowers);
908
1499
  }
909
1500
 
910
1501
  if (ftsSearch) {
@@ -947,6 +1538,7 @@ export function listDmConversations({
947
1538
  c.account_id,
948
1539
  a.handle as account_handle,
949
1540
  c.title,
1541
+ c.inbox_kind,
950
1542
  c.last_message_at,
951
1543
  c.unread_count,
952
1544
  c.needs_reply,
@@ -977,7 +1569,7 @@ export function listDmConversations({
977
1569
  ${join}
978
1570
  ${where}
979
1571
  group by c.id
980
- order by c.last_message_at desc
1572
+ order by ${orderBy}
981
1573
  limit ?
982
1574
  `,
983
1575
  )
@@ -1014,6 +1606,8 @@ export function listDmConversations({
1014
1606
  ...(typeof row.search_snippet === "string"
1015
1607
  ? { searchSnippet: row.search_snippet }
1016
1608
  : {}),
1609
+ inboxKind: row.inbox_kind === "request" ? "request" : "accepted",
1610
+ isMessageRequest: row.inbox_kind === "request",
1017
1611
  lastMessageAt: String(row.last_message_at),
1018
1612
  lastMessagePreview: String(row.last_message_preview ?? ""),
1019
1613
  unreadCount: Number(row.unread_count),
@@ -1050,7 +1644,7 @@ export function listDmConversations({
1050
1644
  return true;
1051
1645
  });
1052
1646
 
1053
- if (sort === "influence") {
1647
+ if (sort === "followers" || sort === "influence") {
1054
1648
  filtered.sort((left, right) => {
1055
1649
  if (
1056
1650
  right.participant.followersCount !== left.participant.followersCount
@@ -1087,10 +1681,13 @@ export function listDmConversations({
1087
1681
 
1088
1682
  export function getConversationThread(
1089
1683
  conversationId: string,
1684
+ filters: Pick<DmQuery, "account"> = {},
1090
1685
  ): { conversation: DmConversationItem; messages: DmMessageItem[] } | null {
1091
- const conversation = listDmConversations({ limit: 100 }).find(
1092
- (item) => item.id === conversationId,
1093
- );
1686
+ const conversation = listDmConversations({
1687
+ ...filters,
1688
+ conversationIds: [conversationId],
1689
+ limit: 1,
1690
+ }).find((item) => item.id === conversationId);
1094
1691
 
1095
1692
  if (!conversation) {
1096
1693
  return null;
@@ -1150,6 +1747,54 @@ export function getConversationThread(
1150
1747
  };
1151
1748
  }
1152
1749
 
1750
+ export type DmRequestMutationAction = "accept" | "reject" | "block";
1751
+
1752
+ export function applyDmRequestMutationToLocalStore(
1753
+ conversationId: string,
1754
+ action: DmRequestMutationAction,
1755
+ ) {
1756
+ return persistWrite((db) => {
1757
+ db.prepare(
1758
+ "delete from sync_cache where cache_key like 'dms:bird:%'",
1759
+ ).run();
1760
+ if (action === "accept") {
1761
+ return db
1762
+ .prepare(
1763
+ `
1764
+ update dm_conversations
1765
+ set inbox_kind = 'accepted'
1766
+ where id = ?
1767
+ `,
1768
+ )
1769
+ .run(conversationId).changes;
1770
+ }
1771
+
1772
+ db.prepare(
1773
+ `
1774
+ delete from link_occurrences
1775
+ where source_kind = 'dm'
1776
+ and source_id in (
1777
+ select id from dm_messages where conversation_id = ?
1778
+ )
1779
+ `,
1780
+ ).run(conversationId);
1781
+ db.prepare(
1782
+ `
1783
+ delete from dm_fts
1784
+ where message_id in (
1785
+ select id from dm_messages where conversation_id = ?
1786
+ )
1787
+ `,
1788
+ ).run(conversationId);
1789
+ db.prepare("delete from dm_messages where conversation_id = ?").run(
1790
+ conversationId,
1791
+ );
1792
+ return db
1793
+ .prepare("delete from dm_conversations where id = ?")
1794
+ .run(conversationId).changes;
1795
+ });
1796
+ }
1797
+
1153
1798
  function normalizeDmContext(value: number | undefined) {
1154
1799
  if (typeof value !== "number" || !Number.isFinite(value)) {
1155
1800
  return 0;
@@ -1316,12 +1961,19 @@ export function queryResource(
1316
1961
  if (resource === "dms") {
1317
1962
  const dmFilters = filters as DmQuery & { conversationId?: string };
1318
1963
  const items = listDmConversations(dmFilters);
1319
- const selectedConversationId = dmFilters.conversationId ?? items[0]?.id;
1964
+ const requestedConversationId = dmFilters.conversationId;
1965
+ const selectedConversationId =
1966
+ requestedConversationId &&
1967
+ items.some((item) => item.id === requestedConversationId)
1968
+ ? requestedConversationId
1969
+ : items[0]?.id;
1320
1970
  return {
1321
1971
  resource,
1322
1972
  items,
1323
1973
  selectedConversation: selectedConversationId
1324
- ? getConversationThread(selectedConversationId)
1974
+ ? getConversationThread(selectedConversationId, {
1975
+ account: dmFilters.account,
1976
+ })
1325
1977
  : null,
1326
1978
  };
1327
1979
  }
@@ -1342,16 +1994,36 @@ function refreshDmConversationState(
1342
1994
  db: Database,
1343
1995
  conversationId: string,
1344
1996
  lastMessageAt: string,
1997
+ observedLastMessageAt = lastMessageAt,
1345
1998
  ) {
1346
1999
  db.prepare(
1347
2000
  `
1348
2001
  update dm_conversations
1349
- set last_message_at = ?,
1350
- unread_count = 0,
1351
- needs_reply = 0
2002
+ set last_message_at = case
2003
+ when last_message_at < ? then ?
2004
+ else last_message_at
2005
+ end,
2006
+ unread_count = case
2007
+ when last_message_at = ? then 0
2008
+ when last_message_at <= ? then 0
2009
+ else unread_count
2010
+ end,
2011
+ needs_reply = case
2012
+ when last_message_at = ? then 0
2013
+ when last_message_at <= ? then 0
2014
+ else needs_reply
2015
+ end
1352
2016
  where id = ?
1353
2017
  `,
1354
- ).run(lastMessageAt, conversationId);
2018
+ ).run(
2019
+ lastMessageAt,
2020
+ lastMessageAt,
2021
+ observedLastMessageAt,
2022
+ lastMessageAt,
2023
+ observedLastMessageAt,
2024
+ lastMessageAt,
2025
+ conversationId,
2026
+ );
1355
2027
  }
1356
2028
 
1357
2029
  function getLocalAuthorProfileId(accountId: string) {
@@ -1370,16 +2042,60 @@ function getLocalAuthorProfileId(accountId: string) {
1370
2042
  return row?.id;
1371
2043
  }
1372
2044
 
1373
- export async function createPost(accountId: string, text: string) {
2045
+ let savepointCounter = 0;
2046
+
2047
+ function preflightWrite<T>(write: (db: Database) => T) {
2048
+ const db = getNativeDb();
2049
+ const savepoint = `__birdclaw_preflight_${++savepointCounter}`;
2050
+ db.exec(`savepoint ${savepoint}`);
2051
+ try {
2052
+ const result = write(db);
2053
+ db.exec(`rollback to ${savepoint}`);
2054
+ db.exec(`release ${savepoint}`);
2055
+ return result;
2056
+ } catch (error) {
2057
+ try {
2058
+ db.exec(`rollback to ${savepoint}`);
2059
+ db.exec(`release ${savepoint}`);
2060
+ } catch {
2061
+ // Preserve the original staging error; cleanup is best effort here.
2062
+ }
2063
+ throw error;
2064
+ }
2065
+ }
2066
+
2067
+ function persistWrite<T>(write: (db: Database) => T) {
1374
2068
  const db = getNativeDb();
2069
+ return db.transaction(() => write(db))();
2070
+ }
2071
+
2072
+ type PostDraft = {
2073
+ actionId: string;
2074
+ authorProfileId: string;
2075
+ createdAt: string;
2076
+ tweetId: string;
2077
+ };
2078
+
2079
+ function preparePostDraft(accountId: string): PostDraft {
1375
2080
  const authorProfileId = getLocalAuthorProfileId(accountId);
1376
2081
  if (!authorProfileId) {
1377
2082
  throw new Error("No local author profile for account");
1378
2083
  }
1379
2084
 
1380
- const now = new Date().toISOString();
1381
- const tweetId = `tweet_${randomUUID()}`;
2085
+ return {
2086
+ actionId: randomUUID(),
2087
+ authorProfileId,
2088
+ createdAt: new Date().toISOString(),
2089
+ tweetId: `tweet_${randomUUID()}`,
2090
+ };
2091
+ }
1382
2092
 
2093
+ function writePostDraft(
2094
+ db: Database,
2095
+ accountId: string,
2096
+ text: string,
2097
+ draft: PostDraft,
2098
+ ) {
1383
2099
  db.prepare(
1384
2100
  `
1385
2101
  insert into tweets (
@@ -1387,88 +2103,207 @@ export async function createPost(accountId: string, text: string) {
1387
2103
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked
1388
2104
  ) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
1389
2105
  `,
1390
- ).run(tweetId, accountId, authorProfileId, text, now);
2106
+ ).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
1391
2107
 
1392
2108
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
1393
- tweetId,
2109
+ draft.tweetId,
1394
2110
  text,
1395
2111
  );
1396
2112
  db.prepare(
1397
2113
  "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
1398
- ).run(randomUUID(), accountId, tweetId, "post", text, now);
2114
+ ).run(
2115
+ draft.actionId,
2116
+ accountId,
2117
+ draft.tweetId,
2118
+ "post",
2119
+ text,
2120
+ draft.createdAt,
2121
+ );
2122
+ }
1399
2123
 
1400
- const transport = await postViaXurl(text);
1401
- return { ok: true, transport, tweetId };
2124
+ export function createPostEffect(accountId: string, text: string) {
2125
+ return Effect.gen(function* () {
2126
+ const draft = yield* trySync(() => {
2127
+ const postDraft = preparePostDraft(accountId);
2128
+ preflightWrite((db) => writePostDraft(db, accountId, text, postDraft));
2129
+ return postDraft;
2130
+ });
2131
+
2132
+ yield* verifySelectedXurlAccountEffect(accountId);
2133
+ const transport = yield* postViaXurlEffect(text);
2134
+ if (!transport.ok) {
2135
+ return yield* Effect.fail(new Error(transport.output || "post failed"));
2136
+ }
2137
+ yield* trySync(() =>
2138
+ persistWrite((db) => writePostDraft(db, accountId, text, draft)),
2139
+ );
2140
+
2141
+ return { ok: true, transport, tweetId: draft.tweetId };
2142
+ });
2143
+ }
2144
+
2145
+ export function createPost(accountId: string, text: string) {
2146
+ return runEffectPromise(createPostEffect(accountId, text));
1402
2147
  }
1403
2148
 
1404
- export async function createTweetReply(
2149
+ export function createTweetReplyEffect(
1405
2150
  accountId: string,
1406
2151
  tweetId: string,
1407
2152
  text: string,
1408
2153
  ) {
1409
- const db = getNativeDb();
1410
- const authorProfileId = getLocalAuthorProfileId(accountId);
1411
- if (!authorProfileId) {
1412
- throw new Error("No local author profile for account");
2154
+ type ReplyDraft = PostDraft & { replyId: string };
2155
+
2156
+ function prepareReplyDraft(): ReplyDraft {
2157
+ const postDraft = preparePostDraft(accountId);
2158
+ return {
2159
+ ...postDraft,
2160
+ replyId: postDraft.tweetId,
2161
+ };
1413
2162
  }
1414
2163
 
1415
- const now = new Date().toISOString();
1416
- db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
2164
+ function writeReplyDraft(db: Database, draft: ReplyDraft) {
2165
+ db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
1417
2166
 
1418
- const replyId = `tweet_${randomUUID()}`;
1419
- db.prepare(
1420
- `
2167
+ db.prepare(
2168
+ `
1421
2169
  insert into tweets (
1422
2170
  id, account_id, author_profile_id, kind, text, created_at,
1423
2171
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked
1424
2172
  ) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
1425
2173
  `,
1426
- ).run(replyId, accountId, authorProfileId, text, now, tweetId);
1427
- db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
1428
- replyId,
1429
- text,
1430
- );
2174
+ ).run(
2175
+ draft.replyId,
2176
+ accountId,
2177
+ draft.authorProfileId,
2178
+ text,
2179
+ draft.createdAt,
2180
+ tweetId,
2181
+ );
2182
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
2183
+ draft.replyId,
2184
+ text,
2185
+ );
1431
2186
 
1432
- db.prepare(
1433
- "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
1434
- ).run(randomUUID(), accountId, tweetId, "reply", text, now);
2187
+ db.prepare(
2188
+ "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
2189
+ ).run(draft.actionId, accountId, tweetId, "reply", text, draft.createdAt);
2190
+ }
2191
+
2192
+ return Effect.gen(function* () {
2193
+ const draft = yield* trySync(() => {
2194
+ const replyDraft = prepareReplyDraft();
2195
+ preflightWrite((db) => writeReplyDraft(db, replyDraft));
2196
+ return replyDraft;
2197
+ });
2198
+
2199
+ yield* verifySelectedXurlAccountEffect(accountId);
2200
+ const transport = yield* replyViaXurlEffect(tweetId, text);
2201
+ if (!transport.ok) {
2202
+ return yield* Effect.fail(new Error(transport.output || "reply failed"));
2203
+ }
2204
+ yield* trySync(() => persistWrite((db) => writeReplyDraft(db, draft)));
2205
+
2206
+ return { ok: true, transport, replyId: draft.replyId };
2207
+ });
2208
+ }
1435
2209
 
1436
- const transport = await replyViaXurl(tweetId, text);
1437
- return { ok: true, transport, replyId };
2210
+ export function createTweetReply(
2211
+ accountId: string,
2212
+ tweetId: string,
2213
+ text: string,
2214
+ ) {
2215
+ return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
1438
2216
  }
1439
2217
 
1440
- export async function createDmReply(conversationId: string, text: string) {
1441
- const db = getNativeDb();
1442
- const conversation = getConversationThread(conversationId);
1443
- if (!conversation) {
1444
- throw new Error("Conversation not found");
1445
- }
1446
- const authorProfileId = getLocalAuthorProfileId(
1447
- conversation.conversation.accountId,
1448
- );
1449
- if (!authorProfileId) {
1450
- throw new Error("No local author profile for account");
1451
- }
2218
+ export function createDmReplyEffect(conversationId: string, text: string) {
2219
+ return Effect.gen(function* () {
2220
+ const draft = yield* trySync(() => {
2221
+ const conversation = getConversationThread(conversationId);
2222
+ if (!conversation) {
2223
+ throw new Error("Conversation not found");
2224
+ }
2225
+ const authorProfileId = getLocalAuthorProfileId(
2226
+ conversation.conversation.accountId,
2227
+ );
2228
+ if (!authorProfileId) {
2229
+ throw new Error("No local author profile for account");
2230
+ }
1452
2231
 
1453
- const now = new Date().toISOString();
1454
- const outboundId = `msg_${randomUUID()}`;
2232
+ const dmDraft = {
2233
+ accountId: conversation.conversation.accountId,
2234
+ authorProfileId,
2235
+ createdAt: new Date().toISOString(),
2236
+ handle: conversation.conversation.participant.handle,
2237
+ observedLastMessageAt: conversation.conversation.lastMessageAt,
2238
+ outboundId: `msg_${randomUUID()}`,
2239
+ };
2240
+ preflightWrite((db) => {
2241
+ db.prepare(
2242
+ `
2243
+ insert into dm_messages (
2244
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
2245
+ ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
2246
+ `,
2247
+ ).run(
2248
+ dmDraft.outboundId,
2249
+ conversationId,
2250
+ dmDraft.authorProfileId,
2251
+ text,
2252
+ dmDraft.createdAt,
2253
+ );
2254
+ db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
2255
+ dmDraft.outboundId,
2256
+ text,
2257
+ );
1455
2258
 
1456
- db.prepare(
1457
- `
2259
+ refreshDmConversationState(
2260
+ db,
2261
+ conversationId,
2262
+ dmDraft.createdAt,
2263
+ dmDraft.observedLastMessageAt,
2264
+ );
2265
+ });
2266
+ return dmDraft;
2267
+ });
2268
+
2269
+ yield* verifySelectedXurlAccountEffect(draft.accountId);
2270
+ const transport = yield* dmViaXurlEffect(draft.handle, text);
2271
+ if (!transport.ok) {
2272
+ return yield* Effect.fail(new Error(transport.output || "dm failed"));
2273
+ }
2274
+ yield* trySync(() =>
2275
+ persistWrite((db) => {
2276
+ db.prepare(
2277
+ `
1458
2278
  insert into dm_messages (
1459
2279
  id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1460
2280
  ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
1461
2281
  `,
1462
- ).run(outboundId, conversationId, authorProfileId, text, now);
1463
- db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
1464
- outboundId,
1465
- text,
1466
- );
2282
+ ).run(
2283
+ draft.outboundId,
2284
+ conversationId,
2285
+ draft.authorProfileId,
2286
+ text,
2287
+ draft.createdAt,
2288
+ );
2289
+ db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
2290
+ draft.outboundId,
2291
+ text,
2292
+ );
1467
2293
 
1468
- refreshDmConversationState(db, conversationId, now);
1469
- const transport = await dmViaXurl(
1470
- conversation.conversation.participant.handle,
1471
- text,
1472
- );
1473
- return { ok: true, transport, messageId: outboundId };
2294
+ refreshDmConversationState(
2295
+ db,
2296
+ conversationId,
2297
+ draft.createdAt,
2298
+ draft.observedLastMessageAt,
2299
+ );
2300
+ }),
2301
+ );
2302
+
2303
+ return { ok: true, transport, messageId: draft.outboundId };
2304
+ });
2305
+ }
2306
+
2307
+ export function createDmReply(conversationId: string, text: string) {
2308
+ return runEffectPromise(createDmReplyEffect(conversationId, text));
1474
2309
  }