birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -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 +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  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 +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -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({
@@ -408,6 +786,7 @@ export function listTimelineItems({
408
786
  replyFilter = "all",
409
787
  since,
410
788
  until,
789
+ untilId,
411
790
  includeReplies = true,
412
791
  qualityFilter = "all",
413
792
  lowQualityThreshold,
@@ -421,13 +800,14 @@ export function listTimelineItems({
421
800
  const params: Array<string | number> = [];
422
801
  const normalizedLowQualityThreshold =
423
802
  normalizeLowQualityThreshold(lowQualityThreshold);
803
+ const shouldDedupeAcrossAccounts = !account || account === "all";
424
804
  let timelineEdgesCte = `
425
805
  with timeline_edges as (
426
- select account_id, tweet_id, kind
806
+ select account_id, tweet_id, kind, raw_json
427
807
  from tweet_account_edges
428
808
  where kind = ?
429
809
  union all
430
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
810
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
431
811
  from tweets legacy
432
812
  where legacy.kind = ?
433
813
  and not exists (
@@ -460,7 +840,7 @@ export function listTimelineItems({
460
840
  if (likedOnly && bookmarkedOnly) {
461
841
  timelineEdgesCte = `
462
842
  with timeline_edges as (
463
- select likes.account_id, likes.tweet_id, 'home' as kind
843
+ select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json
464
844
  from tweet_collections likes
465
845
  join tweet_collections bookmarks
466
846
  on bookmarks.account_id = likes.account_id
@@ -468,7 +848,7 @@ export function listTimelineItems({
468
848
  and bookmarks.kind = 'bookmarks'
469
849
  where likes.kind = 'likes'
470
850
  union all
471
- select legacy.account_id, legacy.id as tweet_id, 'home' as kind
851
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
472
852
  from tweets legacy
473
853
  where legacy.liked = 1
474
854
  and legacy.bookmarked = 1
@@ -486,11 +866,11 @@ export function listTimelineItems({
486
866
  const legacyColumn = likedOnly ? "liked" : "bookmarked";
487
867
  timelineEdgesCte = `
488
868
  with timeline_edges as (
489
- select account_id, tweet_id, 'home' as kind
869
+ select account_id, tweet_id, 'home' as kind, raw_json
490
870
  from tweet_collections
491
871
  where kind = ?
492
872
  union all
493
- select legacy.account_id, legacy.id as tweet_id, 'home' as kind
873
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
494
874
  from tweets legacy
495
875
  where legacy.${legacyColumn} = 1
496
876
  and not exists (
@@ -509,7 +889,7 @@ export function listTimelineItems({
509
889
  usedRecentEdgeWindow = true;
510
890
  timelineEdgesCte = `
511
891
  with timeline_edges as (
512
- select account_id, tweet_id, kind
892
+ select account_id, tweet_id, kind, raw_json
513
893
  from tweet_account_edges
514
894
  where kind = ?
515
895
  and tweet_id in (
@@ -519,7 +899,7 @@ export function listTimelineItems({
519
899
  limit ?
520
900
  )
521
901
  union all
522
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
902
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
523
903
  from tweets legacy
524
904
  where legacy.kind = ?
525
905
  and legacy.id in (
@@ -555,6 +935,20 @@ export function listTimelineItems({
555
935
  params.push(account);
556
936
  }
557
937
 
938
+ if (shouldDedupeAcrossAccounts) {
939
+ where += `
940
+ and e.account_id = (
941
+ select e2.account_id
942
+ from timeline_edges e2
943
+ join accounts a2 on a2.id = e2.account_id
944
+ where e2.tweet_id = e.tweet_id
945
+ and e2.kind = e.kind
946
+ order by a2.is_default desc, e2.account_id asc
947
+ limit 1
948
+ )
949
+ `;
950
+ }
951
+
558
952
  where += buildReplyClause(replyFilter).replaceAll(
559
953
  "is_replied",
560
954
  "t.is_replied",
@@ -576,8 +970,17 @@ export function listTimelineItems({
576
970
  }
577
971
 
578
972
  if (until?.trim()) {
579
- where += " and t.created_at < ?";
580
- params.push(until.trim());
973
+ // Deterministic keyset cursor: page on (created_at, id) so rows that share
974
+ // the boundary timestamp are not skipped. Uses the same text comparison as
975
+ // the `order by t.created_at desc, t.id desc` below, which is a total order
976
+ // because t.id is unique.
977
+ if (untilId?.trim()) {
978
+ where += " and (t.created_at < ? or (t.created_at = ? and t.id < ?))";
979
+ params.push(until.trim(), until.trim(), untilId.trim());
980
+ } else {
981
+ where += " and t.created_at < ?";
982
+ params.push(until.trim());
983
+ }
581
984
  }
582
985
 
583
986
  const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
@@ -598,6 +1001,7 @@ export function listTimelineItems({
598
1001
  e.account_id,
599
1002
  a.handle as account_handle,
600
1003
  e.kind,
1004
+ e.raw_json as edge_raw_json,
601
1005
  t.text,
602
1006
  t.created_at,
603
1007
  t.reply_to_id,
@@ -681,7 +1085,7 @@ export function listTimelineItems({
681
1085
  left join profiles qp on qp.id = qt.author_profile_id
682
1086
  ${join}
683
1087
  ${where}
684
- order by t.created_at desc
1088
+ order by t.created_at desc, t.id desc
685
1089
  limit ?
686
1090
  `;
687
1091
 
@@ -696,6 +1100,7 @@ export function listTimelineItems({
696
1100
  }
697
1101
 
698
1102
  const urlExpansionCache: UrlExpansionCache = new Map();
1103
+ const profileByHandleCache: ProfileByHandleCache = new Map();
699
1104
  return rows.map((row) => {
700
1105
  const author = {
701
1106
  id: String(row.profile_id),
@@ -709,45 +1114,49 @@ export function listTimelineItems({
709
1114
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
710
1115
  createdAt: String(row.profile_created_at),
711
1116
  };
1117
+ const rowProfiles: Record<string, ProfileRecord> = {
1118
+ [author.id]: author,
1119
+ ...(row.reply_profile_id
1120
+ ? {
1121
+ [String(row.reply_profile_id)]: toProfile({
1122
+ id: row.reply_profile_id,
1123
+ handle: row.reply_handle,
1124
+ display_name: row.reply_display_name,
1125
+ bio: row.reply_bio,
1126
+ followers_count: row.reply_followers_count,
1127
+ following_count: row.reply_following_count,
1128
+ avatar_hue: row.reply_avatar_hue,
1129
+ avatar_url: row.reply_avatar_url,
1130
+ created_at: row.reply_profile_created_at,
1131
+ }),
1132
+ }
1133
+ : {}),
1134
+ ...(row.quoted_profile_id
1135
+ ? {
1136
+ [String(row.quoted_profile_id)]: toProfile({
1137
+ id: row.quoted_profile_id,
1138
+ handle: row.quoted_handle,
1139
+ display_name: row.quoted_display_name,
1140
+ bio: row.quoted_bio,
1141
+ followers_count: row.quoted_followers_count,
1142
+ following_count: row.quoted_following_count,
1143
+ avatar_hue: row.quoted_avatar_hue,
1144
+ avatar_url: row.quoted_avatar_url,
1145
+ created_at: row.quoted_profile_created_at,
1146
+ }),
1147
+ }
1148
+ : {}),
1149
+ };
1150
+ const resolveProfileByHandle = (handle: string) =>
1151
+ getProfileByHandle(db, profileByHandleCache, handle, rowProfiles);
712
1152
  const text = String(row.text);
713
1153
  const entities = enrichTimelineEntities(
714
1154
  db,
715
1155
  urlExpansionCache,
716
1156
  text,
717
1157
  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
- },
1158
+ rowProfiles,
1159
+ resolveProfileByHandle,
751
1160
  );
752
1161
  const item = {
753
1162
  id: String(row.id),
@@ -769,8 +1178,26 @@ export function listTimelineItems({
769
1178
  author,
770
1179
  entities,
771
1180
  media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
772
- replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
773
- quotedTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "quoted_"),
1181
+ replyToTweet: buildEmbeddedTweet(
1182
+ db,
1183
+ urlExpansionCache,
1184
+ row,
1185
+ "reply_",
1186
+ resolveProfileByHandle,
1187
+ ),
1188
+ quotedTweet: buildEmbeddedTweet(
1189
+ db,
1190
+ urlExpansionCache,
1191
+ row,
1192
+ "quoted_",
1193
+ resolveProfileByHandle,
1194
+ ),
1195
+ retweetedTweet: buildRetweetedTweet(
1196
+ db,
1197
+ urlExpansionCache,
1198
+ row,
1199
+ resolveProfileByHandle,
1200
+ ),
774
1201
  };
775
1202
  return includeQualityReason
776
1203
  ? {
@@ -784,12 +1211,42 @@ export function listTimelineItems({
784
1211
  });
785
1212
  }
786
1213
 
787
- const conversationTweetSelect = `
1214
+ function conversationTweetSelect(accountId?: string) {
1215
+ const collectionStateSelect = accountId
1216
+ ? `
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 = 'bookmarks'
1223
+ ) then 1
1224
+ when t.account_id = ? and t.bookmarked = 1 then 1
1225
+ else 0
1226
+ end as bookmarked,
1227
+ case
1228
+ when exists (
1229
+ select 1 from tweet_collections collection
1230
+ where collection.account_id = ?
1231
+ and collection.tweet_id = t.id
1232
+ and collection.kind = 'likes'
1233
+ ) then 1
1234
+ when t.account_id = ? and t.liked = 1 then 1
1235
+ else 0
1236
+ end as liked,`
1237
+ : `
1238
+ t.bookmarked,
1239
+ t.liked,`;
1240
+ return `
788
1241
  select
789
1242
  t.id,
790
1243
  t.text,
791
1244
  t.created_at,
792
1245
  t.reply_to_id,
1246
+ t.is_replied,
1247
+ t.like_count,
1248
+ t.media_count,
1249
+ ${collectionStateSelect}
793
1250
  t.entities_json,
794
1251
  t.media_json,
795
1252
  p.id as profile_id,
@@ -804,17 +1261,29 @@ const conversationTweetSelect = `
804
1261
  from tweets t
805
1262
  join profiles p on p.id = t.author_profile_id
806
1263
  `;
1264
+ }
807
1265
 
808
1266
  function getTweetById(
809
1267
  db: Database,
810
1268
  urlExpansionCache: UrlExpansionCache,
811
1269
  tweetId: string,
1270
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
1271
+ accountId?: string,
812
1272
  ): EmbeddedTweet | null {
1273
+ const stateParams = accountId
1274
+ ? [accountId, accountId, accountId, accountId]
1275
+ : [];
813
1276
  const row = db
814
- .prepare(`${conversationTweetSelect} where t.id = ?`)
815
- .get(tweetId) as Record<string, unknown> | undefined;
1277
+ .prepare(`${conversationTweetSelect(accountId)} where t.id = ?`)
1278
+ .get(...stateParams, tweetId) as Record<string, unknown> | undefined;
816
1279
  if (!row) return null;
817
- return buildEmbeddedTweet(db, urlExpansionCache, row, "");
1280
+ return buildEmbeddedTweet(
1281
+ db,
1282
+ urlExpansionCache,
1283
+ row,
1284
+ "",
1285
+ resolveProfileByHandle,
1286
+ );
818
1287
  }
819
1288
 
820
1289
  function listTweetDescendants(
@@ -822,6 +1291,7 @@ function listTweetDescendants(
822
1291
  urlExpansionCache: UrlExpansionCache,
823
1292
  rootId: string,
824
1293
  limit: number,
1294
+ resolveProfileByHandle?: (handle: string) => ProfileRecord,
825
1295
  ) {
826
1296
  if (limit <= 0) return [];
827
1297
  const rows = db
@@ -837,7 +1307,7 @@ function listTweetDescendants(
837
1307
  join branch on child.reply_to_id = branch.id
838
1308
  where branch.depth < 8
839
1309
  )
840
- ${conversationTweetSelect}
1310
+ ${conversationTweetSelect()}
841
1311
  join branch on branch.id = t.id
842
1312
  where t.id != ?
843
1313
  order by t.created_at asc
@@ -847,7 +1317,15 @@ function listTweetDescendants(
847
1317
  .all(rootId, rootId, limit) as Array<Record<string, unknown>>;
848
1318
 
849
1319
  return rows
850
- .map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
1320
+ .map((row) =>
1321
+ buildEmbeddedTweet(
1322
+ db,
1323
+ urlExpansionCache,
1324
+ row,
1325
+ "",
1326
+ resolveProfileByHandle,
1327
+ ),
1328
+ )
851
1329
  .filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
852
1330
  }
853
1331
 
@@ -870,13 +1348,26 @@ export function getTweetConversation(
870
1348
  ): TweetConversationResponse | null {
871
1349
  const db = getNativeDb();
872
1350
  const urlExpansionCache: UrlExpansionCache = new Map();
873
- const anchor = getTweetById(db, urlExpansionCache, tweetId);
1351
+ const profileByHandleCache: ProfileByHandleCache = new Map();
1352
+ const resolveProfileByHandle = (handle: string) =>
1353
+ getProfileByHandle(db, profileByHandleCache, handle);
1354
+ const anchor = getTweetById(
1355
+ db,
1356
+ urlExpansionCache,
1357
+ tweetId,
1358
+ resolveProfileByHandle,
1359
+ );
874
1360
  if (!anchor) return null;
875
1361
 
876
1362
  const ancestors: EmbeddedTweet[] = [];
877
1363
  let current = anchor;
878
1364
  for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
879
- const parent = getTweetById(db, urlExpansionCache, current.replyToId);
1365
+ const parent = getTweetById(
1366
+ db,
1367
+ urlExpansionCache,
1368
+ current.replyToId,
1369
+ resolveProfileByHandle,
1370
+ );
880
1371
  if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
881
1372
  ancestors.push(parent);
882
1373
  current = parent;
@@ -897,6 +1388,7 @@ export function getTweetConversation(
897
1388
  urlExpansionCache,
898
1389
  anchor.id,
899
1390
  remainingAfterRequired,
1391
+ resolveProfileByHandle,
900
1392
  );
901
1393
  appendConversationTweets(items, seen, focusedDescendants, limit);
902
1394
 
@@ -906,6 +1398,7 @@ export function getTweetConversation(
906
1398
  urlExpansionCache,
907
1399
  root.id,
908
1400
  limit,
1401
+ resolveProfileByHandle,
909
1402
  );
910
1403
  appendConversationTweets(items, seen, ambientDescendants, limit);
911
1404
  }
@@ -921,9 +1414,12 @@ export function getTweetConversation(
921
1414
  export function listDmConversations({
922
1415
  account,
923
1416
  conversationIds,
1417
+ inbox = "all",
924
1418
  participant,
925
1419
  search,
926
1420
  replyFilter = "all",
1421
+ since,
1422
+ until,
927
1423
  minFollowers,
928
1424
  maxFollowers,
929
1425
  minInfluenceScore,
@@ -940,6 +1436,31 @@ export function listDmConversations({
940
1436
  let where = "where 1 = 1";
941
1437
  let searchSnippetSelect = "";
942
1438
  const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
1439
+ const influenceMinFollowers =
1440
+ typeof minInfluenceScore === "number"
1441
+ ? getMinFollowersForInfluenceScore(minInfluenceScore)
1442
+ : undefined;
1443
+ const influenceMaxFollowers =
1444
+ typeof maxInfluenceScore === "number"
1445
+ ? getMaxFollowersForInfluenceScore(maxInfluenceScore)
1446
+ : undefined;
1447
+ const effectiveMinFollowers =
1448
+ typeof minFollowers === "number" ||
1449
+ typeof influenceMinFollowers === "number"
1450
+ ? Math.max(minFollowers ?? 0, influenceMinFollowers ?? 0)
1451
+ : undefined;
1452
+ const effectiveMaxFollowers =
1453
+ typeof maxFollowers === "number" ||
1454
+ typeof influenceMaxFollowers === "number"
1455
+ ? Math.min(
1456
+ maxFollowers ?? Number.MAX_SAFE_INTEGER,
1457
+ influenceMaxFollowers ?? Number.MAX_SAFE_INTEGER,
1458
+ )
1459
+ : undefined;
1460
+ const orderBy =
1461
+ sort === "followers" || sort === "influence"
1462
+ ? "p.followers_count desc, c.last_message_at desc"
1463
+ : "c.last_message_at desc";
943
1464
 
944
1465
  if (account && account !== "all") {
945
1466
  where += " and a.id = ?";
@@ -951,6 +1472,12 @@ export function listDmConversations({
951
1472
  params.push(...conversationIds);
952
1473
  }
953
1474
 
1475
+ if (inbox === "accepted") {
1476
+ where += " and c.inbox_kind = 'accepted'";
1477
+ } else if (inbox === "requests") {
1478
+ where += " and c.inbox_kind = 'request'";
1479
+ }
1480
+
954
1481
  if (participant?.trim()) {
955
1482
  where += " and (p.handle like ? or p.display_name like ?)";
956
1483
  params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
@@ -962,14 +1489,23 @@ export function listDmConversations({
962
1489
  where += " and c.needs_reply = 1";
963
1490
  }
964
1491
 
965
- if (typeof minFollowers === "number") {
1492
+ if (since?.trim()) {
1493
+ where += " and c.last_message_at >= ?";
1494
+ params.push(since);
1495
+ }
1496
+ if (until?.trim()) {
1497
+ where += " and c.last_message_at < ?";
1498
+ params.push(until);
1499
+ }
1500
+
1501
+ if (typeof effectiveMinFollowers === "number") {
966
1502
  where += " and p.followers_count >= ?";
967
- params.push(minFollowers);
1503
+ params.push(effectiveMinFollowers);
968
1504
  }
969
1505
 
970
- if (typeof maxFollowers === "number") {
1506
+ if (typeof effectiveMaxFollowers === "number") {
971
1507
  where += " and p.followers_count <= ?";
972
- params.push(maxFollowers);
1508
+ params.push(effectiveMaxFollowers);
973
1509
  }
974
1510
 
975
1511
  if (ftsSearch) {
@@ -1012,6 +1548,7 @@ export function listDmConversations({
1012
1548
  c.account_id,
1013
1549
  a.handle as account_handle,
1014
1550
  c.title,
1551
+ c.inbox_kind,
1015
1552
  c.last_message_at,
1016
1553
  c.unread_count,
1017
1554
  c.needs_reply,
@@ -1042,7 +1579,7 @@ export function listDmConversations({
1042
1579
  ${join}
1043
1580
  ${where}
1044
1581
  group by c.id
1045
- order by c.last_message_at desc
1582
+ order by ${orderBy}
1046
1583
  limit ?
1047
1584
  `,
1048
1585
  )
@@ -1079,6 +1616,8 @@ export function listDmConversations({
1079
1616
  ...(typeof row.search_snippet === "string"
1080
1617
  ? { searchSnippet: row.search_snippet }
1081
1618
  : {}),
1619
+ inboxKind: row.inbox_kind === "request" ? "request" : "accepted",
1620
+ isMessageRequest: row.inbox_kind === "request",
1082
1621
  lastMessageAt: String(row.last_message_at),
1083
1622
  lastMessagePreview: String(row.last_message_preview ?? ""),
1084
1623
  unreadCount: Number(row.unread_count),
@@ -1115,7 +1654,7 @@ export function listDmConversations({
1115
1654
  return true;
1116
1655
  });
1117
1656
 
1118
- if (sort === "influence") {
1657
+ if (sort === "followers" || sort === "influence") {
1119
1658
  filtered.sort((left, right) => {
1120
1659
  if (
1121
1660
  right.participant.followersCount !== left.participant.followersCount
@@ -1152,10 +1691,13 @@ export function listDmConversations({
1152
1691
 
1153
1692
  export function getConversationThread(
1154
1693
  conversationId: string,
1694
+ filters: Pick<DmQuery, "account"> = {},
1155
1695
  ): { conversation: DmConversationItem; messages: DmMessageItem[] } | null {
1156
- const conversation = listDmConversations({ limit: 100 }).find(
1157
- (item) => item.id === conversationId,
1158
- );
1696
+ const conversation = listDmConversations({
1697
+ ...filters,
1698
+ conversationIds: [conversationId],
1699
+ limit: 1,
1700
+ }).find((item) => item.id === conversationId);
1159
1701
 
1160
1702
  if (!conversation) {
1161
1703
  return null;
@@ -1215,6 +1757,54 @@ export function getConversationThread(
1215
1757
  };
1216
1758
  }
1217
1759
 
1760
+ export type DmRequestMutationAction = "accept" | "reject" | "block";
1761
+
1762
+ export function applyDmRequestMutationToLocalStore(
1763
+ conversationId: string,
1764
+ action: DmRequestMutationAction,
1765
+ ) {
1766
+ return persistWrite((db) => {
1767
+ db.prepare(
1768
+ "delete from sync_cache where cache_key like 'dms:bird:%'",
1769
+ ).run();
1770
+ if (action === "accept") {
1771
+ return db
1772
+ .prepare(
1773
+ `
1774
+ update dm_conversations
1775
+ set inbox_kind = 'accepted'
1776
+ where id = ?
1777
+ `,
1778
+ )
1779
+ .run(conversationId).changes;
1780
+ }
1781
+
1782
+ db.prepare(
1783
+ `
1784
+ delete from link_occurrences
1785
+ where source_kind = 'dm'
1786
+ and source_id in (
1787
+ select id from dm_messages where conversation_id = ?
1788
+ )
1789
+ `,
1790
+ ).run(conversationId);
1791
+ db.prepare(
1792
+ `
1793
+ delete from dm_fts
1794
+ where message_id in (
1795
+ select id from dm_messages where conversation_id = ?
1796
+ )
1797
+ `,
1798
+ ).run(conversationId);
1799
+ db.prepare("delete from dm_messages where conversation_id = ?").run(
1800
+ conversationId,
1801
+ );
1802
+ return db
1803
+ .prepare("delete from dm_conversations where id = ?")
1804
+ .run(conversationId).changes;
1805
+ });
1806
+ }
1807
+
1218
1808
  function normalizeDmContext(value: number | undefined) {
1219
1809
  if (typeof value !== "number" || !Number.isFinite(value)) {
1220
1810
  return 0;
@@ -1375,18 +1965,25 @@ function getDmSearchMatches({
1375
1965
  }
1376
1966
 
1377
1967
  export function queryResource(
1378
- resource: "home" | "mentions" | "authored" | "dms",
1968
+ resource: "home" | "mentions" | "authored" | "search" | "dms",
1379
1969
  filters: (TimelineQuery | DmQuery) & { conversationId?: string },
1380
1970
  ): QueryResponse {
1381
1971
  if (resource === "dms") {
1382
1972
  const dmFilters = filters as DmQuery & { conversationId?: string };
1383
1973
  const items = listDmConversations(dmFilters);
1384
- const selectedConversationId = dmFilters.conversationId ?? items[0]?.id;
1974
+ const requestedConversationId = dmFilters.conversationId;
1975
+ const selectedConversationId =
1976
+ requestedConversationId &&
1977
+ items.some((item) => item.id === requestedConversationId)
1978
+ ? requestedConversationId
1979
+ : items[0]?.id;
1385
1980
  return {
1386
1981
  resource,
1387
1982
  items,
1388
1983
  selectedConversation: selectedConversationId
1389
- ? getConversationThread(selectedConversationId)
1984
+ ? getConversationThread(selectedConversationId, {
1985
+ account: dmFilters.account,
1986
+ })
1390
1987
  : null,
1391
1988
  };
1392
1989
  }
@@ -1407,16 +2004,36 @@ function refreshDmConversationState(
1407
2004
  db: Database,
1408
2005
  conversationId: string,
1409
2006
  lastMessageAt: string,
2007
+ observedLastMessageAt = lastMessageAt,
1410
2008
  ) {
1411
2009
  db.prepare(
1412
2010
  `
1413
2011
  update dm_conversations
1414
- set last_message_at = ?,
1415
- unread_count = 0,
1416
- needs_reply = 0
2012
+ set last_message_at = case
2013
+ when last_message_at < ? then ?
2014
+ else last_message_at
2015
+ end,
2016
+ unread_count = case
2017
+ when last_message_at = ? then 0
2018
+ when last_message_at <= ? then 0
2019
+ else unread_count
2020
+ end,
2021
+ needs_reply = case
2022
+ when last_message_at = ? then 0
2023
+ when last_message_at <= ? then 0
2024
+ else needs_reply
2025
+ end
1417
2026
  where id = ?
1418
2027
  `,
1419
- ).run(lastMessageAt, conversationId);
2028
+ ).run(
2029
+ lastMessageAt,
2030
+ lastMessageAt,
2031
+ observedLastMessageAt,
2032
+ lastMessageAt,
2033
+ observedLastMessageAt,
2034
+ lastMessageAt,
2035
+ conversationId,
2036
+ );
1420
2037
  }
1421
2038
 
1422
2039
  function getLocalAuthorProfileId(accountId: string) {
@@ -1435,16 +2052,60 @@ function getLocalAuthorProfileId(accountId: string) {
1435
2052
  return row?.id;
1436
2053
  }
1437
2054
 
1438
- export async function createPost(accountId: string, text: string) {
2055
+ let savepointCounter = 0;
2056
+
2057
+ function preflightWrite<T>(write: (db: Database) => T) {
2058
+ const db = getNativeDb();
2059
+ const savepoint = `__birdclaw_preflight_${++savepointCounter}`;
2060
+ db.exec(`savepoint ${savepoint}`);
2061
+ try {
2062
+ const result = write(db);
2063
+ db.exec(`rollback to ${savepoint}`);
2064
+ db.exec(`release ${savepoint}`);
2065
+ return result;
2066
+ } catch (error) {
2067
+ try {
2068
+ db.exec(`rollback to ${savepoint}`);
2069
+ db.exec(`release ${savepoint}`);
2070
+ } catch {
2071
+ // Preserve the original staging error; cleanup is best effort here.
2072
+ }
2073
+ throw error;
2074
+ }
2075
+ }
2076
+
2077
+ function persistWrite<T>(write: (db: Database) => T) {
1439
2078
  const db = getNativeDb();
2079
+ return db.transaction(() => write(db))();
2080
+ }
2081
+
2082
+ type PostDraft = {
2083
+ actionId: string;
2084
+ authorProfileId: string;
2085
+ createdAt: string;
2086
+ tweetId: string;
2087
+ };
2088
+
2089
+ function preparePostDraft(accountId: string): PostDraft {
1440
2090
  const authorProfileId = getLocalAuthorProfileId(accountId);
1441
2091
  if (!authorProfileId) {
1442
2092
  throw new Error("No local author profile for account");
1443
2093
  }
1444
2094
 
1445
- const now = new Date().toISOString();
1446
- const tweetId = `tweet_${randomUUID()}`;
2095
+ return {
2096
+ actionId: randomUUID(),
2097
+ authorProfileId,
2098
+ createdAt: new Date().toISOString(),
2099
+ tweetId: `tweet_${randomUUID()}`,
2100
+ };
2101
+ }
1447
2102
 
2103
+ function writePostDraft(
2104
+ db: Database,
2105
+ accountId: string,
2106
+ text: string,
2107
+ draft: PostDraft,
2108
+ ) {
1448
2109
  db.prepare(
1449
2110
  `
1450
2111
  insert into tweets (
@@ -1452,88 +2113,207 @@ export async function createPost(accountId: string, text: string) {
1452
2113
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked
1453
2114
  ) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
1454
2115
  `,
1455
- ).run(tweetId, accountId, authorProfileId, text, now);
2116
+ ).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
1456
2117
 
1457
2118
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
1458
- tweetId,
2119
+ draft.tweetId,
1459
2120
  text,
1460
2121
  );
1461
2122
  db.prepare(
1462
2123
  "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
1463
- ).run(randomUUID(), accountId, tweetId, "post", text, now);
2124
+ ).run(
2125
+ draft.actionId,
2126
+ accountId,
2127
+ draft.tweetId,
2128
+ "post",
2129
+ text,
2130
+ draft.createdAt,
2131
+ );
2132
+ }
2133
+
2134
+ export function createPostEffect(accountId: string, text: string) {
2135
+ return Effect.gen(function* () {
2136
+ const draft = yield* trySync(() => {
2137
+ const postDraft = preparePostDraft(accountId);
2138
+ preflightWrite((db) => writePostDraft(db, accountId, text, postDraft));
2139
+ return postDraft;
2140
+ });
2141
+
2142
+ yield* verifySelectedXurlAccountEffect(accountId);
2143
+ const transport = yield* postViaXurlEffect(text);
2144
+ if (!transport.ok) {
2145
+ return yield* Effect.fail(new Error(transport.output || "post failed"));
2146
+ }
2147
+ yield* trySync(() =>
2148
+ persistWrite((db) => writePostDraft(db, accountId, text, draft)),
2149
+ );
2150
+
2151
+ return { ok: true, transport, tweetId: draft.tweetId };
2152
+ });
2153
+ }
1464
2154
 
1465
- const transport = await postViaXurl(text);
1466
- return { ok: true, transport, tweetId };
2155
+ export function createPost(accountId: string, text: string) {
2156
+ return runEffectPromise(createPostEffect(accountId, text));
1467
2157
  }
1468
2158
 
1469
- export async function createTweetReply(
2159
+ export function createTweetReplyEffect(
1470
2160
  accountId: string,
1471
2161
  tweetId: string,
1472
2162
  text: string,
1473
2163
  ) {
1474
- const db = getNativeDb();
1475
- const authorProfileId = getLocalAuthorProfileId(accountId);
1476
- if (!authorProfileId) {
1477
- throw new Error("No local author profile for account");
2164
+ type ReplyDraft = PostDraft & { replyId: string };
2165
+
2166
+ function prepareReplyDraft(): ReplyDraft {
2167
+ const postDraft = preparePostDraft(accountId);
2168
+ return {
2169
+ ...postDraft,
2170
+ replyId: postDraft.tweetId,
2171
+ };
1478
2172
  }
1479
2173
 
1480
- const now = new Date().toISOString();
1481
- db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
2174
+ function writeReplyDraft(db: Database, draft: ReplyDraft) {
2175
+ db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
1482
2176
 
1483
- const replyId = `tweet_${randomUUID()}`;
1484
- db.prepare(
1485
- `
2177
+ db.prepare(
2178
+ `
1486
2179
  insert into tweets (
1487
2180
  id, account_id, author_profile_id, kind, text, created_at,
1488
2181
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked
1489
2182
  ) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
1490
2183
  `,
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
- );
2184
+ ).run(
2185
+ draft.replyId,
2186
+ accountId,
2187
+ draft.authorProfileId,
2188
+ text,
2189
+ draft.createdAt,
2190
+ tweetId,
2191
+ );
2192
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
2193
+ draft.replyId,
2194
+ text,
2195
+ );
1496
2196
 
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);
2197
+ db.prepare(
2198
+ "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
2199
+ ).run(draft.actionId, accountId, tweetId, "reply", text, draft.createdAt);
2200
+ }
2201
+
2202
+ return Effect.gen(function* () {
2203
+ const draft = yield* trySync(() => {
2204
+ const replyDraft = prepareReplyDraft();
2205
+ preflightWrite((db) => writeReplyDraft(db, replyDraft));
2206
+ return replyDraft;
2207
+ });
2208
+
2209
+ yield* verifySelectedXurlAccountEffect(accountId);
2210
+ const transport = yield* replyViaXurlEffect(tweetId, text);
2211
+ if (!transport.ok) {
2212
+ return yield* Effect.fail(new Error(transport.output || "reply failed"));
2213
+ }
2214
+ yield* trySync(() => persistWrite((db) => writeReplyDraft(db, draft)));
1500
2215
 
1501
- const transport = await replyViaXurl(tweetId, text);
1502
- return { ok: true, transport, replyId };
2216
+ return { ok: true, transport, replyId: draft.replyId };
2217
+ });
1503
2218
  }
1504
2219
 
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
- }
2220
+ export function createTweetReply(
2221
+ accountId: string,
2222
+ tweetId: string,
2223
+ text: string,
2224
+ ) {
2225
+ return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
2226
+ }
1517
2227
 
1518
- const now = new Date().toISOString();
1519
- const outboundId = `msg_${randomUUID()}`;
2228
+ export function createDmReplyEffect(conversationId: string, text: string) {
2229
+ return Effect.gen(function* () {
2230
+ const draft = yield* trySync(() => {
2231
+ const conversation = getConversationThread(conversationId);
2232
+ if (!conversation) {
2233
+ throw new Error("Conversation not found");
2234
+ }
2235
+ const authorProfileId = getLocalAuthorProfileId(
2236
+ conversation.conversation.accountId,
2237
+ );
2238
+ if (!authorProfileId) {
2239
+ throw new Error("No local author profile for account");
2240
+ }
1520
2241
 
1521
- db.prepare(
1522
- `
2242
+ const dmDraft = {
2243
+ accountId: conversation.conversation.accountId,
2244
+ authorProfileId,
2245
+ createdAt: new Date().toISOString(),
2246
+ handle: conversation.conversation.participant.handle,
2247
+ observedLastMessageAt: conversation.conversation.lastMessageAt,
2248
+ outboundId: `msg_${randomUUID()}`,
2249
+ };
2250
+ preflightWrite((db) => {
2251
+ db.prepare(
2252
+ `
1523
2253
  insert into dm_messages (
1524
2254
  id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1525
2255
  ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
1526
2256
  `,
1527
- ).run(outboundId, conversationId, authorProfileId, text, now);
1528
- db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
1529
- outboundId,
1530
- text,
1531
- );
2257
+ ).run(
2258
+ dmDraft.outboundId,
2259
+ conversationId,
2260
+ dmDraft.authorProfileId,
2261
+ text,
2262
+ dmDraft.createdAt,
2263
+ );
2264
+ db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
2265
+ dmDraft.outboundId,
2266
+ text,
2267
+ );
1532
2268
 
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 };
2269
+ refreshDmConversationState(
2270
+ db,
2271
+ conversationId,
2272
+ dmDraft.createdAt,
2273
+ dmDraft.observedLastMessageAt,
2274
+ );
2275
+ });
2276
+ return dmDraft;
2277
+ });
2278
+
2279
+ yield* verifySelectedXurlAccountEffect(draft.accountId);
2280
+ const transport = yield* dmViaXurlEffect(draft.handle, text);
2281
+ if (!transport.ok) {
2282
+ return yield* Effect.fail(new Error(transport.output || "dm failed"));
2283
+ }
2284
+ yield* trySync(() =>
2285
+ persistWrite((db) => {
2286
+ db.prepare(
2287
+ `
2288
+ insert into dm_messages (
2289
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
2290
+ ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
2291
+ `,
2292
+ ).run(
2293
+ draft.outboundId,
2294
+ conversationId,
2295
+ draft.authorProfileId,
2296
+ text,
2297
+ draft.createdAt,
2298
+ );
2299
+ db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
2300
+ draft.outboundId,
2301
+ text,
2302
+ );
2303
+
2304
+ refreshDmConversationState(
2305
+ db,
2306
+ conversationId,
2307
+ draft.createdAt,
2308
+ draft.observedLastMessageAt,
2309
+ );
2310
+ }),
2311
+ );
2312
+
2313
+ return { ok: true, transport, messageId: draft.outboundId };
2314
+ });
2315
+ }
2316
+
2317
+ export function createDmReply(conversationId: string, text: string) {
2318
+ return runEffectPromise(createDmReplyEffect(conversationId, text));
1539
2319
  }