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