birdclaw 0.8.3 → 0.8.4

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 (284) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -8
  3. package/bin/birdclaw.mjs +4 -66
  4. package/dist/cli/birdclaw.js +28165 -0
  5. package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
  6. package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
  7. package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
  8. package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
  9. package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
  10. package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
  11. package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
  12. package/dist/client/assets/chunk-C0phanGV.js +1 -0
  13. package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
  14. package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
  15. package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
  16. package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
  17. package/dist/client/assets/discuss-DTjaOugw.js +1 -0
  18. package/dist/client/assets/dms-CGd6Eogq.js +1 -0
  19. package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
  20. package/dist/client/assets/index-CEIG7S6s.js +31 -0
  21. package/dist/client/assets/index-bpFrQ5yG.css +1 -0
  22. package/dist/client/assets/likes-cLrcAQem.js +1 -0
  23. package/dist/client/assets/links-CM9QO1LC.js +1 -0
  24. package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
  25. package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
  26. package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
  27. package/dist/client/assets/network-map-B5a3whqj.js +2 -0
  28. package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
  29. package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
  30. package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
  31. package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
  32. package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
  33. package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
  34. package/dist/client/assets/react-nBylq1U5.js +1 -0
  35. package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
  36. package/dist/client/assets/routes-q7ZaW4En.js +1 -0
  37. package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
  38. package/dist/client/assets/search-Dq5kYsxA.js +1 -0
  39. package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
  40. package/dist/client/assets/styles-B8s3tt3r.css +2 -0
  41. package/dist/client/assets/today-CeG1O7Py.js +1 -0
  42. package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
  43. package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
  44. package/dist/client/assets/users-CHbKlqUr.js +1 -0
  45. package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
  46. package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
  47. package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
  48. package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
  49. package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
  50. package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
  51. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
  52. package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
  53. package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
  54. package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
  55. package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
  56. package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
  57. package/dist/server/assets/blocks-DSgQghqP.js +17 -0
  58. package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
  59. package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
  60. package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
  61. package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
  62. package/dist/server/assets/discuss-QTo08NCN.js +338 -0
  63. package/dist/server/assets/dms-BuLj4bp8.js +368 -0
  64. package/dist/server/assets/dms-DAyQrVM2.js +240 -0
  65. package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
  66. package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
  67. package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
  68. package/dist/server/assets/likes-DBOAtsJU.js +14 -0
  69. package/dist/server/assets/links-DAhSjynu.js +240 -0
  70. package/dist/server/assets/links-DmH5lxHO.js +468 -0
  71. package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
  72. package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
  73. package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
  74. package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
  75. package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
  76. package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
  77. package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
  78. package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
  79. package/dist/server/assets/query-client-BdauHBgd.js +923 -0
  80. package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
  81. package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
  82. package/dist/server/assets/router-DqkwSPiM.js +17026 -0
  83. package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
  84. package/dist/server/assets/start-FaDOTmto.js +4 -0
  85. package/dist/server/assets/today-B5hVM57Z.js +17 -0
  86. package/dist/server/assets/today-fvGiZ7ud.js +303 -0
  87. package/dist/server/assets/ui-qbmcMSSX.js +137 -0
  88. package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
  89. package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
  90. package/dist/server/server.js +5520 -0
  91. package/package.json +26 -34
  92. package/playwright.config.ts +0 -35
  93. package/scripts/browser-perf.mjs +0 -427
  94. package/scripts/build-docs-site.mjs +0 -967
  95. package/scripts/docs-site-assets.mjs +0 -311
  96. package/scripts/run-vitest.mjs +0 -21
  97. package/scripts/sanitize-node-options.mjs +0 -23
  98. package/scripts/start-test-server.mjs +0 -42
  99. package/src/cli/command-context.ts +0 -17
  100. package/src/cli/register-analysis.ts +0 -500
  101. package/src/cli/register-compose.ts +0 -40
  102. package/src/cli/register-graph.ts +0 -132
  103. package/src/cli/register-inbox.ts +0 -41
  104. package/src/cli/register-storage.ts +0 -106
  105. package/src/cli-moderation.ts +0 -210
  106. package/src/cli.ts +0 -1616
  107. package/src/components/AccountSwitcher.tsx +0 -178
  108. package/src/components/AppNav.tsx +0 -118
  109. package/src/components/AvatarChip.tsx +0 -58
  110. package/src/components/AvatarPreload.ts +0 -149
  111. package/src/components/BrandMark.tsx +0 -67
  112. package/src/components/ConversationThread.tsx +0 -131
  113. package/src/components/DmWorkspace.tsx +0 -286
  114. package/src/components/EmbeddedTweetCard.tsx +0 -55
  115. package/src/components/FeedState.tsx +0 -147
  116. package/src/components/FloatingPreview.tsx +0 -382
  117. package/src/components/InboxCard.tsx +0 -152
  118. package/src/components/LinkPreviewCard.tsx +0 -292
  119. package/src/components/MarkdownCitations.tsx +0 -680
  120. package/src/components/MarkdownViewer.tsx +0 -111
  121. package/src/components/ProfileAnalysisClient.ts +0 -191
  122. package/src/components/ProfileAnalysisStream.tsx +0 -259
  123. package/src/components/ProfilePreview.tsx +0 -131
  124. package/src/components/SavedTimelineView.tsx +0 -155
  125. package/src/components/SmartTimestamp.tsx +0 -72
  126. package/src/components/SyncNowButton.tsx +0 -140
  127. package/src/components/ThemeSlider.tsx +0 -80
  128. package/src/components/TimelineCard.tsx +0 -533
  129. package/src/components/TimelineRouteFrame.tsx +0 -186
  130. package/src/components/TweetArticleCard.tsx +0 -66
  131. package/src/components/TweetMediaGrid.tsx +0 -185
  132. package/src/components/TweetRichText.tsx +0 -159
  133. package/src/components/account-selection.ts +0 -64
  134. package/src/components/links-controller.ts +0 -162
  135. package/src/components/links-model.ts +0 -198
  136. package/src/components/network-map-controller.ts +0 -84
  137. package/src/components/network-map-model.ts +0 -255
  138. package/src/components/useTimelineRouteData.ts +0 -193
  139. package/src/lib/account-sync-job.ts +0 -666
  140. package/src/lib/actions-transport.ts +0 -300
  141. package/src/lib/analysis-runtime.ts +0 -238
  142. package/src/lib/api-client.ts +0 -220
  143. package/src/lib/api-contracts.ts +0 -328
  144. package/src/lib/archive-finder.ts +0 -185
  145. package/src/lib/archive-import-plan.ts +0 -102
  146. package/src/lib/archive-import.ts +0 -2695
  147. package/src/lib/authored-live.ts +0 -1027
  148. package/src/lib/avatar-cache.ts +0 -349
  149. package/src/lib/backup.ts +0 -2652
  150. package/src/lib/bird-actions.ts +0 -294
  151. package/src/lib/bird-command.ts +0 -130
  152. package/src/lib/bird.ts +0 -1140
  153. package/src/lib/blocklist.ts +0 -136
  154. package/src/lib/blocks-write.ts +0 -171
  155. package/src/lib/blocks.ts +0 -387
  156. package/src/lib/bookmark-sync-job.ts +0 -536
  157. package/src/lib/config.ts +0 -224
  158. package/src/lib/conversation-surface.ts +0 -205
  159. package/src/lib/data-sources.ts +0 -219
  160. package/src/lib/database-metrics.ts +0 -88
  161. package/src/lib/database-migrations.ts +0 -34
  162. package/src/lib/database-schema.ts +0 -312
  163. package/src/lib/database-writer.ts +0 -69
  164. package/src/lib/db.ts +0 -889
  165. package/src/lib/dm-read-model.ts +0 -533
  166. package/src/lib/dms-live.ts +0 -870
  167. package/src/lib/effect-runtime.ts +0 -45
  168. package/src/lib/follow-graph.ts +0 -1087
  169. package/src/lib/geocoding.ts +0 -296
  170. package/src/lib/http-effect.ts +0 -222
  171. package/src/lib/identity-search-index.ts +0 -369
  172. package/src/lib/import-repository.ts +0 -138
  173. package/src/lib/inbox.ts +0 -242
  174. package/src/lib/link-index.ts +0 -641
  175. package/src/lib/link-insights.ts +0 -860
  176. package/src/lib/link-preview-metadata.ts +0 -754
  177. package/src/lib/live-sync-engine.ts +0 -209
  178. package/src/lib/live-transport-gateway.ts +0 -128
  179. package/src/lib/location.ts +0 -137
  180. package/src/lib/media-fetch.ts +0 -860
  181. package/src/lib/media-includes.ts +0 -165
  182. package/src/lib/mention-threads-live.ts +0 -830
  183. package/src/lib/mentions-export.ts +0 -147
  184. package/src/lib/mentions-live.ts +0 -1114
  185. package/src/lib/moderation-target.ts +0 -227
  186. package/src/lib/moderation-write.ts +0 -131
  187. package/src/lib/mutes-write.ts +0 -171
  188. package/src/lib/mutes.ts +0 -84
  189. package/src/lib/ndjson-stream.ts +0 -106
  190. package/src/lib/network-map.ts +0 -382
  191. package/src/lib/openai-response-runtime.ts +0 -251
  192. package/src/lib/openai.ts +0 -117
  193. package/src/lib/paginated-sync.ts +0 -93
  194. package/src/lib/period-digest.ts +0 -1364
  195. package/src/lib/present.ts +0 -118
  196. package/src/lib/profile-affiliation-hydration.ts +0 -228
  197. package/src/lib/profile-affiliations.ts +0 -262
  198. package/src/lib/profile-analysis.ts +0 -1198
  199. package/src/lib/profile-bio-entities.ts +0 -318
  200. package/src/lib/profile-history.ts +0 -164
  201. package/src/lib/profile-hydration.ts +0 -180
  202. package/src/lib/profile-replies.ts +0 -77
  203. package/src/lib/profile-resolver.ts +0 -650
  204. package/src/lib/queries.ts +0 -6
  205. package/src/lib/query-actions.ts +0 -437
  206. package/src/lib/query-client.tsx +0 -47
  207. package/src/lib/query-read-model-shared.ts +0 -52
  208. package/src/lib/query-read-models.ts +0 -5
  209. package/src/lib/query-resource.ts +0 -41
  210. package/src/lib/query-status.ts +0 -164
  211. package/src/lib/research.ts +0 -652
  212. package/src/lib/runtime-services.ts +0 -20
  213. package/src/lib/search-discussion.ts +0 -812
  214. package/src/lib/seed.ts +0 -613
  215. package/src/lib/server-runtime-services.ts +0 -30
  216. package/src/lib/sqlite.ts +0 -183
  217. package/src/lib/streaming-ingestion.ts +0 -240
  218. package/src/lib/sync-cache.ts +0 -70
  219. package/src/lib/sync-plan.ts +0 -175
  220. package/src/lib/theme-transition.ts +0 -113
  221. package/src/lib/theme.tsx +0 -165
  222. package/src/lib/timeline-collections-live.ts +0 -407
  223. package/src/lib/timeline-live.ts +0 -302
  224. package/src/lib/timeline-read-model.ts +0 -1191
  225. package/src/lib/tweet-account-edges.ts +0 -48
  226. package/src/lib/tweet-lookup.ts +0 -46
  227. package/src/lib/tweet-render.ts +0 -430
  228. package/src/lib/tweet-repository.ts +0 -156
  229. package/src/lib/tweet-search-live.ts +0 -462
  230. package/src/lib/types.ts +0 -723
  231. package/src/lib/ui.ts +0 -529
  232. package/src/lib/url-expansion-store.ts +0 -110
  233. package/src/lib/url-expansion.ts +0 -343
  234. package/src/lib/url-safety.ts +0 -220
  235. package/src/lib/web-sync.ts +0 -533
  236. package/src/lib/whois.ts +0 -1080
  237. package/src/lib/x-profile.ts +0 -365
  238. package/src/lib/x-web.ts +0 -199
  239. package/src/lib/xurl-rate-limits.ts +0 -267
  240. package/src/lib/xurl.ts +0 -1677
  241. package/src/routeTree.gen.ts +0 -723
  242. package/src/router.tsx +0 -20
  243. package/src/routes/__root.tsx +0 -82
  244. package/src/routes/api/action.tsx +0 -147
  245. package/src/routes/api/avatar.tsx +0 -50
  246. package/src/routes/api/blocks.tsx +0 -35
  247. package/src/routes/api/conversation.tsx +0 -45
  248. package/src/routes/api/data-sources.tsx +0 -24
  249. package/src/routes/api/inbox.tsx +0 -43
  250. package/src/routes/api/link-insights.tsx +0 -85
  251. package/src/routes/api/link-preview.tsx +0 -47
  252. package/src/routes/api/network-map.tsx +0 -55
  253. package/src/routes/api/period-digest.tsx +0 -101
  254. package/src/routes/api/profile-analysis.tsx +0 -97
  255. package/src/routes/api/profile-hydrate.tsx +0 -54
  256. package/src/routes/api/query.tsx +0 -121
  257. package/src/routes/api/search-discussion.tsx +0 -113
  258. package/src/routes/api/status.tsx +0 -30
  259. package/src/routes/api/sync.tsx +0 -108
  260. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  261. package/src/routes/blocks.tsx +0 -343
  262. package/src/routes/bookmarks.tsx +0 -18
  263. package/src/routes/data-sources.tsx +0 -249
  264. package/src/routes/discuss.tsx +0 -419
  265. package/src/routes/dms.tsx +0 -415
  266. package/src/routes/inbox.tsx +0 -232
  267. package/src/routes/index.tsx +0 -32
  268. package/src/routes/likes.tsx +0 -18
  269. package/src/routes/links.tsx +0 -631
  270. package/src/routes/mentions.tsx +0 -32
  271. package/src/routes/network-map.tsx +0 -732
  272. package/src/routes/profile-analyze.tsx +0 -112
  273. package/src/routes/profiles.$handle.tsx +0 -228
  274. package/src/routes/rate-limits.tsx +0 -305
  275. package/src/routes/today.tsx +0 -511
  276. package/src/styles.css +0 -257
  277. package/tsconfig.json +0 -29
  278. package/vite.config.ts +0 -29
  279. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  280. /package/{public → dist/client}/favicon.ico +0 -0
  281. /package/{public → dist/client}/logo192.png +0 -0
  282. /package/{public → dist/client}/logo512.png +0 -0
  283. /package/{public → dist/client}/manifest.json +0 -0
  284. /package/{public → dist/client}/robots.txt +0 -0
@@ -1,1191 +0,0 @@
1
- import type { Database } from "./sqlite";
2
- import { getReadDb } from "./db";
3
- import { displayUrlForLink, enrichFallbackUrlEntities } from "./tweet-render";
4
- import {
5
- parseJsonField,
6
- toFtsSearchQuery,
7
- toProfile,
8
- } from "./query-read-model-shared";
9
- import type {
10
- EmbeddedTweet,
11
- ProfileRecord,
12
- ReplyFilter,
13
- TimelineItem,
14
- TimelineQualityFilter,
15
- TimelineQuery,
16
- TweetConversationResponse,
17
- TweetEntities,
18
- TweetMediaItem,
19
- TweetUrlEntity,
20
- } from "./types";
21
-
22
- export type {
23
- TimelineItem,
24
- TimelineQuery,
25
- TweetConversationResponse,
26
- } from "./types";
27
-
28
- function normalizeProfileHandle(handle: string) {
29
- return handle.replace(/^@/, "").toLowerCase();
30
- }
31
-
32
- function avatarHueForHandle(handle: string) {
33
- let hash = 0;
34
- for (const character of handle) {
35
- hash = (hash * 31 + character.charCodeAt(0)) % 360;
36
- }
37
- return hash;
38
- }
39
-
40
- function fallbackProfileForHandle(handle: string): ProfileRecord {
41
- const normalized = normalizeProfileHandle(handle);
42
- return {
43
- id: `profile_handle_${normalized}`,
44
- handle: normalized,
45
- displayName: `@${normalized}`,
46
- bio: "",
47
- followersCount: 0,
48
- avatarHue: avatarHueForHandle(normalized),
49
- createdAt: new Date(0).toISOString(),
50
- };
51
- }
52
-
53
- type ProfileByHandleCache = Map<string, ProfileRecord | null>;
54
-
55
- function getProfileByHandle(
56
- db: Database,
57
- cache: ProfileByHandleCache,
58
- handle: string,
59
- profiles: Record<string, ProfileRecord> = {},
60
- ) {
61
- const normalized = normalizeProfileHandle(handle);
62
- const inlineProfile = Object.values(profiles).find(
63
- (profile) => normalizeProfileHandle(profile.handle) === normalized,
64
- );
65
- if (inlineProfile) {
66
- return inlineProfile;
67
- }
68
-
69
- if (cache.has(normalized)) {
70
- return cache.get(normalized) ?? fallbackProfileForHandle(normalized);
71
- }
72
-
73
- const row = db
74
- .prepare(
75
- `
76
- select *
77
- from profiles
78
- where lower(handle) = lower(?)
79
- limit 1
80
- `,
81
- )
82
- .get(normalized) as Record<string, unknown> | undefined;
83
- const profile = row ? toProfile(row) : null;
84
- cache.set(normalized, profile);
85
- return profile ?? fallbackProfileForHandle(normalized);
86
- }
87
-
88
- function spansOverlap(
89
- leftStart: number,
90
- leftEnd: number,
91
- rightStart: number,
92
- rightEnd: number,
93
- ) {
94
- return leftStart < rightEnd && rightStart < leftEnd;
95
- }
96
-
97
- function enrichFallbackMentionEntities(
98
- text: string,
99
- entities: TweetEntities,
100
- resolveProfileByHandle: (handle: string) => ProfileRecord,
101
- ): TweetEntities {
102
- const existingMentions = entities.mentions ?? [];
103
- const occupied = [
104
- ...existingMentions,
105
- ...(entities.urls ?? []),
106
- ...(entities.hashtags ?? []),
107
- ].map((entry) => ({ start: entry.start, end: entry.end }));
108
- const fallbackMentions = [];
109
- const mentionPattern = /(^|[^\w@])@([A-Za-z0-9_]{1,15})/g;
110
-
111
- for (const match of text.matchAll(mentionPattern)) {
112
- const prefix = match[1] ?? "";
113
- const username = match[2];
114
- if (!username) continue;
115
- const start = (match.index ?? 0) + prefix.length;
116
- const end = start + username.length + 1;
117
- if (
118
- occupied.some((entry) => spansOverlap(start, end, entry.start, entry.end))
119
- ) {
120
- continue;
121
- }
122
-
123
- const profile = resolveProfileByHandle(username);
124
- fallbackMentions.push({
125
- username,
126
- id: profile.id,
127
- start,
128
- end,
129
- profile,
130
- });
131
- occupied.push({ start, end });
132
- }
133
-
134
- if (fallbackMentions.length === 0) {
135
- return entities;
136
- }
137
-
138
- return {
139
- ...entities,
140
- mentions: [...existingMentions, ...fallbackMentions].sort(
141
- (left, right) => left.start - right.start,
142
- ),
143
- };
144
- }
145
-
146
- function enrichEntities(
147
- entities: TweetEntities,
148
- profiles: Record<string, ProfileRecord>,
149
- resolveProfileByHandle?: (handle: string) => ProfileRecord,
150
- ): TweetEntities {
151
- const mentions = entities.mentions?.map((mention) => {
152
- const profile =
153
- (mention.id ? profiles[mention.id] : undefined) ??
154
- Object.values(profiles).find(
155
- (candidate) =>
156
- normalizeProfileHandle(candidate.handle) ===
157
- normalizeProfileHandle(mention.username),
158
- ) ??
159
- resolveProfileByHandle?.(mention.username);
160
- return profile ? { ...mention, profile } : mention;
161
- });
162
-
163
- return {
164
- ...entities,
165
- ...(mentions ? { mentions } : {}),
166
- };
167
- }
168
-
169
- type UrlExpansionCache = Map<
170
- string,
171
- | (Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
172
- Partial<
173
- Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
174
- >)
175
- | null
176
- >;
177
-
178
- function getUrlExpansion(
179
- db: Database,
180
- cache: UrlExpansionCache,
181
- rawUrl: string,
182
- ) {
183
- if (cache.has(rawUrl)) {
184
- return cache.get(rawUrl);
185
- }
186
-
187
- const row = db
188
- .prepare(
189
- `
190
- select expanded_url, final_url, title, description, image_url, site_name
191
- from url_expansions
192
- where short_url = ?
193
- and status = 'hit'
194
- `,
195
- )
196
- .get(rawUrl) as
197
- | {
198
- expanded_url: string;
199
- final_url: string;
200
- title: string | null;
201
- description: string | null;
202
- image_url: string | null;
203
- site_name: string | null;
204
- }
205
- | undefined;
206
- if (!row) {
207
- cache.set(rawUrl, null);
208
- return null;
209
- }
210
-
211
- const expandedUrl = row.final_url || row.expanded_url || rawUrl;
212
- const expansion = {
213
- expandedUrl,
214
- displayUrl: displayUrlForLink(expandedUrl),
215
- ...(row.title ? { title: row.title } : {}),
216
- ...(row.description ? { description: row.description } : {}),
217
- ...(row.image_url ? { imageUrl: row.image_url } : {}),
218
- ...(row.site_name ? { siteName: row.site_name } : {}),
219
- };
220
- cache.set(rawUrl, expansion);
221
- return expansion;
222
- }
223
-
224
- function enrichTimelineEntities(
225
- db: Database,
226
- urlExpansionCache: UrlExpansionCache,
227
- text: string,
228
- entities: TweetEntities,
229
- profiles: Record<string, ProfileRecord>,
230
- resolveProfileByHandle?: (handle: string) => ProfileRecord,
231
- ): TweetEntities {
232
- const withUrls = enrichFallbackUrlEntities(
233
- text,
234
- enrichEntities(entities, profiles, resolveProfileByHandle),
235
- (rawUrl) => getUrlExpansion(db, urlExpansionCache, rawUrl),
236
- );
237
- return resolveProfileByHandle
238
- ? enrichFallbackMentionEntities(text, withUrls, resolveProfileByHandle)
239
- : withUrls;
240
- }
241
-
242
- function buildEmbeddedTweet(
243
- db: Database,
244
- urlExpansionCache: UrlExpansionCache,
245
- row: Record<string, unknown>,
246
- prefix: string,
247
- resolveProfileByHandle?: (handle: string) => ProfileRecord,
248
- ): EmbeddedTweet | null {
249
- if (!row[`${prefix}id`]) {
250
- return null;
251
- }
252
-
253
- const author = toProfile({
254
- id: row[`${prefix}profile_id`],
255
- handle: row[`${prefix}handle`],
256
- display_name: row[`${prefix}display_name`],
257
- bio: row[`${prefix}bio`],
258
- followers_count: row[`${prefix}followers_count`],
259
- following_count: row[`${prefix}following_count`],
260
- avatar_hue: row[`${prefix}avatar_hue`],
261
- avatar_url: row[`${prefix}avatar_url`],
262
- created_at: row[`${prefix}profile_created_at`],
263
- });
264
-
265
- const text = String(row[`${prefix}text`] ?? "");
266
- return {
267
- id: String(row[`${prefix}id`]),
268
- text,
269
- createdAt: String(row[`${prefix}created_at`] ?? new Date(0).toISOString()),
270
- replyToId:
271
- typeof row[`${prefix}reply_to_id`] === "string"
272
- ? String(row[`${prefix}reply_to_id`])
273
- : null,
274
- ...(row[`${prefix}is_replied`] === undefined
275
- ? {}
276
- : { isReplied: Boolean(row[`${prefix}is_replied`]) }),
277
- ...(row[`${prefix}like_count`] === undefined
278
- ? {}
279
- : { likeCount: Number(row[`${prefix}like_count`]) }),
280
- ...(row[`${prefix}media_count`] === undefined
281
- ? {}
282
- : { mediaCount: Number(row[`${prefix}media_count`]) }),
283
- ...(row[`${prefix}bookmarked`] === undefined
284
- ? {}
285
- : { bookmarked: Boolean(row[`${prefix}bookmarked`]) }),
286
- ...(row[`${prefix}liked`] === undefined
287
- ? {}
288
- : { liked: Boolean(row[`${prefix}liked`]) }),
289
- author,
290
- entities: enrichTimelineEntities(
291
- db,
292
- urlExpansionCache,
293
- text,
294
- parseJsonField<TweetEntities>(row[`${prefix}entities_json`], {}),
295
- {
296
- [author.id]: author,
297
- },
298
- resolveProfileByHandle,
299
- ),
300
- media: parseJsonField<TweetMediaItem[]>(row[`${prefix}media_json`], []),
301
- };
302
- }
303
-
304
- function getRetweetedTweetIdFromRaw(rawJson: unknown) {
305
- const raw = parseJsonField<Record<string, unknown>>(rawJson, {});
306
- const directCandidates = [
307
- raw.retweeted_tweet_id,
308
- raw.retweetedTweetId,
309
- raw.retweetedStatusId,
310
- raw.retweeted_status_id_str,
311
- ];
312
- for (const candidate of directCandidates) {
313
- if (typeof candidate === "string" && candidate.length > 0) {
314
- return candidate;
315
- }
316
- }
317
-
318
- const nestedCandidates = [raw.retweetedTweet, raw.retweeted_status];
319
- for (const nested of nestedCandidates) {
320
- if (nested && typeof nested === "object") {
321
- const record = nested as Record<string, unknown>;
322
- for (const key of ["id", "id_str"]) {
323
- if (typeof record[key] === "string" && record[key].length > 0) {
324
- return record[key];
325
- }
326
- }
327
- }
328
- }
329
-
330
- const references = [raw.referenced_tweets, raw.referencedTweets].find(
331
- (value): value is unknown[] => Array.isArray(value),
332
- );
333
- for (const reference of references ?? []) {
334
- if (!reference || typeof reference !== "object") continue;
335
- const record = reference as Record<string, unknown>;
336
- if (record.type === "retweeted" && typeof record.id === "string") {
337
- return record.id;
338
- }
339
- }
340
-
341
- return null;
342
- }
343
-
344
- function parseManualRetweet(text: string) {
345
- const match = text.match(/^RT\s+@([A-Za-z0-9_]{1,15}):\s*([\s\S]+)$/);
346
- if (!match?.[1] || !match[2]) {
347
- return null;
348
- }
349
- return {
350
- handle: match[1],
351
- text: match[2].trim(),
352
- };
353
- }
354
-
355
- function buildRetweetedTweet(
356
- db: Database,
357
- urlExpansionCache: UrlExpansionCache,
358
- row: Record<string, unknown>,
359
- resolveProfileByHandle: (handle: string) => ProfileRecord,
360
- ) {
361
- const retweetedId = getRetweetedTweetIdFromRaw(row.edge_raw_json);
362
- const accountId = String(row.account_id);
363
- if (retweetedId) {
364
- const tweet = getTweetById(
365
- db,
366
- urlExpansionCache,
367
- retweetedId,
368
- resolveProfileByHandle,
369
- accountId,
370
- );
371
- if (tweet) {
372
- return tweet;
373
- }
374
- }
375
-
376
- const manualRetweet = parseManualRetweet(String(row.text ?? ""));
377
- if (!manualRetweet) {
378
- return null;
379
- }
380
-
381
- const author = resolveProfileByHandle(manualRetweet.handle);
382
- return {
383
- id: `${String(row.id)}:retweeted`,
384
- text: manualRetweet.text,
385
- createdAt: String(row.created_at ?? new Date(0).toISOString()),
386
- replyToId: null,
387
- isReplied: Boolean(row.is_replied),
388
- likeCount: Number(row.like_count ?? 0),
389
- mediaCount: 0,
390
- bookmarked: Boolean(row.bookmarked),
391
- liked: Boolean(row.liked),
392
- author,
393
- entities: enrichTimelineEntities(
394
- db,
395
- urlExpansionCache,
396
- manualRetweet.text,
397
- {},
398
- { [author.id]: author },
399
- resolveProfileByHandle,
400
- ),
401
- media: [],
402
- };
403
- }
404
-
405
- function buildReplyClause(replyFilter: ReplyFilter) {
406
- if (replyFilter === "replied") {
407
- return " and is_replied = 1";
408
- }
409
- if (replyFilter === "unreplied") {
410
- return " and is_replied = 0";
411
- }
412
- return "";
413
- }
414
-
415
- function normalizeLowQualityThreshold(threshold: number | undefined) {
416
- const value = threshold ?? 50;
417
- if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
418
- throw new Error("lowQualityThreshold must be a non-negative integer");
419
- }
420
- return value;
421
- }
422
-
423
- function buildTimelineQualityClause(
424
- qualityFilter: TimelineQualityFilter,
425
- lowQualityThreshold: number,
426
- ) {
427
- if (qualityFilter === "all") {
428
- return { sql: "", params: [] };
429
- }
430
-
431
- return {
432
- sql: `
433
- and not (
434
- t.text like 'RT @%'
435
- or (
436
- t.like_count < ?
437
- and (
438
- (
439
- length(trim(replace(t.text, 'https://t.co/', ''))) < 16
440
- and t.media_count = 0
441
- )
442
- or (
443
- t.text like '@%'
444
- and length(trim(t.text)) < 60
445
- )
446
- or (
447
- t.text glob '*https://t.co/*'
448
- and t.media_count = 0
449
- and length(trim(replace(t.text, 'https://t.co/', ''))) < 45
450
- )
451
- )
452
- )
453
- )
454
- `,
455
- params: [lowQualityThreshold],
456
- };
457
- }
458
-
459
- function getTimelineQualityReason(
460
- row: Record<string, unknown>,
461
- lowQualityThreshold: number,
462
- ) {
463
- const text = String(row.text);
464
- const trimmed = text.trim();
465
- const strippedShortUrlText = text.replaceAll("https://t.co/", "").trim();
466
- const likeCount = Number(row.like_count);
467
- const mediaCount = Number(row.media_count);
468
-
469
- if (text.startsWith("RT @")) {
470
- return "drop:rt";
471
- }
472
-
473
- if (likeCount < lowQualityThreshold) {
474
- if (text.startsWith("@") && trimmed.length < 60) {
475
- return "drop:short-reply";
476
- }
477
- if (
478
- text.includes("https://t.co/") &&
479
- mediaCount === 0 &&
480
- strippedShortUrlText.length < 45
481
- ) {
482
- return "drop:short-link-only";
483
- }
484
- if (strippedShortUrlText.length < 16 && mediaCount === 0) {
485
- return "drop:short-text";
486
- }
487
- }
488
-
489
- if (mediaCount > 0) {
490
- return "keep:has-media";
491
- }
492
- if (likeCount >= lowQualityThreshold) {
493
- return "keep:high-likes";
494
- }
495
- return "keep:long-text";
496
- }
497
-
498
- const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
499
-
500
- export function listTimelineItems({
501
- resource,
502
- account,
503
- search,
504
- replyFilter = "all",
505
- since,
506
- until,
507
- untilId,
508
- includeReplies = true,
509
- qualityFilter = "all",
510
- lowQualityThreshold,
511
- includeQualityReason = false,
512
- likedOnly = false,
513
- bookmarkedOnly = false,
514
- limit = 18,
515
- }: TimelineQuery): TimelineItem[] {
516
- const db = getReadDb();
517
- const kind = resource === "mentions" ? "mention" : resource;
518
- const params: Array<string | number> = [];
519
- const normalizedLowQualityThreshold =
520
- normalizeLowQualityThreshold(lowQualityThreshold);
521
- const shouldDedupeAcrossAccounts = !account || account === "all";
522
- let timelineEdgesCte = `
523
- with timeline_edges as (
524
- select account_id, tweet_id, kind, raw_json
525
- from tweet_account_edges
526
- where kind = ?
527
- union all
528
- select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
529
- from tweets legacy
530
- where legacy.kind = ?
531
- and not exists (
532
- select 1
533
- from tweet_account_edges edge
534
- where edge.account_id = legacy.account_id
535
- and edge.tweet_id = legacy.id
536
- and edge.kind = legacy.kind
537
- )
538
- )
539
- `;
540
- const unwindowedTimelineEdgesCte = timelineEdgesCte;
541
- let usedRecentEdgeWindow = false;
542
- let join = "";
543
- let where = "where t.kind = ?";
544
- let searchSnippetSelect = "";
545
-
546
- const canUseRecentEdgeWindow =
547
- !likedOnly &&
548
- !bookmarkedOnly &&
549
- !account &&
550
- !search?.trim() &&
551
- replyFilter === "all" &&
552
- !since?.trim() &&
553
- !until?.trim() &&
554
- includeReplies &&
555
- qualityFilter === "all";
556
-
557
- if (likedOnly || bookmarkedOnly) {
558
- if (likedOnly && bookmarkedOnly) {
559
- timelineEdgesCte = `
560
- with timeline_edges as (
561
- select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json
562
- from tweet_collections likes
563
- join tweet_collections bookmarks
564
- on bookmarks.account_id = likes.account_id
565
- and bookmarks.tweet_id = likes.tweet_id
566
- and bookmarks.kind = 'bookmarks'
567
- where likes.kind = 'likes'
568
- union all
569
- select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
570
- from tweets legacy
571
- where legacy.liked = 1
572
- and legacy.bookmarked = 1
573
- and not exists (
574
- select 1
575
- from tweet_collections collection
576
- where collection.account_id = legacy.account_id
577
- and collection.tweet_id = legacy.id
578
- and collection.kind in ('likes', 'bookmarks')
579
- )
580
- )
581
- `;
582
- } else {
583
- const collectionKind = likedOnly ? "likes" : "bookmarks";
584
- const legacyColumn = likedOnly ? "liked" : "bookmarked";
585
- timelineEdgesCte = `
586
- with timeline_edges as (
587
- select account_id, tweet_id, 'home' as kind, raw_json
588
- from tweet_collections
589
- where kind = ?
590
- union all
591
- select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
592
- from tweets legacy
593
- where legacy.${legacyColumn} = 1
594
- and not exists (
595
- select 1
596
- from tweet_collections collection
597
- where collection.account_id = legacy.account_id
598
- and collection.tweet_id = legacy.id
599
- and collection.kind = ?
600
- )
601
- )
602
- `;
603
- params.push(collectionKind, collectionKind);
604
- }
605
- where = "where 1 = 1";
606
- } else if (canUseRecentEdgeWindow) {
607
- usedRecentEdgeWindow = true;
608
- timelineEdgesCte = `
609
- with timeline_edges as (
610
- select account_id, tweet_id, kind, raw_json
611
- from tweet_account_edges
612
- where kind = ?
613
- and tweet_id in (
614
- select id
615
- from tweets
616
- order by created_at desc
617
- limit ?
618
- )
619
- union all
620
- select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
621
- from tweets legacy
622
- where legacy.kind = ?
623
- and legacy.id in (
624
- select id
625
- from tweets
626
- order by created_at desc
627
- limit ?
628
- )
629
- and not exists (
630
- select 1
631
- from tweet_account_edges edge
632
- where edge.account_id = legacy.account_id
633
- and edge.tweet_id = legacy.id
634
- and edge.kind = legacy.kind
635
- )
636
- )
637
- `;
638
- const candidateLimit = Math.max(
639
- RECENT_TIMELINE_EDGE_CANDIDATES,
640
- limit * 50,
641
- );
642
- params.push(kind, candidateLimit, kind, candidateLimit);
643
- where = "where e.kind = ?";
644
- params.push(kind);
645
- } else {
646
- params.push(kind, kind);
647
- where = "where e.kind = ?";
648
- params.push(kind);
649
- }
650
-
651
- if (account && account !== "all") {
652
- where += " and e.account_id = ?";
653
- params.push(account);
654
- }
655
-
656
- if (shouldDedupeAcrossAccounts) {
657
- where += `
658
- and e.account_id = (
659
- select e2.account_id
660
- from timeline_edges e2
661
- join accounts a2 on a2.id = e2.account_id
662
- where e2.tweet_id = e.tweet_id
663
- and e2.kind = e.kind
664
- order by a2.is_default desc, e2.account_id asc
665
- limit 1
666
- )
667
- `;
668
- }
669
-
670
- where += buildReplyClause(replyFilter).replaceAll(
671
- "is_replied",
672
- "t.is_replied",
673
- );
674
- const qualityClause = buildTimelineQualityClause(
675
- qualityFilter,
676
- normalizedLowQualityThreshold,
677
- );
678
- where += qualityClause.sql;
679
- params.push(...qualityClause.params);
680
-
681
- if (!includeReplies) {
682
- where += " and t.text not like '@%'";
683
- }
684
-
685
- if (since?.trim()) {
686
- where += " and t.created_at >= ?";
687
- params.push(since.trim());
688
- }
689
-
690
- if (until?.trim()) {
691
- // Deterministic keyset cursor: page on (created_at, id) so rows that share
692
- // the boundary timestamp are not skipped. Uses the same text comparison as
693
- // the `order by t.created_at desc, t.id desc` below, which is a total order
694
- // because t.id is unique.
695
- if (untilId?.trim()) {
696
- where += " and (t.created_at < ? or (t.created_at = ? and t.id < ?))";
697
- params.push(until.trim(), until.trim(), untilId.trim());
698
- } else {
699
- where += " and t.created_at < ?";
700
- params.push(until.trim());
701
- }
702
- }
703
-
704
- const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
705
- if (ftsSearch) {
706
- join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
707
- where += " and tweets_fts.text match ?";
708
- searchSnippetSelect =
709
- ", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
710
- params.push(ftsSearch);
711
- }
712
-
713
- params.push(limit);
714
-
715
- const buildTimelineSelectSql = (timelineEdgesSql: string) => `
716
- ${timelineEdgesSql}
717
- select
718
- t.id,
719
- e.account_id,
720
- a.handle as account_handle,
721
- e.kind,
722
- e.raw_json as edge_raw_json,
723
- t.text,
724
- t.created_at,
725
- t.reply_to_id,
726
- t.is_replied,
727
- t.like_count,
728
- t.media_count,
729
- case
730
- when exists (
731
- select 1 from tweet_collections collection
732
- where collection.account_id = e.account_id
733
- and collection.tweet_id = t.id
734
- and collection.kind = 'bookmarks'
735
- ) then 1
736
- when t.account_id = e.account_id and t.bookmarked = 1 then 1
737
- else 0
738
- end as bookmarked,
739
- case
740
- when exists (
741
- select 1 from tweet_collections collection
742
- where collection.account_id = e.account_id
743
- and collection.tweet_id = t.id
744
- and collection.kind = 'likes'
745
- ) then 1
746
- when t.account_id = e.account_id and t.liked = 1 then 1
747
- else 0
748
- end as liked,
749
- t.entities_json,
750
- t.media_json,
751
- t.quoted_tweet_id,
752
- p.id as profile_id,
753
- p.handle,
754
- p.display_name,
755
- p.bio,
756
- p.followers_count,
757
- p.following_count,
758
- p.avatar_hue,
759
- p.avatar_url,
760
- p.location as profile_location,
761
- p.url as profile_url,
762
- p.verified_type as profile_verified_type,
763
- p.entities_json as profile_entities_json,
764
- p.created_at as profile_created_at,
765
- rt.id as reply_id,
766
- rt.text as reply_text,
767
- rt.created_at as reply_created_at,
768
- rt.reply_to_id as reply_reply_to_id,
769
- rt.entities_json as reply_entities_json,
770
- rt.media_json as reply_media_json,
771
- rp.id as reply_profile_id,
772
- rp.handle as reply_handle,
773
- rp.display_name as reply_display_name,
774
- rp.bio as reply_bio,
775
- rp.followers_count as reply_followers_count,
776
- rp.following_count as reply_following_count,
777
- rp.avatar_hue as reply_avatar_hue,
778
- rp.avatar_url as reply_avatar_url,
779
- rp.created_at as reply_profile_created_at,
780
- qt.id as quoted_id,
781
- qt.text as quoted_text,
782
- qt.created_at as quoted_created_at,
783
- qt.reply_to_id as quoted_reply_to_id,
784
- qt.entities_json as quoted_entities_json,
785
- qt.media_json as quoted_media_json,
786
- qp.id as quoted_profile_id,
787
- qp.handle as quoted_handle,
788
- qp.display_name as quoted_display_name,
789
- qp.bio as quoted_bio,
790
- qp.followers_count as quoted_followers_count,
791
- qp.following_count as quoted_following_count,
792
- qp.avatar_hue as quoted_avatar_hue,
793
- qp.avatar_url as quoted_avatar_url,
794
- qp.created_at as quoted_profile_created_at
795
- ${searchSnippetSelect}
796
- from timeline_edges e
797
- join tweets t on t.id = e.tweet_id
798
- join accounts a on a.id = e.account_id
799
- join profiles p on p.id = t.author_profile_id
800
- left join tweets rt on rt.id = t.reply_to_id
801
- left join profiles rp on rp.id = rt.author_profile_id
802
- left join tweets qt on qt.id = t.quoted_tweet_id
803
- left join profiles qp on qp.id = qt.author_profile_id
804
- ${join}
805
- ${where}
806
- order by t.created_at desc, t.id desc
807
- limit ?
808
- `;
809
-
810
- let rows = db
811
- .prepare(buildTimelineSelectSql(timelineEdgesCte))
812
- .all(...params) as Array<Record<string, unknown>>;
813
-
814
- if (usedRecentEdgeWindow && rows.length < limit) {
815
- rows = db
816
- .prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
817
- .all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
818
- }
819
-
820
- const urlExpansionCache: UrlExpansionCache = new Map();
821
- const profileByHandleCache: ProfileByHandleCache = new Map();
822
- return rows.map((row) => {
823
- const author = {
824
- id: String(row.profile_id),
825
- handle: String(row.handle),
826
- displayName: String(row.display_name),
827
- bio: String(row.bio),
828
- followersCount: Number(row.followers_count),
829
- followingCount: Number(row.following_count ?? 0),
830
- avatarHue: Number(row.avatar_hue),
831
- avatarUrl:
832
- typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
833
- createdAt: String(row.profile_created_at),
834
- };
835
- const rowProfiles: Record<string, ProfileRecord> = {
836
- [author.id]: author,
837
- ...(row.reply_profile_id
838
- ? {
839
- [String(row.reply_profile_id)]: toProfile({
840
- id: row.reply_profile_id,
841
- handle: row.reply_handle,
842
- display_name: row.reply_display_name,
843
- bio: row.reply_bio,
844
- followers_count: row.reply_followers_count,
845
- following_count: row.reply_following_count,
846
- avatar_hue: row.reply_avatar_hue,
847
- avatar_url: row.reply_avatar_url,
848
- created_at: row.reply_profile_created_at,
849
- }),
850
- }
851
- : {}),
852
- ...(row.quoted_profile_id
853
- ? {
854
- [String(row.quoted_profile_id)]: toProfile({
855
- id: row.quoted_profile_id,
856
- handle: row.quoted_handle,
857
- display_name: row.quoted_display_name,
858
- bio: row.quoted_bio,
859
- followers_count: row.quoted_followers_count,
860
- following_count: row.quoted_following_count,
861
- avatar_hue: row.quoted_avatar_hue,
862
- avatar_url: row.quoted_avatar_url,
863
- created_at: row.quoted_profile_created_at,
864
- }),
865
- }
866
- : {}),
867
- };
868
- const resolveProfileByHandle = (handle: string) =>
869
- getProfileByHandle(db, profileByHandleCache, handle, rowProfiles);
870
- const text = String(row.text);
871
- const entities = enrichTimelineEntities(
872
- db,
873
- urlExpansionCache,
874
- text,
875
- parseJsonField<TweetEntities>(row.entities_json, {}),
876
- rowProfiles,
877
- resolveProfileByHandle,
878
- );
879
- const item = {
880
- id: String(row.id),
881
- accountId: String(row.account_id),
882
- accountHandle: String(row.account_handle),
883
- kind: row.kind as TimelineItem["kind"],
884
- text,
885
- ...(typeof row.search_snippet === "string"
886
- ? { searchSnippet: row.search_snippet }
887
- : {}),
888
- createdAt: String(row.created_at),
889
- replyToId:
890
- typeof row.reply_to_id === "string" ? String(row.reply_to_id) : null,
891
- isReplied: Boolean(row.is_replied),
892
- likeCount: Number(row.like_count),
893
- mediaCount: Number(row.media_count),
894
- bookmarked: Boolean(row.bookmarked),
895
- liked: Boolean(row.liked),
896
- author,
897
- entities,
898
- media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
899
- replyToTweet: buildEmbeddedTweet(
900
- db,
901
- urlExpansionCache,
902
- row,
903
- "reply_",
904
- resolveProfileByHandle,
905
- ),
906
- quotedTweet: buildEmbeddedTweet(
907
- db,
908
- urlExpansionCache,
909
- row,
910
- "quoted_",
911
- resolveProfileByHandle,
912
- ),
913
- retweetedTweet: buildRetweetedTweet(
914
- db,
915
- urlExpansionCache,
916
- row,
917
- resolveProfileByHandle,
918
- ),
919
- };
920
- return includeQualityReason
921
- ? {
922
- ...item,
923
- qualityReason: getTimelineQualityReason(
924
- row,
925
- normalizedLowQualityThreshold,
926
- ),
927
- }
928
- : item;
929
- });
930
- }
931
-
932
- function conversationTweetSelect(accountId?: string) {
933
- const collectionStateSelect = accountId
934
- ? `
935
- case
936
- when exists (
937
- select 1 from tweet_collections collection
938
- where collection.account_id = ?
939
- and collection.tweet_id = t.id
940
- and collection.kind = 'bookmarks'
941
- ) then 1
942
- when t.account_id = ? and t.bookmarked = 1 then 1
943
- else 0
944
- end as bookmarked,
945
- case
946
- when exists (
947
- select 1 from tweet_collections collection
948
- where collection.account_id = ?
949
- and collection.tweet_id = t.id
950
- and collection.kind = 'likes'
951
- ) then 1
952
- when t.account_id = ? and t.liked = 1 then 1
953
- else 0
954
- end as liked,`
955
- : `
956
- t.bookmarked,
957
- t.liked,`;
958
- return `
959
- select
960
- t.id,
961
- t.text,
962
- t.created_at,
963
- t.reply_to_id,
964
- t.is_replied,
965
- t.like_count,
966
- t.media_count,
967
- ${collectionStateSelect}
968
- t.entities_json,
969
- t.media_json,
970
- p.id as profile_id,
971
- p.handle,
972
- p.display_name,
973
- p.bio,
974
- p.followers_count,
975
- p.following_count,
976
- p.avatar_hue,
977
- p.avatar_url,
978
- p.created_at as profile_created_at
979
- from tweets t
980
- join profiles p on p.id = t.author_profile_id
981
- `;
982
- }
983
-
984
- function getTweetById(
985
- db: Database,
986
- urlExpansionCache: UrlExpansionCache,
987
- tweetId: string,
988
- resolveProfileByHandle?: (handle: string) => ProfileRecord,
989
- accountId?: string,
990
- ): EmbeddedTweet | null {
991
- const stateParams = accountId
992
- ? [accountId, accountId, accountId, accountId]
993
- : [];
994
- const row = db
995
- .prepare(`${conversationTweetSelect(accountId)} where t.id = ?`)
996
- .get(...stateParams, tweetId) as Record<string, unknown> | undefined;
997
- if (!row) return null;
998
- return buildEmbeddedTweet(
999
- db,
1000
- urlExpansionCache,
1001
- row,
1002
- "",
1003
- resolveProfileByHandle,
1004
- );
1005
- }
1006
-
1007
- export function getTweetsByIds(
1008
- tweetIds: string[],
1009
- accountId?: string,
1010
- ): EmbeddedTweet[] {
1011
- const db = getReadDb();
1012
- const scopedAccountId =
1013
- accountId && accountId !== "all" ? accountId : undefined;
1014
- const urlExpansionCache: UrlExpansionCache = new Map();
1015
- const profileByHandleCache: ProfileByHandleCache = new Map();
1016
- const resolveProfileByHandle = (handle: string) =>
1017
- getProfileByHandle(db, profileByHandleCache, handle);
1018
- const seen = new Set<string>();
1019
- const tweets: EmbeddedTweet[] = [];
1020
-
1021
- for (const tweetId of tweetIds) {
1022
- const normalized = tweetId.trim().replace(/^tweet_/, "");
1023
- if (!normalized || seen.has(normalized)) continue;
1024
- seen.add(normalized);
1025
- if (
1026
- scopedAccountId &&
1027
- !db
1028
- .prepare(
1029
- `
1030
- select 1
1031
- from tweets tweet
1032
- where tweet.id = ?
1033
- and (
1034
- tweet.account_id = ?
1035
- or exists (
1036
- select 1
1037
- from tweet_account_edges edge
1038
- where edge.account_id = ?
1039
- and edge.tweet_id = tweet.id
1040
- )
1041
- or exists (
1042
- select 1
1043
- from tweet_collections collection
1044
- where collection.account_id = ?
1045
- and collection.tweet_id = tweet.id
1046
- )
1047
- )
1048
- limit 1
1049
- `,
1050
- )
1051
- .get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
1052
- ) {
1053
- continue;
1054
- }
1055
- const tweet = getTweetById(
1056
- db,
1057
- urlExpansionCache,
1058
- normalized,
1059
- resolveProfileByHandle,
1060
- scopedAccountId,
1061
- );
1062
- if (tweet) tweets.push(tweet);
1063
- }
1064
-
1065
- return tweets;
1066
- }
1067
-
1068
- function listTweetDescendants(
1069
- db: Database,
1070
- urlExpansionCache: UrlExpansionCache,
1071
- rootId: string,
1072
- limit: number,
1073
- resolveProfileByHandle?: (handle: string) => ProfileRecord,
1074
- ) {
1075
- if (limit <= 0) return [];
1076
- const rows = db
1077
- .prepare(
1078
- `
1079
- with recursive branch(id, depth) as (
1080
- select t.id, 0
1081
- from tweets t
1082
- where t.id = ?
1083
- union all
1084
- select child.id, branch.depth + 1
1085
- from tweets child
1086
- join branch on child.reply_to_id = branch.id
1087
- where branch.depth < 8
1088
- )
1089
- ${conversationTweetSelect()}
1090
- join branch on branch.id = t.id
1091
- where t.id != ?
1092
- order by t.created_at asc
1093
- limit ?
1094
- `,
1095
- )
1096
- .all(rootId, rootId, limit) as Array<Record<string, unknown>>;
1097
-
1098
- return rows
1099
- .map((row) =>
1100
- buildEmbeddedTweet(
1101
- db,
1102
- urlExpansionCache,
1103
- row,
1104
- "",
1105
- resolveProfileByHandle,
1106
- ),
1107
- )
1108
- .filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
1109
- }
1110
-
1111
- function appendConversationTweets(
1112
- target: EmbeddedTweet[],
1113
- seen: Set<string>,
1114
- items: EmbeddedTweet[],
1115
- remaining: number,
1116
- ) {
1117
- for (const tweet of items) {
1118
- if (target.length >= remaining || seen.has(tweet.id)) continue;
1119
- seen.add(tweet.id);
1120
- target.push(tweet);
1121
- }
1122
- }
1123
-
1124
- export function getTweetConversation(
1125
- tweetId: string,
1126
- limit = 80,
1127
- ): TweetConversationResponse | null {
1128
- const db = getReadDb();
1129
- const urlExpansionCache: UrlExpansionCache = new Map();
1130
- const profileByHandleCache: ProfileByHandleCache = new Map();
1131
- const resolveProfileByHandle = (handle: string) =>
1132
- getProfileByHandle(db, profileByHandleCache, handle);
1133
- const anchor = getTweetById(
1134
- db,
1135
- urlExpansionCache,
1136
- tweetId,
1137
- resolveProfileByHandle,
1138
- );
1139
- if (!anchor) return null;
1140
-
1141
- const ancestors: EmbeddedTweet[] = [];
1142
- let current = anchor;
1143
- for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
1144
- const parent = getTweetById(
1145
- db,
1146
- urlExpansionCache,
1147
- current.replyToId,
1148
- resolveProfileByHandle,
1149
- );
1150
- if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
1151
- ancestors.push(parent);
1152
- current = parent;
1153
- }
1154
-
1155
- const required = [...ancestors].reverse();
1156
- required.push(anchor);
1157
- const root = required[0] ?? anchor;
1158
- const seen = new Set<string>();
1159
- const items = required.filter((tweet) => {
1160
- if (seen.has(tweet.id)) return false;
1161
- seen.add(tweet.id);
1162
- return true;
1163
- });
1164
- const remainingAfterRequired = Math.max(0, limit - items.length);
1165
- const focusedDescendants = listTweetDescendants(
1166
- db,
1167
- urlExpansionCache,
1168
- anchor.id,
1169
- remainingAfterRequired,
1170
- resolveProfileByHandle,
1171
- );
1172
- appendConversationTweets(items, seen, focusedDescendants, limit);
1173
-
1174
- if (items.length < limit && root.id !== anchor.id) {
1175
- const ambientDescendants = listTweetDescendants(
1176
- db,
1177
- urlExpansionCache,
1178
- root.id,
1179
- limit,
1180
- resolveProfileByHandle,
1181
- );
1182
- appendConversationTweets(items, seen, ambientDescendants, limit);
1183
- }
1184
-
1185
- items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
1186
-
1187
- return {
1188
- anchorId: anchor.id,
1189
- items,
1190
- };
1191
- }