birdclaw 0.8.2 → 0.8.3
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.
- package/CHANGELOG.md +16 -0
- package/package.json +2 -1
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +84 -330
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +36 -110
- package/src/lib/queries.ts +6 -2381
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +48 -12
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -4
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/src/lib/client-cache.ts +0 -109
package/src/lib/queries.ts
CHANGED
|
@@ -1,2381 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { fetchProfileAffiliations } from "./profile-affiliations";
|
|
8
|
-
import { displayUrlForLink, enrichFallbackUrlEntities } from "./tweet-render";
|
|
9
|
-
import type {
|
|
10
|
-
AccountRecord,
|
|
11
|
-
DmConversationItem,
|
|
12
|
-
DmMessageItem,
|
|
13
|
-
DmQuery,
|
|
14
|
-
EmbeddedTweet,
|
|
15
|
-
ProfileRecord,
|
|
16
|
-
QueryEnvelope,
|
|
17
|
-
QueryResponse,
|
|
18
|
-
ReplyFilter,
|
|
19
|
-
TimelineQualityFilter,
|
|
20
|
-
TimelineItem,
|
|
21
|
-
TimelineQuery,
|
|
22
|
-
TweetEntities,
|
|
23
|
-
TweetConversationResponse,
|
|
24
|
-
TweetMediaItem,
|
|
25
|
-
TweetUrlEntity,
|
|
26
|
-
} from "./types";
|
|
27
|
-
import {
|
|
28
|
-
dmViaXurlEffect,
|
|
29
|
-
getTransportStatusEffect,
|
|
30
|
-
lookupAuthenticatedUserFresh,
|
|
31
|
-
postViaXurlEffect,
|
|
32
|
-
replyViaXurlEffect,
|
|
33
|
-
} from "./xurl";
|
|
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
|
-
|
|
99
|
-
function getInfluenceScore(followersCount: number) {
|
|
100
|
-
return Math.round(Math.log10(followersCount + 10) * 24);
|
|
101
|
-
}
|
|
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
|
-
|
|
114
|
-
function getInfluenceLabel(score: number) {
|
|
115
|
-
if (score >= 150) return "very high";
|
|
116
|
-
if (score >= 120) return "high";
|
|
117
|
-
if (score >= 90) return "medium";
|
|
118
|
-
return "emerging";
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
122
|
-
const followingCount = Number(row.following_count ?? 0);
|
|
123
|
-
const entities = parseJsonField<Record<string, unknown> | undefined>(
|
|
124
|
-
row.entities_json,
|
|
125
|
-
undefined,
|
|
126
|
-
);
|
|
127
|
-
return {
|
|
128
|
-
id: String(row.id),
|
|
129
|
-
handle: String(row.handle),
|
|
130
|
-
displayName: String(row.display_name),
|
|
131
|
-
bio: String(row.bio),
|
|
132
|
-
followersCount: Number(row.followers_count),
|
|
133
|
-
...(Number.isFinite(followingCount) ? { followingCount } : {}),
|
|
134
|
-
avatarHue: Number(row.avatar_hue),
|
|
135
|
-
avatarUrl:
|
|
136
|
-
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
137
|
-
...(typeof row.location === "string" && row.location.length > 0
|
|
138
|
-
? { location: row.location }
|
|
139
|
-
: {}),
|
|
140
|
-
...(typeof row.url === "string" && row.url.length > 0
|
|
141
|
-
? { url: row.url }
|
|
142
|
-
: {}),
|
|
143
|
-
...(typeof row.verified_type === "string" && row.verified_type.length > 0
|
|
144
|
-
? { verifiedType: row.verified_type }
|
|
145
|
-
: {}),
|
|
146
|
-
...(entities ? { entities } : {}),
|
|
147
|
-
createdAt: String(row.created_at),
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function parseJsonField<T>(value: unknown, fallback: T): T {
|
|
152
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
153
|
-
return fallback;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
return JSON.parse(value) as T;
|
|
158
|
-
} catch {
|
|
159
|
-
return fallback;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
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
|
-
|
|
281
|
-
function toFtsSearchQuery(value: string) {
|
|
282
|
-
const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
283
|
-
return terms
|
|
284
|
-
.map((term) => term.trim())
|
|
285
|
-
.filter((term) => term.length > 0)
|
|
286
|
-
.map((term) => `"${term.replaceAll('"', '""')}"`)
|
|
287
|
-
.join(" ");
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function enrichEntities(
|
|
291
|
-
entities: TweetEntities,
|
|
292
|
-
profiles: Record<string, ProfileRecord>,
|
|
293
|
-
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
294
|
-
): TweetEntities {
|
|
295
|
-
const mentions = entities.mentions?.map((mention) => {
|
|
296
|
-
const profile =
|
|
297
|
-
(mention.id ? profiles[mention.id] : undefined) ??
|
|
298
|
-
Object.values(profiles).find(
|
|
299
|
-
(candidate) =>
|
|
300
|
-
normalizeProfileHandle(candidate.handle) ===
|
|
301
|
-
normalizeProfileHandle(mention.username),
|
|
302
|
-
) ??
|
|
303
|
-
resolveProfileByHandle?.(mention.username);
|
|
304
|
-
return profile ? { ...mention, profile } : mention;
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
...entities,
|
|
309
|
-
...(mentions ? { mentions } : {}),
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
type UrlExpansionCache = Map<
|
|
314
|
-
string,
|
|
315
|
-
| (Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
|
|
316
|
-
Partial<
|
|
317
|
-
Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
|
|
318
|
-
>)
|
|
319
|
-
| null
|
|
320
|
-
>;
|
|
321
|
-
|
|
322
|
-
function getUrlExpansion(
|
|
323
|
-
db: Database,
|
|
324
|
-
cache: UrlExpansionCache,
|
|
325
|
-
rawUrl: string,
|
|
326
|
-
) {
|
|
327
|
-
if (cache.has(rawUrl)) {
|
|
328
|
-
return cache.get(rawUrl);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const row = db
|
|
332
|
-
.prepare(
|
|
333
|
-
`
|
|
334
|
-
select expanded_url, final_url, title, description, image_url, site_name
|
|
335
|
-
from url_expansions
|
|
336
|
-
where short_url = ?
|
|
337
|
-
and status = 'hit'
|
|
338
|
-
`,
|
|
339
|
-
)
|
|
340
|
-
.get(rawUrl) as
|
|
341
|
-
| {
|
|
342
|
-
expanded_url: string;
|
|
343
|
-
final_url: string;
|
|
344
|
-
title: string | null;
|
|
345
|
-
description: string | null;
|
|
346
|
-
image_url: string | null;
|
|
347
|
-
site_name: string | null;
|
|
348
|
-
}
|
|
349
|
-
| undefined;
|
|
350
|
-
if (!row) {
|
|
351
|
-
cache.set(rawUrl, null);
|
|
352
|
-
return null;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const expandedUrl = row.final_url || row.expanded_url || rawUrl;
|
|
356
|
-
const expansion = {
|
|
357
|
-
expandedUrl,
|
|
358
|
-
displayUrl: displayUrlForLink(expandedUrl),
|
|
359
|
-
...(row.title ? { title: row.title } : {}),
|
|
360
|
-
...(row.description ? { description: row.description } : {}),
|
|
361
|
-
...(row.image_url ? { imageUrl: row.image_url } : {}),
|
|
362
|
-
...(row.site_name ? { siteName: row.site_name } : {}),
|
|
363
|
-
};
|
|
364
|
-
cache.set(rawUrl, expansion);
|
|
365
|
-
return expansion;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function enrichTimelineEntities(
|
|
369
|
-
db: Database,
|
|
370
|
-
urlExpansionCache: UrlExpansionCache,
|
|
371
|
-
text: string,
|
|
372
|
-
entities: TweetEntities,
|
|
373
|
-
profiles: Record<string, ProfileRecord>,
|
|
374
|
-
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
375
|
-
): TweetEntities {
|
|
376
|
-
const withUrls = enrichFallbackUrlEntities(
|
|
377
|
-
text,
|
|
378
|
-
enrichEntities(entities, profiles, resolveProfileByHandle),
|
|
379
|
-
(rawUrl) => getUrlExpansion(db, urlExpansionCache, rawUrl),
|
|
380
|
-
);
|
|
381
|
-
return resolveProfileByHandle
|
|
382
|
-
? enrichFallbackMentionEntities(text, withUrls, resolveProfileByHandle)
|
|
383
|
-
: withUrls;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function buildEmbeddedTweet(
|
|
387
|
-
db: Database,
|
|
388
|
-
urlExpansionCache: UrlExpansionCache,
|
|
389
|
-
row: Record<string, unknown>,
|
|
390
|
-
prefix: string,
|
|
391
|
-
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
392
|
-
): EmbeddedTweet | null {
|
|
393
|
-
if (!row[`${prefix}id`]) {
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const author = toProfile({
|
|
398
|
-
id: row[`${prefix}profile_id`],
|
|
399
|
-
handle: row[`${prefix}handle`],
|
|
400
|
-
display_name: row[`${prefix}display_name`],
|
|
401
|
-
bio: row[`${prefix}bio`],
|
|
402
|
-
followers_count: row[`${prefix}followers_count`],
|
|
403
|
-
following_count: row[`${prefix}following_count`],
|
|
404
|
-
avatar_hue: row[`${prefix}avatar_hue`],
|
|
405
|
-
avatar_url: row[`${prefix}avatar_url`],
|
|
406
|
-
created_at: row[`${prefix}profile_created_at`],
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
const text = String(row[`${prefix}text`] ?? "");
|
|
410
|
-
return {
|
|
411
|
-
id: String(row[`${prefix}id`]),
|
|
412
|
-
text,
|
|
413
|
-
createdAt: String(row[`${prefix}created_at`] ?? new Date(0).toISOString()),
|
|
414
|
-
replyToId:
|
|
415
|
-
typeof row[`${prefix}reply_to_id`] === "string"
|
|
416
|
-
? String(row[`${prefix}reply_to_id`])
|
|
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`]) }),
|
|
433
|
-
author,
|
|
434
|
-
entities: enrichTimelineEntities(
|
|
435
|
-
db,
|
|
436
|
-
urlExpansionCache,
|
|
437
|
-
text,
|
|
438
|
-
parseJsonField<TweetEntities>(row[`${prefix}entities_json`], {}),
|
|
439
|
-
{
|
|
440
|
-
[author.id]: author,
|
|
441
|
-
},
|
|
442
|
-
resolveProfileByHandle,
|
|
443
|
-
),
|
|
444
|
-
media: parseJsonField<TweetMediaItem[]>(row[`${prefix}media_json`], []),
|
|
445
|
-
};
|
|
446
|
-
}
|
|
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
|
-
|
|
549
|
-
function buildReplyClause(replyFilter: ReplyFilter) {
|
|
550
|
-
if (replyFilter === "replied") {
|
|
551
|
-
return " and is_replied = 1";
|
|
552
|
-
}
|
|
553
|
-
if (replyFilter === "unreplied") {
|
|
554
|
-
return " and is_replied = 0";
|
|
555
|
-
}
|
|
556
|
-
return "";
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function normalizeLowQualityThreshold(threshold: number | undefined) {
|
|
560
|
-
const value = threshold ?? 50;
|
|
561
|
-
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
562
|
-
throw new Error("lowQualityThreshold must be a non-negative integer");
|
|
563
|
-
}
|
|
564
|
-
return value;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function buildTimelineQualityClause(
|
|
568
|
-
qualityFilter: TimelineQualityFilter,
|
|
569
|
-
lowQualityThreshold: number,
|
|
570
|
-
) {
|
|
571
|
-
if (qualityFilter === "all") {
|
|
572
|
-
return { sql: "", params: [] };
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return {
|
|
576
|
-
sql: `
|
|
577
|
-
and not (
|
|
578
|
-
t.text like 'RT @%'
|
|
579
|
-
or (
|
|
580
|
-
t.like_count < ?
|
|
581
|
-
and (
|
|
582
|
-
(
|
|
583
|
-
length(trim(replace(t.text, 'https://t.co/', ''))) < 16
|
|
584
|
-
and t.media_count = 0
|
|
585
|
-
)
|
|
586
|
-
or (
|
|
587
|
-
t.text like '@%'
|
|
588
|
-
and length(trim(t.text)) < 60
|
|
589
|
-
)
|
|
590
|
-
or (
|
|
591
|
-
t.text glob '*https://t.co/*'
|
|
592
|
-
and t.media_count = 0
|
|
593
|
-
and length(trim(replace(t.text, 'https://t.co/', ''))) < 45
|
|
594
|
-
)
|
|
595
|
-
)
|
|
596
|
-
)
|
|
597
|
-
)
|
|
598
|
-
`,
|
|
599
|
-
params: [lowQualityThreshold],
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function getTimelineQualityReason(
|
|
604
|
-
row: Record<string, unknown>,
|
|
605
|
-
lowQualityThreshold: number,
|
|
606
|
-
) {
|
|
607
|
-
const text = String(row.text);
|
|
608
|
-
const trimmed = text.trim();
|
|
609
|
-
const strippedShortUrlText = text.replaceAll("https://t.co/", "").trim();
|
|
610
|
-
const likeCount = Number(row.like_count);
|
|
611
|
-
const mediaCount = Number(row.media_count);
|
|
612
|
-
|
|
613
|
-
if (text.startsWith("RT @")) {
|
|
614
|
-
return "drop:rt";
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
if (likeCount < lowQualityThreshold) {
|
|
618
|
-
if (text.startsWith("@") && trimmed.length < 60) {
|
|
619
|
-
return "drop:short-reply";
|
|
620
|
-
}
|
|
621
|
-
if (
|
|
622
|
-
text.includes("https://t.co/") &&
|
|
623
|
-
mediaCount === 0 &&
|
|
624
|
-
strippedShortUrlText.length < 45
|
|
625
|
-
) {
|
|
626
|
-
return "drop:short-link-only";
|
|
627
|
-
}
|
|
628
|
-
if (strippedShortUrlText.length < 16 && mediaCount === 0) {
|
|
629
|
-
return "drop:short-text";
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
if (mediaCount > 0) {
|
|
634
|
-
return "keep:has-media";
|
|
635
|
-
}
|
|
636
|
-
if (likeCount >= lowQualityThreshold) {
|
|
637
|
-
return "keep:high-likes";
|
|
638
|
-
}
|
|
639
|
-
return "keep:long-text";
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function countTimelineEdges(db: Database, kind: "home" | "mention") {
|
|
643
|
-
const row = db
|
|
644
|
-
.prepare(
|
|
645
|
-
`
|
|
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
|
-
)
|
|
668
|
-
`,
|
|
669
|
-
)
|
|
670
|
-
.get(kind, kind) as { count: number | bigint } | undefined;
|
|
671
|
-
return Number(row?.count ?? 0);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
|
|
675
|
-
|
|
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({
|
|
705
|
-
includeArchives = true,
|
|
706
|
-
}: { includeArchives?: boolean } = {}): Effect.Effect<QueryEnvelope, unknown> {
|
|
707
|
-
return Effect.gen(function* () {
|
|
708
|
-
const db = yield* trySync(() => getDb());
|
|
709
|
-
const nativeDb = yield* trySync(() => getNativeDb());
|
|
710
|
-
const homeCount = yield* trySync(() =>
|
|
711
|
-
countTimelineEdges(nativeDb, "home"),
|
|
712
|
-
);
|
|
713
|
-
const mentionCount = yield* trySync(() =>
|
|
714
|
-
countTimelineEdges(nativeDb, "mention"),
|
|
715
|
-
);
|
|
716
|
-
const counts = yield* Effect.all({
|
|
717
|
-
dms: tryPromise(() =>
|
|
718
|
-
db
|
|
719
|
-
.selectFrom("dm_conversations")
|
|
720
|
-
.select((eb) => eb.fn.countAll().as("count"))
|
|
721
|
-
.executeTakeFirstOrThrow(),
|
|
722
|
-
),
|
|
723
|
-
needsReply: tryPromise(() =>
|
|
724
|
-
db
|
|
725
|
-
.selectFrom("dm_conversations")
|
|
726
|
-
.select((eb) => eb.fn.countAll().as("count"))
|
|
727
|
-
.where("needs_reply", "=", 1)
|
|
728
|
-
.executeTakeFirstOrThrow(),
|
|
729
|
-
),
|
|
730
|
-
accounts: tryPromise(() =>
|
|
731
|
-
db
|
|
732
|
-
.selectFrom("accounts")
|
|
733
|
-
.selectAll()
|
|
734
|
-
.orderBy("is_default", "desc")
|
|
735
|
-
.orderBy("name", "asc")
|
|
736
|
-
.execute(),
|
|
737
|
-
),
|
|
738
|
-
archives: includeArchives
|
|
739
|
-
? findArchivesCachedEffect()
|
|
740
|
-
: Effect.succeed([]),
|
|
741
|
-
transport: getTransportStatusEffect(),
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
return {
|
|
745
|
-
stats: {
|
|
746
|
-
home: homeCount,
|
|
747
|
-
mentions: mentionCount,
|
|
748
|
-
dms: Number(counts.dms.count),
|
|
749
|
-
needsReply: Number(counts.needsReply.count),
|
|
750
|
-
inbox: mentionCount + Number(counts.needsReply.count),
|
|
751
|
-
},
|
|
752
|
-
accounts: counts.accounts.map((row) => {
|
|
753
|
-
const profile = getAccountProfileMeta(nativeDb, row);
|
|
754
|
-
return {
|
|
755
|
-
id: row.id,
|
|
756
|
-
name: row.name,
|
|
757
|
-
handle: row.handle,
|
|
758
|
-
externalUserId: row.external_user_id,
|
|
759
|
-
...(profile
|
|
760
|
-
? {
|
|
761
|
-
profileId: profile.id,
|
|
762
|
-
avatarHue: Number(profile.avatar_hue),
|
|
763
|
-
...(profile.avatar_url
|
|
764
|
-
? { avatarUrl: profile.avatar_url }
|
|
765
|
-
: {}),
|
|
766
|
-
}
|
|
767
|
-
: {}),
|
|
768
|
-
transport: row.transport,
|
|
769
|
-
isDefault: row.is_default,
|
|
770
|
-
createdAt: row.created_at,
|
|
771
|
-
};
|
|
772
|
-
}) satisfies AccountRecord[],
|
|
773
|
-
archives: counts.archives,
|
|
774
|
-
transport: counts.transport,
|
|
775
|
-
};
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
export function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
780
|
-
return runEffectPromise(getQueryEnvelopeEffect());
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
export function listTimelineItems({
|
|
784
|
-
resource,
|
|
785
|
-
account,
|
|
786
|
-
search,
|
|
787
|
-
replyFilter = "all",
|
|
788
|
-
since,
|
|
789
|
-
until,
|
|
790
|
-
untilId,
|
|
791
|
-
includeReplies = true,
|
|
792
|
-
qualityFilter = "all",
|
|
793
|
-
lowQualityThreshold,
|
|
794
|
-
includeQualityReason = false,
|
|
795
|
-
likedOnly = false,
|
|
796
|
-
bookmarkedOnly = false,
|
|
797
|
-
limit = 18,
|
|
798
|
-
}: TimelineQuery): TimelineItem[] {
|
|
799
|
-
const db = getNativeDb();
|
|
800
|
-
const kind = resource === "mentions" ? "mention" : resource;
|
|
801
|
-
const params: Array<string | number> = [];
|
|
802
|
-
const normalizedLowQualityThreshold =
|
|
803
|
-
normalizeLowQualityThreshold(lowQualityThreshold);
|
|
804
|
-
const shouldDedupeAcrossAccounts = !account || account === "all";
|
|
805
|
-
let timelineEdgesCte = `
|
|
806
|
-
with timeline_edges as (
|
|
807
|
-
select account_id, tweet_id, kind, raw_json
|
|
808
|
-
from tweet_account_edges
|
|
809
|
-
where kind = ?
|
|
810
|
-
union all
|
|
811
|
-
select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
|
|
812
|
-
from tweets legacy
|
|
813
|
-
where legacy.kind = ?
|
|
814
|
-
and not exists (
|
|
815
|
-
select 1
|
|
816
|
-
from tweet_account_edges edge
|
|
817
|
-
where edge.account_id = legacy.account_id
|
|
818
|
-
and edge.tweet_id = legacy.id
|
|
819
|
-
and edge.kind = legacy.kind
|
|
820
|
-
)
|
|
821
|
-
)
|
|
822
|
-
`;
|
|
823
|
-
const unwindowedTimelineEdgesCte = timelineEdgesCte;
|
|
824
|
-
let usedRecentEdgeWindow = false;
|
|
825
|
-
let join = "";
|
|
826
|
-
let where = "where t.kind = ?";
|
|
827
|
-
let searchSnippetSelect = "";
|
|
828
|
-
|
|
829
|
-
const canUseRecentEdgeWindow =
|
|
830
|
-
!likedOnly &&
|
|
831
|
-
!bookmarkedOnly &&
|
|
832
|
-
!account &&
|
|
833
|
-
!search?.trim() &&
|
|
834
|
-
replyFilter === "all" &&
|
|
835
|
-
!since?.trim() &&
|
|
836
|
-
!until?.trim() &&
|
|
837
|
-
includeReplies &&
|
|
838
|
-
qualityFilter === "all";
|
|
839
|
-
|
|
840
|
-
if (likedOnly || bookmarkedOnly) {
|
|
841
|
-
if (likedOnly && bookmarkedOnly) {
|
|
842
|
-
timelineEdgesCte = `
|
|
843
|
-
with timeline_edges as (
|
|
844
|
-
select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json
|
|
845
|
-
from tweet_collections likes
|
|
846
|
-
join tweet_collections bookmarks
|
|
847
|
-
on bookmarks.account_id = likes.account_id
|
|
848
|
-
and bookmarks.tweet_id = likes.tweet_id
|
|
849
|
-
and bookmarks.kind = 'bookmarks'
|
|
850
|
-
where likes.kind = 'likes'
|
|
851
|
-
union all
|
|
852
|
-
select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
|
|
853
|
-
from tweets legacy
|
|
854
|
-
where legacy.liked = 1
|
|
855
|
-
and legacy.bookmarked = 1
|
|
856
|
-
and not exists (
|
|
857
|
-
select 1
|
|
858
|
-
from tweet_collections collection
|
|
859
|
-
where collection.account_id = legacy.account_id
|
|
860
|
-
and collection.tweet_id = legacy.id
|
|
861
|
-
and collection.kind in ('likes', 'bookmarks')
|
|
862
|
-
)
|
|
863
|
-
)
|
|
864
|
-
`;
|
|
865
|
-
} else {
|
|
866
|
-
const collectionKind = likedOnly ? "likes" : "bookmarks";
|
|
867
|
-
const legacyColumn = likedOnly ? "liked" : "bookmarked";
|
|
868
|
-
timelineEdgesCte = `
|
|
869
|
-
with timeline_edges as (
|
|
870
|
-
select account_id, tweet_id, 'home' as kind, raw_json
|
|
871
|
-
from tweet_collections
|
|
872
|
-
where kind = ?
|
|
873
|
-
union all
|
|
874
|
-
select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
|
|
875
|
-
from tweets legacy
|
|
876
|
-
where legacy.${legacyColumn} = 1
|
|
877
|
-
and not exists (
|
|
878
|
-
select 1
|
|
879
|
-
from tweet_collections collection
|
|
880
|
-
where collection.account_id = legacy.account_id
|
|
881
|
-
and collection.tweet_id = legacy.id
|
|
882
|
-
and collection.kind = ?
|
|
883
|
-
)
|
|
884
|
-
)
|
|
885
|
-
`;
|
|
886
|
-
params.push(collectionKind, collectionKind);
|
|
887
|
-
}
|
|
888
|
-
where = "where 1 = 1";
|
|
889
|
-
} else if (canUseRecentEdgeWindow) {
|
|
890
|
-
usedRecentEdgeWindow = true;
|
|
891
|
-
timelineEdgesCte = `
|
|
892
|
-
with timeline_edges as (
|
|
893
|
-
select account_id, tweet_id, kind, raw_json
|
|
894
|
-
from tweet_account_edges
|
|
895
|
-
where kind = ?
|
|
896
|
-
and tweet_id in (
|
|
897
|
-
select id
|
|
898
|
-
from tweets
|
|
899
|
-
order by created_at desc
|
|
900
|
-
limit ?
|
|
901
|
-
)
|
|
902
|
-
union all
|
|
903
|
-
select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
|
|
904
|
-
from tweets legacy
|
|
905
|
-
where legacy.kind = ?
|
|
906
|
-
and legacy.id in (
|
|
907
|
-
select id
|
|
908
|
-
from tweets
|
|
909
|
-
order by created_at desc
|
|
910
|
-
limit ?
|
|
911
|
-
)
|
|
912
|
-
and not exists (
|
|
913
|
-
select 1
|
|
914
|
-
from tweet_account_edges edge
|
|
915
|
-
where edge.account_id = legacy.account_id
|
|
916
|
-
and edge.tweet_id = legacy.id
|
|
917
|
-
and edge.kind = legacy.kind
|
|
918
|
-
)
|
|
919
|
-
)
|
|
920
|
-
`;
|
|
921
|
-
const candidateLimit = Math.max(
|
|
922
|
-
RECENT_TIMELINE_EDGE_CANDIDATES,
|
|
923
|
-
limit * 50,
|
|
924
|
-
);
|
|
925
|
-
params.push(kind, candidateLimit, kind, candidateLimit);
|
|
926
|
-
where = "where e.kind = ?";
|
|
927
|
-
params.push(kind);
|
|
928
|
-
} else {
|
|
929
|
-
params.push(kind, kind);
|
|
930
|
-
where = "where e.kind = ?";
|
|
931
|
-
params.push(kind);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
if (account && account !== "all") {
|
|
935
|
-
where += " and e.account_id = ?";
|
|
936
|
-
params.push(account);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
if (shouldDedupeAcrossAccounts) {
|
|
940
|
-
where += `
|
|
941
|
-
and e.account_id = (
|
|
942
|
-
select e2.account_id
|
|
943
|
-
from timeline_edges e2
|
|
944
|
-
join accounts a2 on a2.id = e2.account_id
|
|
945
|
-
where e2.tweet_id = e.tweet_id
|
|
946
|
-
and e2.kind = e.kind
|
|
947
|
-
order by a2.is_default desc, e2.account_id asc
|
|
948
|
-
limit 1
|
|
949
|
-
)
|
|
950
|
-
`;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
where += buildReplyClause(replyFilter).replaceAll(
|
|
954
|
-
"is_replied",
|
|
955
|
-
"t.is_replied",
|
|
956
|
-
);
|
|
957
|
-
const qualityClause = buildTimelineQualityClause(
|
|
958
|
-
qualityFilter,
|
|
959
|
-
normalizedLowQualityThreshold,
|
|
960
|
-
);
|
|
961
|
-
where += qualityClause.sql;
|
|
962
|
-
params.push(...qualityClause.params);
|
|
963
|
-
|
|
964
|
-
if (!includeReplies) {
|
|
965
|
-
where += " and t.text not like '@%'";
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
if (since?.trim()) {
|
|
969
|
-
where += " and t.created_at >= ?";
|
|
970
|
-
params.push(since.trim());
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
if (until?.trim()) {
|
|
974
|
-
// Deterministic keyset cursor: page on (created_at, id) so rows that share
|
|
975
|
-
// the boundary timestamp are not skipped. Uses the same text comparison as
|
|
976
|
-
// the `order by t.created_at desc, t.id desc` below, which is a total order
|
|
977
|
-
// because t.id is unique.
|
|
978
|
-
if (untilId?.trim()) {
|
|
979
|
-
where += " and (t.created_at < ? or (t.created_at = ? and t.id < ?))";
|
|
980
|
-
params.push(until.trim(), until.trim(), untilId.trim());
|
|
981
|
-
} else {
|
|
982
|
-
where += " and t.created_at < ?";
|
|
983
|
-
params.push(until.trim());
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
988
|
-
if (ftsSearch) {
|
|
989
|
-
join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
|
|
990
|
-
where += " and tweets_fts.text match ?";
|
|
991
|
-
searchSnippetSelect =
|
|
992
|
-
", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
|
|
993
|
-
params.push(ftsSearch);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
params.push(limit);
|
|
997
|
-
|
|
998
|
-
const buildTimelineSelectSql = (timelineEdgesSql: string) => `
|
|
999
|
-
${timelineEdgesSql}
|
|
1000
|
-
select
|
|
1001
|
-
t.id,
|
|
1002
|
-
e.account_id,
|
|
1003
|
-
a.handle as account_handle,
|
|
1004
|
-
e.kind,
|
|
1005
|
-
e.raw_json as edge_raw_json,
|
|
1006
|
-
t.text,
|
|
1007
|
-
t.created_at,
|
|
1008
|
-
t.reply_to_id,
|
|
1009
|
-
t.is_replied,
|
|
1010
|
-
t.like_count,
|
|
1011
|
-
t.media_count,
|
|
1012
|
-
case
|
|
1013
|
-
when exists (
|
|
1014
|
-
select 1 from tweet_collections collection
|
|
1015
|
-
where collection.account_id = e.account_id
|
|
1016
|
-
and collection.tweet_id = t.id
|
|
1017
|
-
and collection.kind = 'bookmarks'
|
|
1018
|
-
) then 1
|
|
1019
|
-
when t.account_id = e.account_id and t.bookmarked = 1 then 1
|
|
1020
|
-
else 0
|
|
1021
|
-
end as bookmarked,
|
|
1022
|
-
case
|
|
1023
|
-
when exists (
|
|
1024
|
-
select 1 from tweet_collections collection
|
|
1025
|
-
where collection.account_id = e.account_id
|
|
1026
|
-
and collection.tweet_id = t.id
|
|
1027
|
-
and collection.kind = 'likes'
|
|
1028
|
-
) then 1
|
|
1029
|
-
when t.account_id = e.account_id and t.liked = 1 then 1
|
|
1030
|
-
else 0
|
|
1031
|
-
end as liked,
|
|
1032
|
-
t.entities_json,
|
|
1033
|
-
t.media_json,
|
|
1034
|
-
t.quoted_tweet_id,
|
|
1035
|
-
p.id as profile_id,
|
|
1036
|
-
p.handle,
|
|
1037
|
-
p.display_name,
|
|
1038
|
-
p.bio,
|
|
1039
|
-
p.followers_count,
|
|
1040
|
-
p.following_count,
|
|
1041
|
-
p.avatar_hue,
|
|
1042
|
-
p.avatar_url,
|
|
1043
|
-
p.location as profile_location,
|
|
1044
|
-
p.url as profile_url,
|
|
1045
|
-
p.verified_type as profile_verified_type,
|
|
1046
|
-
p.entities_json as profile_entities_json,
|
|
1047
|
-
p.created_at as profile_created_at,
|
|
1048
|
-
rt.id as reply_id,
|
|
1049
|
-
rt.text as reply_text,
|
|
1050
|
-
rt.created_at as reply_created_at,
|
|
1051
|
-
rt.reply_to_id as reply_reply_to_id,
|
|
1052
|
-
rt.entities_json as reply_entities_json,
|
|
1053
|
-
rt.media_json as reply_media_json,
|
|
1054
|
-
rp.id as reply_profile_id,
|
|
1055
|
-
rp.handle as reply_handle,
|
|
1056
|
-
rp.display_name as reply_display_name,
|
|
1057
|
-
rp.bio as reply_bio,
|
|
1058
|
-
rp.followers_count as reply_followers_count,
|
|
1059
|
-
rp.following_count as reply_following_count,
|
|
1060
|
-
rp.avatar_hue as reply_avatar_hue,
|
|
1061
|
-
rp.avatar_url as reply_avatar_url,
|
|
1062
|
-
rp.created_at as reply_profile_created_at,
|
|
1063
|
-
qt.id as quoted_id,
|
|
1064
|
-
qt.text as quoted_text,
|
|
1065
|
-
qt.created_at as quoted_created_at,
|
|
1066
|
-
qt.reply_to_id as quoted_reply_to_id,
|
|
1067
|
-
qt.entities_json as quoted_entities_json,
|
|
1068
|
-
qt.media_json as quoted_media_json,
|
|
1069
|
-
qp.id as quoted_profile_id,
|
|
1070
|
-
qp.handle as quoted_handle,
|
|
1071
|
-
qp.display_name as quoted_display_name,
|
|
1072
|
-
qp.bio as quoted_bio,
|
|
1073
|
-
qp.followers_count as quoted_followers_count,
|
|
1074
|
-
qp.following_count as quoted_following_count,
|
|
1075
|
-
qp.avatar_hue as quoted_avatar_hue,
|
|
1076
|
-
qp.avatar_url as quoted_avatar_url,
|
|
1077
|
-
qp.created_at as quoted_profile_created_at
|
|
1078
|
-
${searchSnippetSelect}
|
|
1079
|
-
from timeline_edges e
|
|
1080
|
-
join tweets t on t.id = e.tweet_id
|
|
1081
|
-
join accounts a on a.id = e.account_id
|
|
1082
|
-
join profiles p on p.id = t.author_profile_id
|
|
1083
|
-
left join tweets rt on rt.id = t.reply_to_id
|
|
1084
|
-
left join profiles rp on rp.id = rt.author_profile_id
|
|
1085
|
-
left join tweets qt on qt.id = t.quoted_tweet_id
|
|
1086
|
-
left join profiles qp on qp.id = qt.author_profile_id
|
|
1087
|
-
${join}
|
|
1088
|
-
${where}
|
|
1089
|
-
order by t.created_at desc, t.id desc
|
|
1090
|
-
limit ?
|
|
1091
|
-
`;
|
|
1092
|
-
|
|
1093
|
-
let rows = db
|
|
1094
|
-
.prepare(buildTimelineSelectSql(timelineEdgesCte))
|
|
1095
|
-
.all(...params) as Array<Record<string, unknown>>;
|
|
1096
|
-
|
|
1097
|
-
if (usedRecentEdgeWindow && rows.length < limit) {
|
|
1098
|
-
rows = db
|
|
1099
|
-
.prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
|
|
1100
|
-
.all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1104
|
-
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1105
|
-
return rows.map((row) => {
|
|
1106
|
-
const author = {
|
|
1107
|
-
id: String(row.profile_id),
|
|
1108
|
-
handle: String(row.handle),
|
|
1109
|
-
displayName: String(row.display_name),
|
|
1110
|
-
bio: String(row.bio),
|
|
1111
|
-
followersCount: Number(row.followers_count),
|
|
1112
|
-
followingCount: Number(row.following_count ?? 0),
|
|
1113
|
-
avatarHue: Number(row.avatar_hue),
|
|
1114
|
-
avatarUrl:
|
|
1115
|
-
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
1116
|
-
createdAt: String(row.profile_created_at),
|
|
1117
|
-
};
|
|
1118
|
-
const rowProfiles: Record<string, ProfileRecord> = {
|
|
1119
|
-
[author.id]: author,
|
|
1120
|
-
...(row.reply_profile_id
|
|
1121
|
-
? {
|
|
1122
|
-
[String(row.reply_profile_id)]: toProfile({
|
|
1123
|
-
id: row.reply_profile_id,
|
|
1124
|
-
handle: row.reply_handle,
|
|
1125
|
-
display_name: row.reply_display_name,
|
|
1126
|
-
bio: row.reply_bio,
|
|
1127
|
-
followers_count: row.reply_followers_count,
|
|
1128
|
-
following_count: row.reply_following_count,
|
|
1129
|
-
avatar_hue: row.reply_avatar_hue,
|
|
1130
|
-
avatar_url: row.reply_avatar_url,
|
|
1131
|
-
created_at: row.reply_profile_created_at,
|
|
1132
|
-
}),
|
|
1133
|
-
}
|
|
1134
|
-
: {}),
|
|
1135
|
-
...(row.quoted_profile_id
|
|
1136
|
-
? {
|
|
1137
|
-
[String(row.quoted_profile_id)]: toProfile({
|
|
1138
|
-
id: row.quoted_profile_id,
|
|
1139
|
-
handle: row.quoted_handle,
|
|
1140
|
-
display_name: row.quoted_display_name,
|
|
1141
|
-
bio: row.quoted_bio,
|
|
1142
|
-
followers_count: row.quoted_followers_count,
|
|
1143
|
-
following_count: row.quoted_following_count,
|
|
1144
|
-
avatar_hue: row.quoted_avatar_hue,
|
|
1145
|
-
avatar_url: row.quoted_avatar_url,
|
|
1146
|
-
created_at: row.quoted_profile_created_at,
|
|
1147
|
-
}),
|
|
1148
|
-
}
|
|
1149
|
-
: {}),
|
|
1150
|
-
};
|
|
1151
|
-
const resolveProfileByHandle = (handle: string) =>
|
|
1152
|
-
getProfileByHandle(db, profileByHandleCache, handle, rowProfiles);
|
|
1153
|
-
const text = String(row.text);
|
|
1154
|
-
const entities = enrichTimelineEntities(
|
|
1155
|
-
db,
|
|
1156
|
-
urlExpansionCache,
|
|
1157
|
-
text,
|
|
1158
|
-
parseJsonField<TweetEntities>(row.entities_json, {}),
|
|
1159
|
-
rowProfiles,
|
|
1160
|
-
resolveProfileByHandle,
|
|
1161
|
-
);
|
|
1162
|
-
const item = {
|
|
1163
|
-
id: String(row.id),
|
|
1164
|
-
accountId: String(row.account_id),
|
|
1165
|
-
accountHandle: String(row.account_handle),
|
|
1166
|
-
kind: row.kind as TimelineItem["kind"],
|
|
1167
|
-
text,
|
|
1168
|
-
...(typeof row.search_snippet === "string"
|
|
1169
|
-
? { searchSnippet: row.search_snippet }
|
|
1170
|
-
: {}),
|
|
1171
|
-
createdAt: String(row.created_at),
|
|
1172
|
-
replyToId:
|
|
1173
|
-
typeof row.reply_to_id === "string" ? String(row.reply_to_id) : null,
|
|
1174
|
-
isReplied: Boolean(row.is_replied),
|
|
1175
|
-
likeCount: Number(row.like_count),
|
|
1176
|
-
mediaCount: Number(row.media_count),
|
|
1177
|
-
bookmarked: Boolean(row.bookmarked),
|
|
1178
|
-
liked: Boolean(row.liked),
|
|
1179
|
-
author,
|
|
1180
|
-
entities,
|
|
1181
|
-
media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
|
|
1182
|
-
replyToTweet: buildEmbeddedTweet(
|
|
1183
|
-
db,
|
|
1184
|
-
urlExpansionCache,
|
|
1185
|
-
row,
|
|
1186
|
-
"reply_",
|
|
1187
|
-
resolveProfileByHandle,
|
|
1188
|
-
),
|
|
1189
|
-
quotedTweet: buildEmbeddedTweet(
|
|
1190
|
-
db,
|
|
1191
|
-
urlExpansionCache,
|
|
1192
|
-
row,
|
|
1193
|
-
"quoted_",
|
|
1194
|
-
resolveProfileByHandle,
|
|
1195
|
-
),
|
|
1196
|
-
retweetedTweet: buildRetweetedTweet(
|
|
1197
|
-
db,
|
|
1198
|
-
urlExpansionCache,
|
|
1199
|
-
row,
|
|
1200
|
-
resolveProfileByHandle,
|
|
1201
|
-
),
|
|
1202
|
-
};
|
|
1203
|
-
return includeQualityReason
|
|
1204
|
-
? {
|
|
1205
|
-
...item,
|
|
1206
|
-
qualityReason: getTimelineQualityReason(
|
|
1207
|
-
row,
|
|
1208
|
-
normalizedLowQualityThreshold,
|
|
1209
|
-
),
|
|
1210
|
-
}
|
|
1211
|
-
: item;
|
|
1212
|
-
});
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
function conversationTweetSelect(accountId?: string) {
|
|
1216
|
-
const collectionStateSelect = accountId
|
|
1217
|
-
? `
|
|
1218
|
-
case
|
|
1219
|
-
when exists (
|
|
1220
|
-
select 1 from tweet_collections collection
|
|
1221
|
-
where collection.account_id = ?
|
|
1222
|
-
and collection.tweet_id = t.id
|
|
1223
|
-
and collection.kind = 'bookmarks'
|
|
1224
|
-
) then 1
|
|
1225
|
-
when t.account_id = ? and t.bookmarked = 1 then 1
|
|
1226
|
-
else 0
|
|
1227
|
-
end as bookmarked,
|
|
1228
|
-
case
|
|
1229
|
-
when exists (
|
|
1230
|
-
select 1 from tweet_collections collection
|
|
1231
|
-
where collection.account_id = ?
|
|
1232
|
-
and collection.tweet_id = t.id
|
|
1233
|
-
and collection.kind = 'likes'
|
|
1234
|
-
) then 1
|
|
1235
|
-
when t.account_id = ? and t.liked = 1 then 1
|
|
1236
|
-
else 0
|
|
1237
|
-
end as liked,`
|
|
1238
|
-
: `
|
|
1239
|
-
t.bookmarked,
|
|
1240
|
-
t.liked,`;
|
|
1241
|
-
return `
|
|
1242
|
-
select
|
|
1243
|
-
t.id,
|
|
1244
|
-
t.text,
|
|
1245
|
-
t.created_at,
|
|
1246
|
-
t.reply_to_id,
|
|
1247
|
-
t.is_replied,
|
|
1248
|
-
t.like_count,
|
|
1249
|
-
t.media_count,
|
|
1250
|
-
${collectionStateSelect}
|
|
1251
|
-
t.entities_json,
|
|
1252
|
-
t.media_json,
|
|
1253
|
-
p.id as profile_id,
|
|
1254
|
-
p.handle,
|
|
1255
|
-
p.display_name,
|
|
1256
|
-
p.bio,
|
|
1257
|
-
p.followers_count,
|
|
1258
|
-
p.following_count,
|
|
1259
|
-
p.avatar_hue,
|
|
1260
|
-
p.avatar_url,
|
|
1261
|
-
p.created_at as profile_created_at
|
|
1262
|
-
from tweets t
|
|
1263
|
-
join profiles p on p.id = t.author_profile_id
|
|
1264
|
-
`;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
function getTweetById(
|
|
1268
|
-
db: Database,
|
|
1269
|
-
urlExpansionCache: UrlExpansionCache,
|
|
1270
|
-
tweetId: string,
|
|
1271
|
-
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
1272
|
-
accountId?: string,
|
|
1273
|
-
): EmbeddedTweet | null {
|
|
1274
|
-
const stateParams = accountId
|
|
1275
|
-
? [accountId, accountId, accountId, accountId]
|
|
1276
|
-
: [];
|
|
1277
|
-
const row = db
|
|
1278
|
-
.prepare(`${conversationTweetSelect(accountId)} where t.id = ?`)
|
|
1279
|
-
.get(...stateParams, tweetId) as Record<string, unknown> | undefined;
|
|
1280
|
-
if (!row) return null;
|
|
1281
|
-
return buildEmbeddedTweet(
|
|
1282
|
-
db,
|
|
1283
|
-
urlExpansionCache,
|
|
1284
|
-
row,
|
|
1285
|
-
"",
|
|
1286
|
-
resolveProfileByHandle,
|
|
1287
|
-
);
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
export function getTweetsByIds(
|
|
1291
|
-
tweetIds: string[],
|
|
1292
|
-
accountId?: string,
|
|
1293
|
-
): EmbeddedTweet[] {
|
|
1294
|
-
const db = getNativeDb();
|
|
1295
|
-
const scopedAccountId =
|
|
1296
|
-
accountId && accountId !== "all" ? accountId : undefined;
|
|
1297
|
-
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1298
|
-
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1299
|
-
const resolveProfileByHandle = (handle: string) =>
|
|
1300
|
-
getProfileByHandle(db, profileByHandleCache, handle);
|
|
1301
|
-
const seen = new Set<string>();
|
|
1302
|
-
const tweets: EmbeddedTweet[] = [];
|
|
1303
|
-
|
|
1304
|
-
for (const tweetId of tweetIds) {
|
|
1305
|
-
const normalized = tweetId.trim().replace(/^tweet_/, "");
|
|
1306
|
-
if (!normalized || seen.has(normalized)) continue;
|
|
1307
|
-
seen.add(normalized);
|
|
1308
|
-
if (
|
|
1309
|
-
scopedAccountId &&
|
|
1310
|
-
!db
|
|
1311
|
-
.prepare(
|
|
1312
|
-
`
|
|
1313
|
-
select 1
|
|
1314
|
-
from tweets tweet
|
|
1315
|
-
where tweet.id = ?
|
|
1316
|
-
and (
|
|
1317
|
-
tweet.account_id = ?
|
|
1318
|
-
or exists (
|
|
1319
|
-
select 1
|
|
1320
|
-
from tweet_account_edges edge
|
|
1321
|
-
where edge.account_id = ?
|
|
1322
|
-
and edge.tweet_id = tweet.id
|
|
1323
|
-
)
|
|
1324
|
-
or exists (
|
|
1325
|
-
select 1
|
|
1326
|
-
from tweet_collections collection
|
|
1327
|
-
where collection.account_id = ?
|
|
1328
|
-
and collection.tweet_id = tweet.id
|
|
1329
|
-
)
|
|
1330
|
-
)
|
|
1331
|
-
limit 1
|
|
1332
|
-
`,
|
|
1333
|
-
)
|
|
1334
|
-
.get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
|
|
1335
|
-
) {
|
|
1336
|
-
continue;
|
|
1337
|
-
}
|
|
1338
|
-
const tweet = getTweetById(
|
|
1339
|
-
db,
|
|
1340
|
-
urlExpansionCache,
|
|
1341
|
-
normalized,
|
|
1342
|
-
resolveProfileByHandle,
|
|
1343
|
-
scopedAccountId,
|
|
1344
|
-
);
|
|
1345
|
-
if (tweet) tweets.push(tweet);
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
return tweets;
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
function listTweetDescendants(
|
|
1352
|
-
db: Database,
|
|
1353
|
-
urlExpansionCache: UrlExpansionCache,
|
|
1354
|
-
rootId: string,
|
|
1355
|
-
limit: number,
|
|
1356
|
-
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
1357
|
-
) {
|
|
1358
|
-
if (limit <= 0) return [];
|
|
1359
|
-
const rows = db
|
|
1360
|
-
.prepare(
|
|
1361
|
-
`
|
|
1362
|
-
with recursive branch(id, depth) as (
|
|
1363
|
-
select t.id, 0
|
|
1364
|
-
from tweets t
|
|
1365
|
-
where t.id = ?
|
|
1366
|
-
union all
|
|
1367
|
-
select child.id, branch.depth + 1
|
|
1368
|
-
from tweets child
|
|
1369
|
-
join branch on child.reply_to_id = branch.id
|
|
1370
|
-
where branch.depth < 8
|
|
1371
|
-
)
|
|
1372
|
-
${conversationTweetSelect()}
|
|
1373
|
-
join branch on branch.id = t.id
|
|
1374
|
-
where t.id != ?
|
|
1375
|
-
order by t.created_at asc
|
|
1376
|
-
limit ?
|
|
1377
|
-
`,
|
|
1378
|
-
)
|
|
1379
|
-
.all(rootId, rootId, limit) as Array<Record<string, unknown>>;
|
|
1380
|
-
|
|
1381
|
-
return rows
|
|
1382
|
-
.map((row) =>
|
|
1383
|
-
buildEmbeddedTweet(
|
|
1384
|
-
db,
|
|
1385
|
-
urlExpansionCache,
|
|
1386
|
-
row,
|
|
1387
|
-
"",
|
|
1388
|
-
resolveProfileByHandle,
|
|
1389
|
-
),
|
|
1390
|
-
)
|
|
1391
|
-
.filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
function appendConversationTweets(
|
|
1395
|
-
target: EmbeddedTweet[],
|
|
1396
|
-
seen: Set<string>,
|
|
1397
|
-
items: EmbeddedTweet[],
|
|
1398
|
-
remaining: number,
|
|
1399
|
-
) {
|
|
1400
|
-
for (const tweet of items) {
|
|
1401
|
-
if (target.length >= remaining || seen.has(tweet.id)) continue;
|
|
1402
|
-
seen.add(tweet.id);
|
|
1403
|
-
target.push(tweet);
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
export function getTweetConversation(
|
|
1408
|
-
tweetId: string,
|
|
1409
|
-
limit = 80,
|
|
1410
|
-
): TweetConversationResponse | null {
|
|
1411
|
-
const db = getNativeDb();
|
|
1412
|
-
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1413
|
-
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1414
|
-
const resolveProfileByHandle = (handle: string) =>
|
|
1415
|
-
getProfileByHandle(db, profileByHandleCache, handle);
|
|
1416
|
-
const anchor = getTweetById(
|
|
1417
|
-
db,
|
|
1418
|
-
urlExpansionCache,
|
|
1419
|
-
tweetId,
|
|
1420
|
-
resolveProfileByHandle,
|
|
1421
|
-
);
|
|
1422
|
-
if (!anchor) return null;
|
|
1423
|
-
|
|
1424
|
-
const ancestors: EmbeddedTweet[] = [];
|
|
1425
|
-
let current = anchor;
|
|
1426
|
-
for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
|
|
1427
|
-
const parent = getTweetById(
|
|
1428
|
-
db,
|
|
1429
|
-
urlExpansionCache,
|
|
1430
|
-
current.replyToId,
|
|
1431
|
-
resolveProfileByHandle,
|
|
1432
|
-
);
|
|
1433
|
-
if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
|
|
1434
|
-
ancestors.push(parent);
|
|
1435
|
-
current = parent;
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
const required = [...ancestors].reverse();
|
|
1439
|
-
required.push(anchor);
|
|
1440
|
-
const root = required[0] ?? anchor;
|
|
1441
|
-
const seen = new Set<string>();
|
|
1442
|
-
const items = required.filter((tweet) => {
|
|
1443
|
-
if (seen.has(tweet.id)) return false;
|
|
1444
|
-
seen.add(tweet.id);
|
|
1445
|
-
return true;
|
|
1446
|
-
});
|
|
1447
|
-
const remainingAfterRequired = Math.max(0, limit - items.length);
|
|
1448
|
-
const focusedDescendants = listTweetDescendants(
|
|
1449
|
-
db,
|
|
1450
|
-
urlExpansionCache,
|
|
1451
|
-
anchor.id,
|
|
1452
|
-
remainingAfterRequired,
|
|
1453
|
-
resolveProfileByHandle,
|
|
1454
|
-
);
|
|
1455
|
-
appendConversationTweets(items, seen, focusedDescendants, limit);
|
|
1456
|
-
|
|
1457
|
-
if (items.length < limit && root.id !== anchor.id) {
|
|
1458
|
-
const ambientDescendants = listTweetDescendants(
|
|
1459
|
-
db,
|
|
1460
|
-
urlExpansionCache,
|
|
1461
|
-
root.id,
|
|
1462
|
-
limit,
|
|
1463
|
-
resolveProfileByHandle,
|
|
1464
|
-
);
|
|
1465
|
-
appendConversationTweets(items, seen, ambientDescendants, limit);
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
1469
|
-
|
|
1470
|
-
return {
|
|
1471
|
-
anchorId: anchor.id,
|
|
1472
|
-
items,
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
export function listDmConversations({
|
|
1477
|
-
account,
|
|
1478
|
-
conversationIds,
|
|
1479
|
-
inbox = "all",
|
|
1480
|
-
participant,
|
|
1481
|
-
search,
|
|
1482
|
-
replyFilter = "all",
|
|
1483
|
-
since,
|
|
1484
|
-
until,
|
|
1485
|
-
minFollowers,
|
|
1486
|
-
maxFollowers,
|
|
1487
|
-
minInfluenceScore,
|
|
1488
|
-
maxInfluenceScore,
|
|
1489
|
-
sort = "recent",
|
|
1490
|
-
context = 0,
|
|
1491
|
-
limit = 20,
|
|
1492
|
-
}: DmQuery): DmConversationItem[] {
|
|
1493
|
-
const db = getNativeDb();
|
|
1494
|
-
const params: Array<string | number> = [];
|
|
1495
|
-
const joinParams: Array<string | number> = [];
|
|
1496
|
-
let searchSnippetCte = "";
|
|
1497
|
-
let join = "";
|
|
1498
|
-
let where = "where 1 = 1";
|
|
1499
|
-
let searchSnippetSelect = "";
|
|
1500
|
-
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
1501
|
-
const influenceMinFollowers =
|
|
1502
|
-
typeof minInfluenceScore === "number"
|
|
1503
|
-
? getMinFollowersForInfluenceScore(minInfluenceScore)
|
|
1504
|
-
: undefined;
|
|
1505
|
-
const influenceMaxFollowers =
|
|
1506
|
-
typeof maxInfluenceScore === "number"
|
|
1507
|
-
? getMaxFollowersForInfluenceScore(maxInfluenceScore)
|
|
1508
|
-
: undefined;
|
|
1509
|
-
const effectiveMinFollowers =
|
|
1510
|
-
typeof minFollowers === "number" ||
|
|
1511
|
-
typeof influenceMinFollowers === "number"
|
|
1512
|
-
? Math.max(minFollowers ?? 0, influenceMinFollowers ?? 0)
|
|
1513
|
-
: undefined;
|
|
1514
|
-
const effectiveMaxFollowers =
|
|
1515
|
-
typeof maxFollowers === "number" ||
|
|
1516
|
-
typeof influenceMaxFollowers === "number"
|
|
1517
|
-
? Math.min(
|
|
1518
|
-
maxFollowers ?? Number.MAX_SAFE_INTEGER,
|
|
1519
|
-
influenceMaxFollowers ?? Number.MAX_SAFE_INTEGER,
|
|
1520
|
-
)
|
|
1521
|
-
: undefined;
|
|
1522
|
-
const orderBy =
|
|
1523
|
-
sort === "followers" || sort === "influence"
|
|
1524
|
-
? "p.followers_count desc, c.last_message_at desc"
|
|
1525
|
-
: "c.last_message_at desc";
|
|
1526
|
-
|
|
1527
|
-
if (account && account !== "all") {
|
|
1528
|
-
where += " and a.id = ?";
|
|
1529
|
-
params.push(account);
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
if (conversationIds && conversationIds.length > 0) {
|
|
1533
|
-
where += ` and c.id in (${conversationIds.map(() => "?").join(",")})`;
|
|
1534
|
-
params.push(...conversationIds);
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
if (inbox === "accepted") {
|
|
1538
|
-
where += " and c.inbox_kind = 'accepted'";
|
|
1539
|
-
} else if (inbox === "requests") {
|
|
1540
|
-
where += " and c.inbox_kind = 'request'";
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
if (participant?.trim()) {
|
|
1544
|
-
where += " and (p.handle like ? or p.display_name like ?)";
|
|
1545
|
-
params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
if (replyFilter === "replied") {
|
|
1549
|
-
where += " and c.needs_reply = 0";
|
|
1550
|
-
} else if (replyFilter === "unreplied") {
|
|
1551
|
-
where += " and c.needs_reply = 1";
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
if (since?.trim()) {
|
|
1555
|
-
where += " and c.last_message_at >= ?";
|
|
1556
|
-
params.push(since);
|
|
1557
|
-
}
|
|
1558
|
-
if (until?.trim()) {
|
|
1559
|
-
where += " and c.last_message_at < ?";
|
|
1560
|
-
params.push(until);
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
if (typeof effectiveMinFollowers === "number") {
|
|
1564
|
-
where += " and p.followers_count >= ?";
|
|
1565
|
-
params.push(effectiveMinFollowers);
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
if (typeof effectiveMaxFollowers === "number") {
|
|
1569
|
-
where += " and p.followers_count <= ?";
|
|
1570
|
-
params.push(effectiveMaxFollowers);
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
if (ftsSearch) {
|
|
1574
|
-
searchSnippetCte = `
|
|
1575
|
-
with ranked_dm_search as materialized (
|
|
1576
|
-
select
|
|
1577
|
-
latest_search.id,
|
|
1578
|
-
latest_search.conversation_id,
|
|
1579
|
-
row_number() over (
|
|
1580
|
-
partition by latest_search.conversation_id
|
|
1581
|
-
order by latest_search.created_at desc, latest_search.id desc
|
|
1582
|
-
) as match_rank
|
|
1583
|
-
from dm_messages latest_search
|
|
1584
|
-
join dm_fts on dm_fts.message_id = latest_search.id
|
|
1585
|
-
where dm_fts.text match ?
|
|
1586
|
-
),
|
|
1587
|
-
dm_search as materialized (
|
|
1588
|
-
select
|
|
1589
|
-
ranked_dm_search.conversation_id,
|
|
1590
|
-
snippet(dm_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet
|
|
1591
|
-
from ranked_dm_search
|
|
1592
|
-
join dm_fts on dm_fts.message_id = ranked_dm_search.id
|
|
1593
|
-
where ranked_dm_search.match_rank = 1
|
|
1594
|
-
and dm_fts.text match ?
|
|
1595
|
-
)
|
|
1596
|
-
`;
|
|
1597
|
-
join += " join dm_search on dm_search.conversation_id = c.id ";
|
|
1598
|
-
searchSnippetSelect = ", dm_search.search_snippet as search_snippet";
|
|
1599
|
-
joinParams.push(ftsSearch, ftsSearch);
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
params.push(limit);
|
|
1603
|
-
|
|
1604
|
-
const rows = db
|
|
1605
|
-
.prepare(
|
|
1606
|
-
`
|
|
1607
|
-
${searchSnippetCte}
|
|
1608
|
-
select
|
|
1609
|
-
c.id,
|
|
1610
|
-
c.account_id,
|
|
1611
|
-
a.handle as account_handle,
|
|
1612
|
-
c.title,
|
|
1613
|
-
c.inbox_kind,
|
|
1614
|
-
c.last_message_at,
|
|
1615
|
-
c.unread_count,
|
|
1616
|
-
c.needs_reply,
|
|
1617
|
-
p.id as profile_id,
|
|
1618
|
-
p.handle,
|
|
1619
|
-
p.display_name,
|
|
1620
|
-
p.bio,
|
|
1621
|
-
p.followers_count,
|
|
1622
|
-
p.following_count,
|
|
1623
|
-
p.avatar_hue,
|
|
1624
|
-
p.avatar_url,
|
|
1625
|
-
p.location as profile_location,
|
|
1626
|
-
p.url as profile_url,
|
|
1627
|
-
p.verified_type as profile_verified_type,
|
|
1628
|
-
p.entities_json as profile_entities_json,
|
|
1629
|
-
p.created_at as profile_created_at,
|
|
1630
|
-
(
|
|
1631
|
-
select text
|
|
1632
|
-
from dm_messages latest_message
|
|
1633
|
-
where latest_message.conversation_id = c.id
|
|
1634
|
-
order by latest_message.created_at desc
|
|
1635
|
-
limit 1
|
|
1636
|
-
) as last_message_preview
|
|
1637
|
-
${searchSnippetSelect}
|
|
1638
|
-
from dm_conversations c
|
|
1639
|
-
join accounts a on a.id = c.account_id
|
|
1640
|
-
join profiles p on p.id = c.participant_profile_id
|
|
1641
|
-
${join}
|
|
1642
|
-
${where}
|
|
1643
|
-
group by c.id
|
|
1644
|
-
order by ${orderBy}
|
|
1645
|
-
limit ?
|
|
1646
|
-
`,
|
|
1647
|
-
)
|
|
1648
|
-
.all(...joinParams, ...params) as Array<Record<string, unknown>>;
|
|
1649
|
-
|
|
1650
|
-
const affiliationsByProfile = fetchProfileAffiliations(
|
|
1651
|
-
db,
|
|
1652
|
-
rows.map((row) => String(row.profile_id)),
|
|
1653
|
-
);
|
|
1654
|
-
const items: DmConversationItem[] = rows.map((row) => {
|
|
1655
|
-
const followersCount = Number(row.followers_count);
|
|
1656
|
-
const influenceScore = getInfluenceScore(followersCount);
|
|
1657
|
-
const participant = toProfile({
|
|
1658
|
-
id: row.profile_id,
|
|
1659
|
-
handle: row.handle,
|
|
1660
|
-
display_name: row.display_name,
|
|
1661
|
-
bio: row.bio,
|
|
1662
|
-
followers_count: row.followers_count,
|
|
1663
|
-
following_count: row.following_count,
|
|
1664
|
-
avatar_hue: row.avatar_hue,
|
|
1665
|
-
avatar_url: row.avatar_url,
|
|
1666
|
-
location: row.profile_location,
|
|
1667
|
-
url: row.profile_url,
|
|
1668
|
-
verified_type: row.profile_verified_type,
|
|
1669
|
-
entities_json: row.profile_entities_json,
|
|
1670
|
-
created_at: row.profile_created_at,
|
|
1671
|
-
});
|
|
1672
|
-
const affiliations = affiliationsByProfile.get(participant.id) ?? [];
|
|
1673
|
-
return {
|
|
1674
|
-
id: String(row.id),
|
|
1675
|
-
accountId: String(row.account_id),
|
|
1676
|
-
accountHandle: String(row.account_handle),
|
|
1677
|
-
title: String(row.title),
|
|
1678
|
-
...(typeof row.search_snippet === "string"
|
|
1679
|
-
? { searchSnippet: row.search_snippet }
|
|
1680
|
-
: {}),
|
|
1681
|
-
inboxKind: row.inbox_kind === "request" ? "request" : "accepted",
|
|
1682
|
-
isMessageRequest: row.inbox_kind === "request",
|
|
1683
|
-
lastMessageAt: String(row.last_message_at),
|
|
1684
|
-
lastMessagePreview: String(row.last_message_preview ?? ""),
|
|
1685
|
-
unreadCount: Number(row.unread_count),
|
|
1686
|
-
needsReply: Boolean(row.needs_reply),
|
|
1687
|
-
influenceScore,
|
|
1688
|
-
influenceLabel: getInfluenceLabel(influenceScore),
|
|
1689
|
-
participant: {
|
|
1690
|
-
...participant,
|
|
1691
|
-
...(affiliations.length > 0
|
|
1692
|
-
? {
|
|
1693
|
-
affiliations,
|
|
1694
|
-
primaryAffiliation: affiliations[0],
|
|
1695
|
-
}
|
|
1696
|
-
: {}),
|
|
1697
|
-
},
|
|
1698
|
-
};
|
|
1699
|
-
});
|
|
1700
|
-
|
|
1701
|
-
const filtered = items.filter((item) => {
|
|
1702
|
-
if (
|
|
1703
|
-
typeof minInfluenceScore === "number" &&
|
|
1704
|
-
item.influenceScore < minInfluenceScore
|
|
1705
|
-
) {
|
|
1706
|
-
return false;
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
if (
|
|
1710
|
-
typeof maxInfluenceScore === "number" &&
|
|
1711
|
-
item.influenceScore > maxInfluenceScore
|
|
1712
|
-
) {
|
|
1713
|
-
return false;
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
return true;
|
|
1717
|
-
});
|
|
1718
|
-
|
|
1719
|
-
if (sort === "followers" || sort === "influence") {
|
|
1720
|
-
filtered.sort((left, right) => {
|
|
1721
|
-
if (
|
|
1722
|
-
right.participant.followersCount !== left.participant.followersCount
|
|
1723
|
-
) {
|
|
1724
|
-
return (
|
|
1725
|
-
right.participant.followersCount - left.participant.followersCount
|
|
1726
|
-
);
|
|
1727
|
-
}
|
|
1728
|
-
return (
|
|
1729
|
-
new Date(right.lastMessageAt).getTime() -
|
|
1730
|
-
new Date(left.lastMessageAt).getTime()
|
|
1731
|
-
);
|
|
1732
|
-
});
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
const limited = filtered.slice(0, limit);
|
|
1736
|
-
const normalizedContext = normalizeDmContext(context);
|
|
1737
|
-
if (ftsSearch && normalizedContext > 0 && limited.length > 0) {
|
|
1738
|
-
const matches = getDmSearchMatches({
|
|
1739
|
-
search: ftsSearch,
|
|
1740
|
-
conversationIds: limited.map((item) => item.id),
|
|
1741
|
-
context: normalizedContext,
|
|
1742
|
-
});
|
|
1743
|
-
for (const item of limited) {
|
|
1744
|
-
const itemMatches = matches.get(item.id);
|
|
1745
|
-
if (itemMatches && itemMatches.length > 0) {
|
|
1746
|
-
item.matches = itemMatches;
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
return limited;
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
export function getConversationThread(
|
|
1755
|
-
conversationId: string,
|
|
1756
|
-
filters: Pick<DmQuery, "account"> = {},
|
|
1757
|
-
): { conversation: DmConversationItem; messages: DmMessageItem[] } | null {
|
|
1758
|
-
const conversation = listDmConversations({
|
|
1759
|
-
...filters,
|
|
1760
|
-
conversationIds: [conversationId],
|
|
1761
|
-
limit: 1,
|
|
1762
|
-
}).find((item) => item.id === conversationId);
|
|
1763
|
-
|
|
1764
|
-
if (!conversation) {
|
|
1765
|
-
return null;
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
const db = getNativeDb();
|
|
1769
|
-
const rows = db
|
|
1770
|
-
.prepare(
|
|
1771
|
-
`
|
|
1772
|
-
select
|
|
1773
|
-
m.id,
|
|
1774
|
-
m.conversation_id,
|
|
1775
|
-
m.text,
|
|
1776
|
-
m.created_at,
|
|
1777
|
-
m.direction,
|
|
1778
|
-
m.is_replied,
|
|
1779
|
-
m.media_count,
|
|
1780
|
-
p.id as profile_id,
|
|
1781
|
-
p.handle,
|
|
1782
|
-
p.display_name,
|
|
1783
|
-
p.bio,
|
|
1784
|
-
p.followers_count,
|
|
1785
|
-
p.following_count,
|
|
1786
|
-
p.avatar_hue,
|
|
1787
|
-
p.avatar_url,
|
|
1788
|
-
p.created_at as profile_created_at
|
|
1789
|
-
from dm_messages m
|
|
1790
|
-
join profiles p on p.id = m.sender_profile_id
|
|
1791
|
-
where m.conversation_id = ?
|
|
1792
|
-
order by m.created_at asc
|
|
1793
|
-
`,
|
|
1794
|
-
)
|
|
1795
|
-
.all(conversationId) as Array<Record<string, unknown>>;
|
|
1796
|
-
|
|
1797
|
-
return {
|
|
1798
|
-
conversation,
|
|
1799
|
-
messages: rows.map((row) => ({
|
|
1800
|
-
id: String(row.id),
|
|
1801
|
-
conversationId: String(row.conversation_id),
|
|
1802
|
-
text: String(row.text),
|
|
1803
|
-
createdAt: String(row.created_at),
|
|
1804
|
-
direction: row.direction as DmMessageItem["direction"],
|
|
1805
|
-
isReplied: Boolean(row.is_replied),
|
|
1806
|
-
mediaCount: Number(row.media_count),
|
|
1807
|
-
sender: toProfile({
|
|
1808
|
-
id: row.profile_id,
|
|
1809
|
-
handle: row.handle,
|
|
1810
|
-
display_name: row.display_name,
|
|
1811
|
-
bio: row.bio,
|
|
1812
|
-
followers_count: row.followers_count,
|
|
1813
|
-
following_count: row.following_count,
|
|
1814
|
-
avatar_hue: row.avatar_hue,
|
|
1815
|
-
avatar_url: row.avatar_url,
|
|
1816
|
-
created_at: row.profile_created_at,
|
|
1817
|
-
}),
|
|
1818
|
-
})),
|
|
1819
|
-
};
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
export type DmRequestMutationAction = "accept" | "reject" | "block";
|
|
1823
|
-
|
|
1824
|
-
export function applyDmRequestMutationToLocalStore(
|
|
1825
|
-
conversationId: string,
|
|
1826
|
-
action: DmRequestMutationAction,
|
|
1827
|
-
) {
|
|
1828
|
-
return persistWrite((db) => {
|
|
1829
|
-
db.prepare(
|
|
1830
|
-
"delete from sync_cache where cache_key like 'dms:bird:%'",
|
|
1831
|
-
).run();
|
|
1832
|
-
if (action === "accept") {
|
|
1833
|
-
return db
|
|
1834
|
-
.prepare(
|
|
1835
|
-
`
|
|
1836
|
-
update dm_conversations
|
|
1837
|
-
set inbox_kind = 'accepted'
|
|
1838
|
-
where id = ?
|
|
1839
|
-
`,
|
|
1840
|
-
)
|
|
1841
|
-
.run(conversationId).changes;
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
db.prepare(
|
|
1845
|
-
`
|
|
1846
|
-
delete from link_occurrences
|
|
1847
|
-
where source_kind = 'dm'
|
|
1848
|
-
and source_id in (
|
|
1849
|
-
select id from dm_messages where conversation_id = ?
|
|
1850
|
-
)
|
|
1851
|
-
`,
|
|
1852
|
-
).run(conversationId);
|
|
1853
|
-
db.prepare(
|
|
1854
|
-
`
|
|
1855
|
-
delete from dm_fts
|
|
1856
|
-
where message_id in (
|
|
1857
|
-
select id from dm_messages where conversation_id = ?
|
|
1858
|
-
)
|
|
1859
|
-
`,
|
|
1860
|
-
).run(conversationId);
|
|
1861
|
-
db.prepare("delete from dm_messages where conversation_id = ?").run(
|
|
1862
|
-
conversationId,
|
|
1863
|
-
);
|
|
1864
|
-
return db
|
|
1865
|
-
.prepare("delete from dm_conversations where id = ?")
|
|
1866
|
-
.run(conversationId).changes;
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
function normalizeDmContext(value: number | undefined) {
|
|
1871
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1872
|
-
return 0;
|
|
1873
|
-
}
|
|
1874
|
-
return Math.max(0, Math.min(20, Math.trunc(value)));
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
function mapDmMessageRow(row: Record<string, unknown>): DmMessageItem {
|
|
1878
|
-
return {
|
|
1879
|
-
id: String(row.id),
|
|
1880
|
-
conversationId: String(row.conversation_id),
|
|
1881
|
-
text: String(row.text),
|
|
1882
|
-
createdAt: String(row.created_at),
|
|
1883
|
-
direction: row.direction as DmMessageItem["direction"],
|
|
1884
|
-
isReplied: Boolean(row.is_replied),
|
|
1885
|
-
mediaCount: Number(row.media_count),
|
|
1886
|
-
sender: toProfile({
|
|
1887
|
-
id: row.profile_id,
|
|
1888
|
-
handle: row.handle,
|
|
1889
|
-
display_name: row.display_name,
|
|
1890
|
-
bio: row.bio,
|
|
1891
|
-
followers_count: row.followers_count,
|
|
1892
|
-
following_count: row.following_count,
|
|
1893
|
-
avatar_hue: row.avatar_hue,
|
|
1894
|
-
avatar_url: row.avatar_url,
|
|
1895
|
-
created_at: row.profile_created_at,
|
|
1896
|
-
}),
|
|
1897
|
-
};
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
function selectDmMessageSql(where: string, orderBy: string) {
|
|
1901
|
-
return `
|
|
1902
|
-
select
|
|
1903
|
-
m.id,
|
|
1904
|
-
m.conversation_id,
|
|
1905
|
-
m.text,
|
|
1906
|
-
m.created_at,
|
|
1907
|
-
m.direction,
|
|
1908
|
-
m.is_replied,
|
|
1909
|
-
m.media_count,
|
|
1910
|
-
p.id as profile_id,
|
|
1911
|
-
p.handle,
|
|
1912
|
-
p.display_name,
|
|
1913
|
-
p.bio,
|
|
1914
|
-
p.followers_count,
|
|
1915
|
-
p.following_count,
|
|
1916
|
-
p.avatar_hue,
|
|
1917
|
-
p.avatar_url,
|
|
1918
|
-
p.created_at as profile_created_at
|
|
1919
|
-
from dm_messages m
|
|
1920
|
-
join profiles p on p.id = m.sender_profile_id
|
|
1921
|
-
${where}
|
|
1922
|
-
${orderBy}
|
|
1923
|
-
`;
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
function getDmSearchMatches({
|
|
1927
|
-
search,
|
|
1928
|
-
conversationIds,
|
|
1929
|
-
context,
|
|
1930
|
-
}: {
|
|
1931
|
-
search: string;
|
|
1932
|
-
conversationIds: string[];
|
|
1933
|
-
context: number;
|
|
1934
|
-
}) {
|
|
1935
|
-
const db = getNativeDb();
|
|
1936
|
-
if (search.length === 0) {
|
|
1937
|
-
return new Map<string, DmConversationItem["matches"]>();
|
|
1938
|
-
}
|
|
1939
|
-
const conversationPlaceholders = conversationIds.map(() => "?").join(", ");
|
|
1940
|
-
const matchRows = db
|
|
1941
|
-
.prepare(
|
|
1942
|
-
`
|
|
1943
|
-
with ranked_matches as (
|
|
1944
|
-
select
|
|
1945
|
-
m.id,
|
|
1946
|
-
m.conversation_id,
|
|
1947
|
-
m.text,
|
|
1948
|
-
m.created_at,
|
|
1949
|
-
m.direction,
|
|
1950
|
-
m.is_replied,
|
|
1951
|
-
m.media_count,
|
|
1952
|
-
p.id as profile_id,
|
|
1953
|
-
p.handle,
|
|
1954
|
-
p.display_name,
|
|
1955
|
-
p.bio,
|
|
1956
|
-
p.followers_count,
|
|
1957
|
-
p.following_count,
|
|
1958
|
-
p.avatar_hue,
|
|
1959
|
-
p.avatar_url,
|
|
1960
|
-
p.created_at as profile_created_at,
|
|
1961
|
-
row_number() over (
|
|
1962
|
-
partition by m.conversation_id
|
|
1963
|
-
order by m.created_at desc, m.id desc
|
|
1964
|
-
) as match_rank
|
|
1965
|
-
from dm_messages m
|
|
1966
|
-
join dm_fts on dm_fts.message_id = m.id
|
|
1967
|
-
join profiles p on p.id = m.sender_profile_id
|
|
1968
|
-
where dm_fts.text match ?
|
|
1969
|
-
and m.conversation_id in (${conversationPlaceholders})
|
|
1970
|
-
)
|
|
1971
|
-
select *
|
|
1972
|
-
from ranked_matches
|
|
1973
|
-
where match_rank <= 3
|
|
1974
|
-
order by created_at desc, id desc
|
|
1975
|
-
`,
|
|
1976
|
-
)
|
|
1977
|
-
.all(search, ...conversationIds) as Array<Record<string, unknown>>;
|
|
1978
|
-
|
|
1979
|
-
const beforeStatement = db.prepare(
|
|
1980
|
-
selectDmMessageSql(
|
|
1981
|
-
`
|
|
1982
|
-
where m.conversation_id = ?
|
|
1983
|
-
and (m.created_at < ? or (m.created_at = ? and m.id < ?))
|
|
1984
|
-
`,
|
|
1985
|
-
"order by m.created_at desc, m.id desc limit ?",
|
|
1986
|
-
),
|
|
1987
|
-
);
|
|
1988
|
-
const afterStatement = db.prepare(
|
|
1989
|
-
selectDmMessageSql(
|
|
1990
|
-
`
|
|
1991
|
-
where m.conversation_id = ?
|
|
1992
|
-
and (m.created_at > ? or (m.created_at = ? and m.id > ?))
|
|
1993
|
-
`,
|
|
1994
|
-
"order by m.created_at asc, m.id asc limit ?",
|
|
1995
|
-
),
|
|
1996
|
-
);
|
|
1997
|
-
const grouped = new Map<string, DmConversationItem["matches"]>();
|
|
1998
|
-
|
|
1999
|
-
for (const row of matchRows) {
|
|
2000
|
-
const message = mapDmMessageRow(row);
|
|
2001
|
-
const before = (
|
|
2002
|
-
beforeStatement.all(
|
|
2003
|
-
message.conversationId,
|
|
2004
|
-
message.createdAt,
|
|
2005
|
-
message.createdAt,
|
|
2006
|
-
message.id,
|
|
2007
|
-
context,
|
|
2008
|
-
) as Array<Record<string, unknown>>
|
|
2009
|
-
)
|
|
2010
|
-
.map(mapDmMessageRow)
|
|
2011
|
-
.reverse();
|
|
2012
|
-
const after = (
|
|
2013
|
-
afterStatement.all(
|
|
2014
|
-
message.conversationId,
|
|
2015
|
-
message.createdAt,
|
|
2016
|
-
message.createdAt,
|
|
2017
|
-
message.id,
|
|
2018
|
-
context,
|
|
2019
|
-
) as Array<Record<string, unknown>>
|
|
2020
|
-
).map(mapDmMessageRow);
|
|
2021
|
-
const matches = grouped.get(message.conversationId) ?? [];
|
|
2022
|
-
matches.push({ message, before, after });
|
|
2023
|
-
grouped.set(message.conversationId, matches);
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
return grouped;
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
export function queryResource(
|
|
2030
|
-
resource: "home" | "mentions" | "authored" | "search" | "dms",
|
|
2031
|
-
filters: (TimelineQuery | DmQuery) & { conversationId?: string },
|
|
2032
|
-
): QueryResponse {
|
|
2033
|
-
if (resource === "dms") {
|
|
2034
|
-
const dmFilters = filters as DmQuery & { conversationId?: string };
|
|
2035
|
-
const items = listDmConversations(dmFilters);
|
|
2036
|
-
const requestedConversationId = dmFilters.conversationId;
|
|
2037
|
-
const selectedConversationId =
|
|
2038
|
-
requestedConversationId &&
|
|
2039
|
-
items.some((item) => item.id === requestedConversationId)
|
|
2040
|
-
? requestedConversationId
|
|
2041
|
-
: items[0]?.id;
|
|
2042
|
-
return {
|
|
2043
|
-
resource,
|
|
2044
|
-
items,
|
|
2045
|
-
selectedConversation: selectedConversationId
|
|
2046
|
-
? getConversationThread(selectedConversationId, {
|
|
2047
|
-
account: dmFilters.account,
|
|
2048
|
-
})
|
|
2049
|
-
: null,
|
|
2050
|
-
};
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
const { resource: _filterResource, ...timelineFilters } =
|
|
2054
|
-
filters as TimelineQuery;
|
|
2055
|
-
|
|
2056
|
-
return {
|
|
2057
|
-
resource,
|
|
2058
|
-
items: listTimelineItems({
|
|
2059
|
-
resource,
|
|
2060
|
-
...timelineFilters,
|
|
2061
|
-
}),
|
|
2062
|
-
};
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
function refreshDmConversationState(
|
|
2066
|
-
db: Database,
|
|
2067
|
-
conversationId: string,
|
|
2068
|
-
lastMessageAt: string,
|
|
2069
|
-
observedLastMessageAt = lastMessageAt,
|
|
2070
|
-
) {
|
|
2071
|
-
db.prepare(
|
|
2072
|
-
`
|
|
2073
|
-
update dm_conversations
|
|
2074
|
-
set last_message_at = case
|
|
2075
|
-
when last_message_at < ? then ?
|
|
2076
|
-
else last_message_at
|
|
2077
|
-
end,
|
|
2078
|
-
unread_count = case
|
|
2079
|
-
when last_message_at = ? then 0
|
|
2080
|
-
when last_message_at <= ? then 0
|
|
2081
|
-
else unread_count
|
|
2082
|
-
end,
|
|
2083
|
-
needs_reply = case
|
|
2084
|
-
when last_message_at = ? then 0
|
|
2085
|
-
when last_message_at <= ? then 0
|
|
2086
|
-
else needs_reply
|
|
2087
|
-
end
|
|
2088
|
-
where id = ?
|
|
2089
|
-
`,
|
|
2090
|
-
).run(
|
|
2091
|
-
lastMessageAt,
|
|
2092
|
-
lastMessageAt,
|
|
2093
|
-
observedLastMessageAt,
|
|
2094
|
-
lastMessageAt,
|
|
2095
|
-
observedLastMessageAt,
|
|
2096
|
-
lastMessageAt,
|
|
2097
|
-
conversationId,
|
|
2098
|
-
);
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
function getLocalAuthorProfileId(accountId: string) {
|
|
2102
|
-
const db = getNativeDb();
|
|
2103
|
-
const row = db
|
|
2104
|
-
.prepare(
|
|
2105
|
-
`
|
|
2106
|
-
select p.id
|
|
2107
|
-
from accounts a
|
|
2108
|
-
join profiles p on p.handle = replace(a.handle, '@', '')
|
|
2109
|
-
where a.id = ?
|
|
2110
|
-
`,
|
|
2111
|
-
)
|
|
2112
|
-
.get(accountId) as { id: string } | undefined;
|
|
2113
|
-
|
|
2114
|
-
return row?.id;
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
let savepointCounter = 0;
|
|
2118
|
-
|
|
2119
|
-
function preflightWrite<T>(write: (db: Database) => T) {
|
|
2120
|
-
const db = getNativeDb();
|
|
2121
|
-
const savepoint = `__birdclaw_preflight_${++savepointCounter}`;
|
|
2122
|
-
db.exec(`savepoint ${savepoint}`);
|
|
2123
|
-
try {
|
|
2124
|
-
const result = write(db);
|
|
2125
|
-
db.exec(`rollback to ${savepoint}`);
|
|
2126
|
-
db.exec(`release ${savepoint}`);
|
|
2127
|
-
return result;
|
|
2128
|
-
} catch (error) {
|
|
2129
|
-
try {
|
|
2130
|
-
db.exec(`rollback to ${savepoint}`);
|
|
2131
|
-
db.exec(`release ${savepoint}`);
|
|
2132
|
-
} catch {
|
|
2133
|
-
// Preserve the original staging error; cleanup is best effort here.
|
|
2134
|
-
}
|
|
2135
|
-
throw error;
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
function persistWrite<T>(write: (db: Database) => T) {
|
|
2140
|
-
const db = getNativeDb();
|
|
2141
|
-
return db.transaction(() => write(db))();
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
type PostDraft = {
|
|
2145
|
-
actionId: string;
|
|
2146
|
-
authorProfileId: string;
|
|
2147
|
-
createdAt: string;
|
|
2148
|
-
tweetId: string;
|
|
2149
|
-
};
|
|
2150
|
-
|
|
2151
|
-
function preparePostDraft(accountId: string): PostDraft {
|
|
2152
|
-
const authorProfileId = getLocalAuthorProfileId(accountId);
|
|
2153
|
-
if (!authorProfileId) {
|
|
2154
|
-
throw new Error("No local author profile for account");
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
return {
|
|
2158
|
-
actionId: randomUUID(),
|
|
2159
|
-
authorProfileId,
|
|
2160
|
-
createdAt: new Date().toISOString(),
|
|
2161
|
-
tweetId: `tweet_${randomUUID()}`,
|
|
2162
|
-
};
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
function writePostDraft(
|
|
2166
|
-
db: Database,
|
|
2167
|
-
accountId: string,
|
|
2168
|
-
text: string,
|
|
2169
|
-
draft: PostDraft,
|
|
2170
|
-
) {
|
|
2171
|
-
db.prepare(
|
|
2172
|
-
`
|
|
2173
|
-
insert into tweets (
|
|
2174
|
-
id, account_id, author_profile_id, kind, text, created_at,
|
|
2175
|
-
is_replied, reply_to_id, like_count, media_count, bookmarked, liked
|
|
2176
|
-
) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
|
|
2177
|
-
`,
|
|
2178
|
-
).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
|
|
2179
|
-
|
|
2180
|
-
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
2181
|
-
draft.tweetId,
|
|
2182
|
-
text,
|
|
2183
|
-
);
|
|
2184
|
-
db.prepare(
|
|
2185
|
-
"insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
|
|
2186
|
-
).run(
|
|
2187
|
-
draft.actionId,
|
|
2188
|
-
accountId,
|
|
2189
|
-
draft.tweetId,
|
|
2190
|
-
"post",
|
|
2191
|
-
text,
|
|
2192
|
-
draft.createdAt,
|
|
2193
|
-
);
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
export function createPostEffect(accountId: string, text: string) {
|
|
2197
|
-
return Effect.gen(function* () {
|
|
2198
|
-
const draft = yield* trySync(() => {
|
|
2199
|
-
const postDraft = preparePostDraft(accountId);
|
|
2200
|
-
preflightWrite((db) => writePostDraft(db, accountId, text, postDraft));
|
|
2201
|
-
return postDraft;
|
|
2202
|
-
});
|
|
2203
|
-
|
|
2204
|
-
yield* verifySelectedXurlAccountEffect(accountId);
|
|
2205
|
-
const transport = yield* postViaXurlEffect(text);
|
|
2206
|
-
if (!transport.ok) {
|
|
2207
|
-
return yield* Effect.fail(new Error(transport.output || "post failed"));
|
|
2208
|
-
}
|
|
2209
|
-
yield* trySync(() =>
|
|
2210
|
-
persistWrite((db) => writePostDraft(db, accountId, text, draft)),
|
|
2211
|
-
);
|
|
2212
|
-
|
|
2213
|
-
return { ok: true, transport, tweetId: draft.tweetId };
|
|
2214
|
-
});
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
export function createPost(accountId: string, text: string) {
|
|
2218
|
-
return runEffectPromise(createPostEffect(accountId, text));
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
|
-
export function createTweetReplyEffect(
|
|
2222
|
-
accountId: string,
|
|
2223
|
-
tweetId: string,
|
|
2224
|
-
text: string,
|
|
2225
|
-
) {
|
|
2226
|
-
type ReplyDraft = PostDraft & { replyId: string };
|
|
2227
|
-
|
|
2228
|
-
function prepareReplyDraft(): ReplyDraft {
|
|
2229
|
-
const postDraft = preparePostDraft(accountId);
|
|
2230
|
-
return {
|
|
2231
|
-
...postDraft,
|
|
2232
|
-
replyId: postDraft.tweetId,
|
|
2233
|
-
};
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
function writeReplyDraft(db: Database, draft: ReplyDraft) {
|
|
2237
|
-
db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
|
|
2238
|
-
|
|
2239
|
-
db.prepare(
|
|
2240
|
-
`
|
|
2241
|
-
insert into tweets (
|
|
2242
|
-
id, account_id, author_profile_id, kind, text, created_at,
|
|
2243
|
-
is_replied, reply_to_id, like_count, media_count, bookmarked, liked
|
|
2244
|
-
) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
|
|
2245
|
-
`,
|
|
2246
|
-
).run(
|
|
2247
|
-
draft.replyId,
|
|
2248
|
-
accountId,
|
|
2249
|
-
draft.authorProfileId,
|
|
2250
|
-
text,
|
|
2251
|
-
draft.createdAt,
|
|
2252
|
-
tweetId,
|
|
2253
|
-
);
|
|
2254
|
-
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
2255
|
-
draft.replyId,
|
|
2256
|
-
text,
|
|
2257
|
-
);
|
|
2258
|
-
|
|
2259
|
-
db.prepare(
|
|
2260
|
-
"insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
|
|
2261
|
-
).run(draft.actionId, accountId, tweetId, "reply", text, draft.createdAt);
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
return Effect.gen(function* () {
|
|
2265
|
-
const draft = yield* trySync(() => {
|
|
2266
|
-
const replyDraft = prepareReplyDraft();
|
|
2267
|
-
preflightWrite((db) => writeReplyDraft(db, replyDraft));
|
|
2268
|
-
return replyDraft;
|
|
2269
|
-
});
|
|
2270
|
-
|
|
2271
|
-
yield* verifySelectedXurlAccountEffect(accountId);
|
|
2272
|
-
const transport = yield* replyViaXurlEffect(tweetId, text);
|
|
2273
|
-
if (!transport.ok) {
|
|
2274
|
-
return yield* Effect.fail(new Error(transport.output || "reply failed"));
|
|
2275
|
-
}
|
|
2276
|
-
yield* trySync(() => persistWrite((db) => writeReplyDraft(db, draft)));
|
|
2277
|
-
|
|
2278
|
-
return { ok: true, transport, replyId: draft.replyId };
|
|
2279
|
-
});
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
export function createTweetReply(
|
|
2283
|
-
accountId: string,
|
|
2284
|
-
tweetId: string,
|
|
2285
|
-
text: string,
|
|
2286
|
-
) {
|
|
2287
|
-
return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
export function createDmReplyEffect(conversationId: string, text: string) {
|
|
2291
|
-
return Effect.gen(function* () {
|
|
2292
|
-
const draft = yield* trySync(() => {
|
|
2293
|
-
const conversation = getConversationThread(conversationId);
|
|
2294
|
-
if (!conversation) {
|
|
2295
|
-
throw new Error("Conversation not found");
|
|
2296
|
-
}
|
|
2297
|
-
const authorProfileId = getLocalAuthorProfileId(
|
|
2298
|
-
conversation.conversation.accountId,
|
|
2299
|
-
);
|
|
2300
|
-
if (!authorProfileId) {
|
|
2301
|
-
throw new Error("No local author profile for account");
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
|
-
const dmDraft = {
|
|
2305
|
-
accountId: conversation.conversation.accountId,
|
|
2306
|
-
authorProfileId,
|
|
2307
|
-
createdAt: new Date().toISOString(),
|
|
2308
|
-
handle: conversation.conversation.participant.handle,
|
|
2309
|
-
observedLastMessageAt: conversation.conversation.lastMessageAt,
|
|
2310
|
-
outboundId: `msg_${randomUUID()}`,
|
|
2311
|
-
};
|
|
2312
|
-
preflightWrite((db) => {
|
|
2313
|
-
db.prepare(
|
|
2314
|
-
`
|
|
2315
|
-
insert into dm_messages (
|
|
2316
|
-
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
2317
|
-
) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
|
|
2318
|
-
`,
|
|
2319
|
-
).run(
|
|
2320
|
-
dmDraft.outboundId,
|
|
2321
|
-
conversationId,
|
|
2322
|
-
dmDraft.authorProfileId,
|
|
2323
|
-
text,
|
|
2324
|
-
dmDraft.createdAt,
|
|
2325
|
-
);
|
|
2326
|
-
db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
|
|
2327
|
-
dmDraft.outboundId,
|
|
2328
|
-
text,
|
|
2329
|
-
);
|
|
2330
|
-
|
|
2331
|
-
refreshDmConversationState(
|
|
2332
|
-
db,
|
|
2333
|
-
conversationId,
|
|
2334
|
-
dmDraft.createdAt,
|
|
2335
|
-
dmDraft.observedLastMessageAt,
|
|
2336
|
-
);
|
|
2337
|
-
});
|
|
2338
|
-
return dmDraft;
|
|
2339
|
-
});
|
|
2340
|
-
|
|
2341
|
-
yield* verifySelectedXurlAccountEffect(draft.accountId);
|
|
2342
|
-
const transport = yield* dmViaXurlEffect(draft.handle, text);
|
|
2343
|
-
if (!transport.ok) {
|
|
2344
|
-
return yield* Effect.fail(new Error(transport.output || "dm failed"));
|
|
2345
|
-
}
|
|
2346
|
-
yield* trySync(() =>
|
|
2347
|
-
persistWrite((db) => {
|
|
2348
|
-
db.prepare(
|
|
2349
|
-
`
|
|
2350
|
-
insert into dm_messages (
|
|
2351
|
-
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
2352
|
-
) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
|
|
2353
|
-
`,
|
|
2354
|
-
).run(
|
|
2355
|
-
draft.outboundId,
|
|
2356
|
-
conversationId,
|
|
2357
|
-
draft.authorProfileId,
|
|
2358
|
-
text,
|
|
2359
|
-
draft.createdAt,
|
|
2360
|
-
);
|
|
2361
|
-
db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
|
|
2362
|
-
draft.outboundId,
|
|
2363
|
-
text,
|
|
2364
|
-
);
|
|
2365
|
-
|
|
2366
|
-
refreshDmConversationState(
|
|
2367
|
-
db,
|
|
2368
|
-
conversationId,
|
|
2369
|
-
draft.createdAt,
|
|
2370
|
-
draft.observedLastMessageAt,
|
|
2371
|
-
);
|
|
2372
|
-
}),
|
|
2373
|
-
);
|
|
2374
|
-
|
|
2375
|
-
return { ok: true, transport, messageId: draft.outboundId };
|
|
2376
|
-
});
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
export function createDmReply(conversationId: string, text: string) {
|
|
2380
|
-
return runEffectPromise(createDmReplyEffect(conversationId, text));
|
|
2381
|
-
}
|
|
1
|
+
// Compatibility facade. New code should import the owned read model or action module.
|
|
2
|
+
export * from "./query-read-models";
|
|
3
|
+
export {
|
|
4
|
+
applyDmRequestMutationToLocalStore,
|
|
5
|
+
type DmRequestMutationAction,
|
|
6
|
+
} from "./query-actions";
|