birdclaw 0.5.1 → 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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -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,31 +643,28 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
321
643
  const row = db
322
644
  .prepare(
323
645
  `
324
- select (
325
- (
326
- select count(*)
327
- from tweet_account_edges edge
328
- where edge.kind = ?
329
- and exists (
330
- select 1
331
- from tweets t
332
- where t.id = edge.tweet_id
333
- )
334
- )
335
- +
336
- (
337
- select count(*)
338
- from tweets legacy
339
- where legacy.kind = ?
340
- and not exists (
341
- select 1
342
- from tweet_account_edges edge
343
- where edge.account_id = legacy.account_id
344
- and edge.tweet_id = legacy.id
345
- and edge.kind = legacy.kind
346
- )
347
- )
348
- ) as count
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
+ )
656
+ union all
657
+ select legacy.id as tweet_id
658
+ from tweets legacy
659
+ where legacy.kind = ?
660
+ and not exists (
661
+ select 1
662
+ from tweet_account_edges edge
663
+ where edge.account_id = legacy.account_id
664
+ and edge.tweet_id = legacy.id
665
+ and edge.kind = legacy.kind
666
+ )
667
+ )
349
668
  `,
350
669
  )
351
670
  .get(kind, kind) as { count: number | bigint } | undefined;
@@ -354,51 +673,110 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
354
673
 
355
674
  const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
356
675
 
357
- export async function getQueryEnvelope(): Promise<QueryEnvelope> {
358
- const db = getDb();
359
- const nativeDb = getNativeDb();
360
- const homeCount = countTimelineEdges(nativeDb, "home");
361
- const mentionCount = countTimelineEdges(nativeDb, "mention");
362
- const counts = await Promise.all([
363
- db
364
- .selectFrom("dm_conversations")
365
- .select((eb) => eb.fn.countAll().as("count"))
366
- .executeTakeFirstOrThrow(),
367
- db
368
- .selectFrom("dm_conversations")
369
- .select((eb) => eb.fn.countAll().as("count"))
370
- .where("needs_reply", "=", 1)
371
- .executeTakeFirstOrThrow(),
372
- db
373
- .selectFrom("accounts")
374
- .selectAll()
375
- .orderBy("is_default", "desc")
376
- .orderBy("name", "asc")
377
- .execute(),
378
- findArchives(),
379
- getTransportStatus(),
380
- ]);
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
+ });
381
742
 
382
- return {
383
- stats: {
384
- home: homeCount,
385
- mentions: mentionCount,
386
- dms: Number(counts[0].count),
387
- needsReply: Number(counts[1].count),
388
- inbox: mentionCount + Number(counts[1].count),
389
- },
390
- accounts: counts[2].map((row) => ({
391
- id: row.id,
392
- name: row.name,
393
- handle: row.handle,
394
- externalUserId: row.external_user_id,
395
- transport: row.transport,
396
- isDefault: row.is_default,
397
- createdAt: row.created_at,
398
- })) satisfies AccountRecord[],
399
- archives: counts[3],
400
- transport: counts[4],
401
- };
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());
402
780
  }
403
781
 
404
782
  export function listTimelineItems({
@@ -421,13 +799,14 @@ export function listTimelineItems({
421
799
  const params: Array<string | number> = [];
422
800
  const normalizedLowQualityThreshold =
423
801
  normalizeLowQualityThreshold(lowQualityThreshold);
802
+ const shouldDedupeAcrossAccounts = !account || account === "all";
424
803
  let timelineEdgesCte = `
425
804
  with timeline_edges as (
426
- select account_id, tweet_id, kind
805
+ select account_id, tweet_id, kind, raw_json
427
806
  from tweet_account_edges
428
807
  where kind = ?
429
808
  union all
430
- 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
431
810
  from tweets legacy
432
811
  where legacy.kind = ?
433
812
  and not exists (
@@ -460,7 +839,7 @@ export function listTimelineItems({
460
839
  if (likedOnly && bookmarkedOnly) {
461
840
  timelineEdgesCte = `
462
841
  with timeline_edges as (
463
- select likes.account_id, likes.tweet_id, 'home' as kind
842
+ select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json
464
843
  from tweet_collections likes
465
844
  join tweet_collections bookmarks
466
845
  on bookmarks.account_id = likes.account_id
@@ -468,7 +847,7 @@ export function listTimelineItems({
468
847
  and bookmarks.kind = 'bookmarks'
469
848
  where likes.kind = 'likes'
470
849
  union all
471
- 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
472
851
  from tweets legacy
473
852
  where legacy.liked = 1
474
853
  and legacy.bookmarked = 1
@@ -486,11 +865,11 @@ export function listTimelineItems({
486
865
  const legacyColumn = likedOnly ? "liked" : "bookmarked";
487
866
  timelineEdgesCte = `
488
867
  with timeline_edges as (
489
- select account_id, tweet_id, 'home' as kind
868
+ select account_id, tweet_id, 'home' as kind, raw_json
490
869
  from tweet_collections
491
870
  where kind = ?
492
871
  union all
493
- 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
494
873
  from tweets legacy
495
874
  where legacy.${legacyColumn} = 1
496
875
  and not exists (
@@ -509,7 +888,7 @@ export function listTimelineItems({
509
888
  usedRecentEdgeWindow = true;
510
889
  timelineEdgesCte = `
511
890
  with timeline_edges as (
512
- select account_id, tweet_id, kind
891
+ select account_id, tweet_id, kind, raw_json
513
892
  from tweet_account_edges
514
893
  where kind = ?
515
894
  and tweet_id in (
@@ -519,7 +898,7 @@ export function listTimelineItems({
519
898
  limit ?
520
899
  )
521
900
  union all
522
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
901
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
523
902
  from tweets legacy
524
903
  where legacy.kind = ?
525
904
  and legacy.id in (
@@ -555,6 +934,20 @@ export function listTimelineItems({
555
934
  params.push(account);
556
935
  }
557
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
+
558
951
  where += buildReplyClause(replyFilter).replaceAll(
559
952
  "is_replied",
560
953
  "t.is_replied",
@@ -598,6 +991,7 @@ export function listTimelineItems({
598
991
  e.account_id,
599
992
  a.handle as account_handle,
600
993
  e.kind,
994
+ e.raw_json as edge_raw_json,
601
995
  t.text,
602
996
  t.created_at,
603
997
  t.reply_to_id,
@@ -696,6 +1090,7 @@ export function listTimelineItems({
696
1090
  }
697
1091
 
698
1092
  const urlExpansionCache: UrlExpansionCache = new Map();
1093
+ const profileByHandleCache: ProfileByHandleCache = new Map();
699
1094
  return rows.map((row) => {
700
1095
  const author = {
701
1096
  id: String(row.profile_id),
@@ -709,45 +1104,49 @@ export function listTimelineItems({
709
1104
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
710
1105
  createdAt: String(row.profile_created_at),
711
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);
712
1142
  const text = String(row.text);
713
1143
  const entities = enrichTimelineEntities(
714
1144
  db,
715
1145
  urlExpansionCache,
716
1146
  text,
717
1147
  parseJsonField<TweetEntities>(row.entities_json, {}),
718
- {
719
- [author.id]: author,
720
- ...(row.reply_profile_id
721
- ? {
722
- [String(row.reply_profile_id)]: toProfile({
723
- id: row.reply_profile_id,
724
- handle: row.reply_handle,
725
- display_name: row.reply_display_name,
726
- bio: row.reply_bio,
727
- followers_count: row.reply_followers_count,
728
- following_count: row.reply_following_count,
729
- avatar_hue: row.reply_avatar_hue,
730
- avatar_url: row.reply_avatar_url,
731
- created_at: row.reply_profile_created_at,
732
- }),
733
- }
734
- : {}),
735
- ...(row.quoted_profile_id
736
- ? {
737
- [String(row.quoted_profile_id)]: toProfile({
738
- id: row.quoted_profile_id,
739
- handle: row.quoted_handle,
740
- display_name: row.quoted_display_name,
741
- bio: row.quoted_bio,
742
- followers_count: row.quoted_followers_count,
743
- following_count: row.quoted_following_count,
744
- avatar_hue: row.quoted_avatar_hue,
745
- avatar_url: row.quoted_avatar_url,
746
- created_at: row.quoted_profile_created_at,
747
- }),
748
- }
749
- : {}),
750
- },
1148
+ rowProfiles,
1149
+ resolveProfileByHandle,
751
1150
  );
752
1151
  const item = {
753
1152
  id: String(row.id),
@@ -769,8 +1168,26 @@ export function listTimelineItems({
769
1168
  author,
770
1169
  entities,
771
1170
  media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
772
- replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
773
- 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
+ ),
774
1191
  };
775
1192
  return includeQualityReason
776
1193
  ? {
@@ -784,12 +1201,42 @@ export function listTimelineItems({
784
1201
  });
785
1202
  }
786
1203
 
787
- 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 `
788
1231
  select
789
1232
  t.id,
790
1233
  t.text,
791
1234
  t.created_at,
792
1235
  t.reply_to_id,
1236
+ t.is_replied,
1237
+ t.like_count,
1238
+ t.media_count,
1239
+ ${collectionStateSelect}
793
1240
  t.entities_json,
794
1241
  t.media_json,
795
1242
  p.id as profile_id,
@@ -804,17 +1251,29 @@ const conversationTweetSelect = `
804
1251
  from tweets t
805
1252
  join profiles p on p.id = t.author_profile_id
806
1253
  `;
1254
+ }
807
1255
 
808
1256
  function getTweetById(
809
1257
  db: Database,
810
1258
  urlExpansionCache: UrlExpansionCache,
811
1259
  tweetId: string,
1260
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
1261
+ accountId?: string,
812
1262
  ): EmbeddedTweet | null {
1263
+ const stateParams = accountId
1264
+ ? [accountId, accountId, accountId, accountId]
1265
+ : [];
813
1266
  const row = db
814
- .prepare(`${conversationTweetSelect} where t.id = ?`)
815
- .get(tweetId) as Record<string, unknown> | undefined;
1267
+ .prepare(`${conversationTweetSelect(accountId)} where t.id = ?`)
1268
+ .get(...stateParams, tweetId) as Record<string, unknown> | undefined;
816
1269
  if (!row) return null;
817
- return buildEmbeddedTweet(db, urlExpansionCache, row, "");
1270
+ return buildEmbeddedTweet(
1271
+ db,
1272
+ urlExpansionCache,
1273
+ row,
1274
+ "",
1275
+ resolveProfileByHandle,
1276
+ );
818
1277
  }
819
1278
 
820
1279
  function listTweetDescendants(
@@ -822,6 +1281,7 @@ function listTweetDescendants(
822
1281
  urlExpansionCache: UrlExpansionCache,
823
1282
  rootId: string,
824
1283
  limit: number,
1284
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
825
1285
  ) {
826
1286
  if (limit <= 0) return [];
827
1287
  const rows = db
@@ -837,7 +1297,7 @@ function listTweetDescendants(
837
1297
  join branch on child.reply_to_id = branch.id
838
1298
  where branch.depth < 8
839
1299
  )
840
- ${conversationTweetSelect}
1300
+ ${conversationTweetSelect()}
841
1301
  join branch on branch.id = t.id
842
1302
  where t.id != ?
843
1303
  order by t.created_at asc
@@ -847,7 +1307,15 @@ function listTweetDescendants(
847
1307
  .all(rootId, rootId, limit) as Array<Record<string, unknown>>;
848
1308
 
849
1309
  return rows
850
- .map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
1310
+ .map((row) =>
1311
+ buildEmbeddedTweet(
1312
+ db,
1313
+ urlExpansionCache,
1314
+ row,
1315
+ "",
1316
+ resolveProfileByHandle,
1317
+ ),
1318
+ )
851
1319
  .filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
852
1320
  }
853
1321
 
@@ -870,13 +1338,26 @@ export function getTweetConversation(
870
1338
  ): TweetConversationResponse | null {
871
1339
  const db = getNativeDb();
872
1340
  const urlExpansionCache: UrlExpansionCache = new Map();
873
- 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
+ );
874
1350
  if (!anchor) return null;
875
1351
 
876
1352
  const ancestors: EmbeddedTweet[] = [];
877
1353
  let current = anchor;
878
1354
  for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
879
- const parent = getTweetById(db, urlExpansionCache, current.replyToId);
1355
+ const parent = getTweetById(
1356
+ db,
1357
+ urlExpansionCache,
1358
+ current.replyToId,
1359
+ resolveProfileByHandle,
1360
+ );
880
1361
  if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
881
1362
  ancestors.push(parent);
882
1363
  current = parent;
@@ -897,6 +1378,7 @@ export function getTweetConversation(
897
1378
  urlExpansionCache,
898
1379
  anchor.id,
899
1380
  remainingAfterRequired,
1381
+ resolveProfileByHandle,
900
1382
  );
901
1383
  appendConversationTweets(items, seen, focusedDescendants, limit);
902
1384
 
@@ -906,6 +1388,7 @@ export function getTweetConversation(
906
1388
  urlExpansionCache,
907
1389
  root.id,
908
1390
  limit,
1391
+ resolveProfileByHandle,
909
1392
  );
910
1393
  appendConversationTweets(items, seen, ambientDescendants, limit);
911
1394
  }
@@ -921,9 +1404,12 @@ export function getTweetConversation(
921
1404
  export function listDmConversations({
922
1405
  account,
923
1406
  conversationIds,
1407
+ inbox = "all",
924
1408
  participant,
925
1409
  search,
926
1410
  replyFilter = "all",
1411
+ since,
1412
+ until,
927
1413
  minFollowers,
928
1414
  maxFollowers,
929
1415
  minInfluenceScore,
@@ -940,6 +1426,31 @@ export function listDmConversations({
940
1426
  let where = "where 1 = 1";
941
1427
  let searchSnippetSelect = "";
942
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";
943
1454
 
944
1455
  if (account && account !== "all") {
945
1456
  where += " and a.id = ?";
@@ -951,6 +1462,12 @@ export function listDmConversations({
951
1462
  params.push(...conversationIds);
952
1463
  }
953
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
+
954
1471
  if (participant?.trim()) {
955
1472
  where += " and (p.handle like ? or p.display_name like ?)";
956
1473
  params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
@@ -962,14 +1479,23 @@ export function listDmConversations({
962
1479
  where += " and c.needs_reply = 1";
963
1480
  }
964
1481
 
965
- 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") {
966
1492
  where += " and p.followers_count >= ?";
967
- params.push(minFollowers);
1493
+ params.push(effectiveMinFollowers);
968
1494
  }
969
1495
 
970
- if (typeof maxFollowers === "number") {
1496
+ if (typeof effectiveMaxFollowers === "number") {
971
1497
  where += " and p.followers_count <= ?";
972
- params.push(maxFollowers);
1498
+ params.push(effectiveMaxFollowers);
973
1499
  }
974
1500
 
975
1501
  if (ftsSearch) {
@@ -1012,6 +1538,7 @@ export function listDmConversations({
1012
1538
  c.account_id,
1013
1539
  a.handle as account_handle,
1014
1540
  c.title,
1541
+ c.inbox_kind,
1015
1542
  c.last_message_at,
1016
1543
  c.unread_count,
1017
1544
  c.needs_reply,
@@ -1042,7 +1569,7 @@ export function listDmConversations({
1042
1569
  ${join}
1043
1570
  ${where}
1044
1571
  group by c.id
1045
- order by c.last_message_at desc
1572
+ order by ${orderBy}
1046
1573
  limit ?
1047
1574
  `,
1048
1575
  )
@@ -1079,6 +1606,8 @@ export function listDmConversations({
1079
1606
  ...(typeof row.search_snippet === "string"
1080
1607
  ? { searchSnippet: row.search_snippet }
1081
1608
  : {}),
1609
+ inboxKind: row.inbox_kind === "request" ? "request" : "accepted",
1610
+ isMessageRequest: row.inbox_kind === "request",
1082
1611
  lastMessageAt: String(row.last_message_at),
1083
1612
  lastMessagePreview: String(row.last_message_preview ?? ""),
1084
1613
  unreadCount: Number(row.unread_count),
@@ -1115,7 +1644,7 @@ export function listDmConversations({
1115
1644
  return true;
1116
1645
  });
1117
1646
 
1118
- if (sort === "influence") {
1647
+ if (sort === "followers" || sort === "influence") {
1119
1648
  filtered.sort((left, right) => {
1120
1649
  if (
1121
1650
  right.participant.followersCount !== left.participant.followersCount
@@ -1152,10 +1681,13 @@ export function listDmConversations({
1152
1681
 
1153
1682
  export function getConversationThread(
1154
1683
  conversationId: string,
1684
+ filters: Pick<DmQuery, "account"> = {},
1155
1685
  ): { conversation: DmConversationItem; messages: DmMessageItem[] } | null {
1156
- const conversation = listDmConversations({ limit: 100 }).find(
1157
- (item) => item.id === conversationId,
1158
- );
1686
+ const conversation = listDmConversations({
1687
+ ...filters,
1688
+ conversationIds: [conversationId],
1689
+ limit: 1,
1690
+ }).find((item) => item.id === conversationId);
1159
1691
 
1160
1692
  if (!conversation) {
1161
1693
  return null;
@@ -1215,6 +1747,54 @@ export function getConversationThread(
1215
1747
  };
1216
1748
  }
1217
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
+
1218
1798
  function normalizeDmContext(value: number | undefined) {
1219
1799
  if (typeof value !== "number" || !Number.isFinite(value)) {
1220
1800
  return 0;
@@ -1381,12 +1961,19 @@ export function queryResource(
1381
1961
  if (resource === "dms") {
1382
1962
  const dmFilters = filters as DmQuery & { conversationId?: string };
1383
1963
  const items = listDmConversations(dmFilters);
1384
- 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;
1385
1970
  return {
1386
1971
  resource,
1387
1972
  items,
1388
1973
  selectedConversation: selectedConversationId
1389
- ? getConversationThread(selectedConversationId)
1974
+ ? getConversationThread(selectedConversationId, {
1975
+ account: dmFilters.account,
1976
+ })
1390
1977
  : null,
1391
1978
  };
1392
1979
  }
@@ -1407,16 +1994,36 @@ function refreshDmConversationState(
1407
1994
  db: Database,
1408
1995
  conversationId: string,
1409
1996
  lastMessageAt: string,
1997
+ observedLastMessageAt = lastMessageAt,
1410
1998
  ) {
1411
1999
  db.prepare(
1412
2000
  `
1413
2001
  update dm_conversations
1414
- set last_message_at = ?,
1415
- unread_count = 0,
1416
- 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
1417
2016
  where id = ?
1418
2017
  `,
1419
- ).run(lastMessageAt, conversationId);
2018
+ ).run(
2019
+ lastMessageAt,
2020
+ lastMessageAt,
2021
+ observedLastMessageAt,
2022
+ lastMessageAt,
2023
+ observedLastMessageAt,
2024
+ lastMessageAt,
2025
+ conversationId,
2026
+ );
1420
2027
  }
1421
2028
 
1422
2029
  function getLocalAuthorProfileId(accountId: string) {
@@ -1435,16 +2042,60 @@ function getLocalAuthorProfileId(accountId: string) {
1435
2042
  return row?.id;
1436
2043
  }
1437
2044
 
1438
- 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) {
1439
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 {
1440
2080
  const authorProfileId = getLocalAuthorProfileId(accountId);
1441
2081
  if (!authorProfileId) {
1442
2082
  throw new Error("No local author profile for account");
1443
2083
  }
1444
2084
 
1445
- const now = new Date().toISOString();
1446
- const tweetId = `tweet_${randomUUID()}`;
2085
+ return {
2086
+ actionId: randomUUID(),
2087
+ authorProfileId,
2088
+ createdAt: new Date().toISOString(),
2089
+ tweetId: `tweet_${randomUUID()}`,
2090
+ };
2091
+ }
1447
2092
 
2093
+ function writePostDraft(
2094
+ db: Database,
2095
+ accountId: string,
2096
+ text: string,
2097
+ draft: PostDraft,
2098
+ ) {
1448
2099
  db.prepare(
1449
2100
  `
1450
2101
  insert into tweets (
@@ -1452,88 +2103,207 @@ export async function createPost(accountId: string, text: string) {
1452
2103
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked
1453
2104
  ) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
1454
2105
  `,
1455
- ).run(tweetId, accountId, authorProfileId, text, now);
2106
+ ).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
1456
2107
 
1457
2108
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
1458
- tweetId,
2109
+ draft.tweetId,
1459
2110
  text,
1460
2111
  );
1461
2112
  db.prepare(
1462
2113
  "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
1463
- ).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
+ }
2123
+
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
+ }
1464
2144
 
1465
- const transport = await postViaXurl(text);
1466
- return { ok: true, transport, tweetId };
2145
+ export function createPost(accountId: string, text: string) {
2146
+ return runEffectPromise(createPostEffect(accountId, text));
1467
2147
  }
1468
2148
 
1469
- export async function createTweetReply(
2149
+ export function createTweetReplyEffect(
1470
2150
  accountId: string,
1471
2151
  tweetId: string,
1472
2152
  text: string,
1473
2153
  ) {
1474
- const db = getNativeDb();
1475
- const authorProfileId = getLocalAuthorProfileId(accountId);
1476
- if (!authorProfileId) {
1477
- 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
+ };
1478
2162
  }
1479
2163
 
1480
- const now = new Date().toISOString();
1481
- 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);
1482
2166
 
1483
- const replyId = `tweet_${randomUUID()}`;
1484
- db.prepare(
1485
- `
2167
+ db.prepare(
2168
+ `
1486
2169
  insert into tweets (
1487
2170
  id, account_id, author_profile_id, kind, text, created_at,
1488
2171
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked
1489
2172
  ) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
1490
2173
  `,
1491
- ).run(replyId, accountId, authorProfileId, text, now, tweetId);
1492
- db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
1493
- replyId,
1494
- text,
1495
- );
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
+ );
1496
2186
 
1497
- db.prepare(
1498
- "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
1499
- ).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)));
1500
2205
 
1501
- const transport = await replyViaXurl(tweetId, text);
1502
- return { ok: true, transport, replyId };
2206
+ return { ok: true, transport, replyId: draft.replyId };
2207
+ });
1503
2208
  }
1504
2209
 
1505
- export async function createDmReply(conversationId: string, text: string) {
1506
- const db = getNativeDb();
1507
- const conversation = getConversationThread(conversationId);
1508
- if (!conversation) {
1509
- throw new Error("Conversation not found");
1510
- }
1511
- const authorProfileId = getLocalAuthorProfileId(
1512
- conversation.conversation.accountId,
1513
- );
1514
- if (!authorProfileId) {
1515
- throw new Error("No local author profile for account");
1516
- }
2210
+ export function createTweetReply(
2211
+ accountId: string,
2212
+ tweetId: string,
2213
+ text: string,
2214
+ ) {
2215
+ return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
2216
+ }
1517
2217
 
1518
- const now = new Date().toISOString();
1519
- const outboundId = `msg_${randomUUID()}`;
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
+ }
1520
2231
 
1521
- db.prepare(
1522
- `
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
+ `
1523
2243
  insert into dm_messages (
1524
2244
  id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1525
2245
  ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
1526
2246
  `,
1527
- ).run(outboundId, conversationId, authorProfileId, text, now);
1528
- db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
1529
- outboundId,
1530
- text,
1531
- );
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
+ );
1532
2258
 
1533
- refreshDmConversationState(db, conversationId, now);
1534
- const transport = await dmViaXurl(
1535
- conversation.conversation.participant.handle,
1536
- text,
1537
- );
1538
- return { ok: true, transport, messageId: outboundId };
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
+ `
2278
+ insert into dm_messages (
2279
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
2280
+ ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
2281
+ `,
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
+ );
2293
+
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));
1539
2309
  }