birdclaw 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- 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 +79 -48
- package/src/lib/db.ts +33 -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 +969 -199
- 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 +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- 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 +42 -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 +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/today.tsx +441 -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,31 +643,28 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
|
|
|
321
643
|
const row = db
|
|
322
644
|
.prepare(
|
|
323
645
|
`
|
|
324
|
-
select (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
)
|
|
347
|
-
)
|
|
348
|
-
) as count
|
|
646
|
+
select count(distinct tweet_id) as count
|
|
647
|
+
from (
|
|
648
|
+
select edge.tweet_id
|
|
649
|
+
from tweet_account_edges edge
|
|
650
|
+
where edge.kind = ?
|
|
651
|
+
and exists (
|
|
652
|
+
select 1
|
|
653
|
+
from tweets t
|
|
654
|
+
where t.id = edge.tweet_id
|
|
655
|
+
)
|
|
656
|
+
union all
|
|
657
|
+
select legacy.id as tweet_id
|
|
658
|
+
from tweets legacy
|
|
659
|
+
where legacy.kind = ?
|
|
660
|
+
and not exists (
|
|
661
|
+
select 1
|
|
662
|
+
from tweet_account_edges edge
|
|
663
|
+
where edge.account_id = legacy.account_id
|
|
664
|
+
and edge.tweet_id = legacy.id
|
|
665
|
+
and edge.kind = legacy.kind
|
|
666
|
+
)
|
|
667
|
+
)
|
|
349
668
|
`,
|
|
350
669
|
)
|
|
351
670
|
.get(kind, kind) as { count: number | bigint } | undefined;
|
|
@@ -354,51 +673,110 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
|
|
|
354
673
|
|
|
355
674
|
const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
|
|
356
675
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
676
|
+
function getAccountProfileMeta(
|
|
677
|
+
db: Database,
|
|
678
|
+
account: { handle: string; external_user_id: string | null },
|
|
679
|
+
) {
|
|
680
|
+
const handle = account.handle.replace(/^@/, "");
|
|
681
|
+
const externalProfileId = account.external_user_id
|
|
682
|
+
? `profile_user_${account.external_user_id}`
|
|
683
|
+
: "";
|
|
684
|
+
return db
|
|
685
|
+
.prepare(
|
|
686
|
+
`
|
|
687
|
+
select id, avatar_hue, avatar_url
|
|
688
|
+
from profiles
|
|
689
|
+
where id = ?
|
|
690
|
+
or lower(handle) = lower(?)
|
|
691
|
+
order by case
|
|
692
|
+
when id = 'profile_me' then 0
|
|
693
|
+
when id = ? then 1
|
|
694
|
+
else 2
|
|
695
|
+
end
|
|
696
|
+
limit 1
|
|
697
|
+
`,
|
|
698
|
+
)
|
|
699
|
+
.get(externalProfileId, handle, externalProfileId) as
|
|
700
|
+
| { id: string; avatar_hue: number; avatar_url: string | null }
|
|
701
|
+
| undefined;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function getQueryEnvelopeEffect(): Effect.Effect<
|
|
705
|
+
QueryEnvelope,
|
|
706
|
+
unknown
|
|
707
|
+
> {
|
|
708
|
+
return Effect.gen(function* () {
|
|
709
|
+
const db = yield* trySync(() => getDb());
|
|
710
|
+
const nativeDb = yield* trySync(() => getNativeDb());
|
|
711
|
+
const homeCount = yield* trySync(() =>
|
|
712
|
+
countTimelineEdges(nativeDb, "home"),
|
|
713
|
+
);
|
|
714
|
+
const mentionCount = yield* trySync(() =>
|
|
715
|
+
countTimelineEdges(nativeDb, "mention"),
|
|
716
|
+
);
|
|
717
|
+
const counts = yield* Effect.all({
|
|
718
|
+
dms: tryPromise(() =>
|
|
719
|
+
db
|
|
720
|
+
.selectFrom("dm_conversations")
|
|
721
|
+
.select((eb) => eb.fn.countAll().as("count"))
|
|
722
|
+
.executeTakeFirstOrThrow(),
|
|
723
|
+
),
|
|
724
|
+
needsReply: tryPromise(() =>
|
|
725
|
+
db
|
|
726
|
+
.selectFrom("dm_conversations")
|
|
727
|
+
.select((eb) => eb.fn.countAll().as("count"))
|
|
728
|
+
.where("needs_reply", "=", 1)
|
|
729
|
+
.executeTakeFirstOrThrow(),
|
|
730
|
+
),
|
|
731
|
+
accounts: tryPromise(() =>
|
|
732
|
+
db
|
|
733
|
+
.selectFrom("accounts")
|
|
734
|
+
.selectAll()
|
|
735
|
+
.orderBy("is_default", "desc")
|
|
736
|
+
.orderBy("name", "asc")
|
|
737
|
+
.execute(),
|
|
738
|
+
),
|
|
739
|
+
archives: findArchivesEffect(),
|
|
740
|
+
transport: getTransportStatusEffect(),
|
|
741
|
+
});
|
|
381
742
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
743
|
+
return {
|
|
744
|
+
stats: {
|
|
745
|
+
home: homeCount,
|
|
746
|
+
mentions: mentionCount,
|
|
747
|
+
dms: Number(counts.dms.count),
|
|
748
|
+
needsReply: Number(counts.needsReply.count),
|
|
749
|
+
inbox: mentionCount + Number(counts.needsReply.count),
|
|
750
|
+
},
|
|
751
|
+
accounts: counts.accounts.map((row) => {
|
|
752
|
+
const profile = getAccountProfileMeta(nativeDb, row);
|
|
753
|
+
return {
|
|
754
|
+
id: row.id,
|
|
755
|
+
name: row.name,
|
|
756
|
+
handle: row.handle,
|
|
757
|
+
externalUserId: row.external_user_id,
|
|
758
|
+
...(profile
|
|
759
|
+
? {
|
|
760
|
+
profileId: profile.id,
|
|
761
|
+
avatarHue: Number(profile.avatar_hue),
|
|
762
|
+
...(profile.avatar_url
|
|
763
|
+
? { avatarUrl: profile.avatar_url }
|
|
764
|
+
: {}),
|
|
765
|
+
}
|
|
766
|
+
: {}),
|
|
767
|
+
transport: row.transport,
|
|
768
|
+
isDefault: row.is_default,
|
|
769
|
+
createdAt: row.created_at,
|
|
770
|
+
};
|
|
771
|
+
}) satisfies AccountRecord[],
|
|
772
|
+
archives: counts.archives,
|
|
773
|
+
transport: counts.transport,
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
779
|
+
return runEffectPromise(getQueryEnvelopeEffect());
|
|
402
780
|
}
|
|
403
781
|
|
|
404
782
|
export function listTimelineItems({
|
|
@@ -421,13 +799,14 @@ export function listTimelineItems({
|
|
|
421
799
|
const params: Array<string | number> = [];
|
|
422
800
|
const normalizedLowQualityThreshold =
|
|
423
801
|
normalizeLowQualityThreshold(lowQualityThreshold);
|
|
802
|
+
const shouldDedupeAcrossAccounts = !account || account === "all";
|
|
424
803
|
let timelineEdgesCte = `
|
|
425
804
|
with timeline_edges as (
|
|
426
|
-
select account_id, tweet_id, kind
|
|
805
|
+
select account_id, tweet_id, kind, raw_json
|
|
427
806
|
from tweet_account_edges
|
|
428
807
|
where kind = ?
|
|
429
808
|
union all
|
|
430
|
-
select legacy.account_id, legacy.id as tweet_id, legacy.kind
|
|
809
|
+
select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
|
|
431
810
|
from tweets legacy
|
|
432
811
|
where legacy.kind = ?
|
|
433
812
|
and not exists (
|
|
@@ -460,7 +839,7 @@ export function listTimelineItems({
|
|
|
460
839
|
if (likedOnly && bookmarkedOnly) {
|
|
461
840
|
timelineEdgesCte = `
|
|
462
841
|
with timeline_edges as (
|
|
463
|
-
select likes.account_id, likes.tweet_id, 'home' as kind
|
|
842
|
+
select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json
|
|
464
843
|
from tweet_collections likes
|
|
465
844
|
join tweet_collections bookmarks
|
|
466
845
|
on bookmarks.account_id = likes.account_id
|
|
@@ -468,7 +847,7 @@ export function listTimelineItems({
|
|
|
468
847
|
and bookmarks.kind = 'bookmarks'
|
|
469
848
|
where likes.kind = 'likes'
|
|
470
849
|
union all
|
|
471
|
-
select legacy.account_id, legacy.id as tweet_id, 'home' as kind
|
|
850
|
+
select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
|
|
472
851
|
from tweets legacy
|
|
473
852
|
where legacy.liked = 1
|
|
474
853
|
and legacy.bookmarked = 1
|
|
@@ -486,11 +865,11 @@ export function listTimelineItems({
|
|
|
486
865
|
const legacyColumn = likedOnly ? "liked" : "bookmarked";
|
|
487
866
|
timelineEdgesCte = `
|
|
488
867
|
with timeline_edges as (
|
|
489
|
-
select account_id, tweet_id, 'home' as kind
|
|
868
|
+
select account_id, tweet_id, 'home' as kind, raw_json
|
|
490
869
|
from tweet_collections
|
|
491
870
|
where kind = ?
|
|
492
871
|
union all
|
|
493
|
-
select legacy.account_id, legacy.id as tweet_id, 'home' as kind
|
|
872
|
+
select legacy.account_id, legacy.id as tweet_id, 'home' as kind, '{}' as raw_json
|
|
494
873
|
from tweets legacy
|
|
495
874
|
where legacy.${legacyColumn} = 1
|
|
496
875
|
and not exists (
|
|
@@ -509,7 +888,7 @@ export function listTimelineItems({
|
|
|
509
888
|
usedRecentEdgeWindow = true;
|
|
510
889
|
timelineEdgesCte = `
|
|
511
890
|
with timeline_edges as (
|
|
512
|
-
select account_id, tweet_id, kind
|
|
891
|
+
select account_id, tweet_id, kind, raw_json
|
|
513
892
|
from tweet_account_edges
|
|
514
893
|
where kind = ?
|
|
515
894
|
and tweet_id in (
|
|
@@ -519,7 +898,7 @@ export function listTimelineItems({
|
|
|
519
898
|
limit ?
|
|
520
899
|
)
|
|
521
900
|
union all
|
|
522
|
-
select legacy.account_id, legacy.id as tweet_id, legacy.kind
|
|
901
|
+
select legacy.account_id, legacy.id as tweet_id, legacy.kind, '{}' as raw_json
|
|
523
902
|
from tweets legacy
|
|
524
903
|
where legacy.kind = ?
|
|
525
904
|
and legacy.id in (
|
|
@@ -555,6 +934,20 @@ export function listTimelineItems({
|
|
|
555
934
|
params.push(account);
|
|
556
935
|
}
|
|
557
936
|
|
|
937
|
+
if (shouldDedupeAcrossAccounts) {
|
|
938
|
+
where += `
|
|
939
|
+
and e.account_id = (
|
|
940
|
+
select e2.account_id
|
|
941
|
+
from timeline_edges e2
|
|
942
|
+
join accounts a2 on a2.id = e2.account_id
|
|
943
|
+
where e2.tweet_id = e.tweet_id
|
|
944
|
+
and e2.kind = e.kind
|
|
945
|
+
order by a2.is_default desc, e2.account_id asc
|
|
946
|
+
limit 1
|
|
947
|
+
)
|
|
948
|
+
`;
|
|
949
|
+
}
|
|
950
|
+
|
|
558
951
|
where += buildReplyClause(replyFilter).replaceAll(
|
|
559
952
|
"is_replied",
|
|
560
953
|
"t.is_replied",
|
|
@@ -598,6 +991,7 @@ export function listTimelineItems({
|
|
|
598
991
|
e.account_id,
|
|
599
992
|
a.handle as account_handle,
|
|
600
993
|
e.kind,
|
|
994
|
+
e.raw_json as edge_raw_json,
|
|
601
995
|
t.text,
|
|
602
996
|
t.created_at,
|
|
603
997
|
t.reply_to_id,
|
|
@@ -696,6 +1090,7 @@ export function listTimelineItems({
|
|
|
696
1090
|
}
|
|
697
1091
|
|
|
698
1092
|
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1093
|
+
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
699
1094
|
return rows.map((row) => {
|
|
700
1095
|
const author = {
|
|
701
1096
|
id: String(row.profile_id),
|
|
@@ -709,45 +1104,49 @@ export function listTimelineItems({
|
|
|
709
1104
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
710
1105
|
createdAt: String(row.profile_created_at),
|
|
711
1106
|
};
|
|
1107
|
+
const rowProfiles: Record<string, ProfileRecord> = {
|
|
1108
|
+
[author.id]: author,
|
|
1109
|
+
...(row.reply_profile_id
|
|
1110
|
+
? {
|
|
1111
|
+
[String(row.reply_profile_id)]: toProfile({
|
|
1112
|
+
id: row.reply_profile_id,
|
|
1113
|
+
handle: row.reply_handle,
|
|
1114
|
+
display_name: row.reply_display_name,
|
|
1115
|
+
bio: row.reply_bio,
|
|
1116
|
+
followers_count: row.reply_followers_count,
|
|
1117
|
+
following_count: row.reply_following_count,
|
|
1118
|
+
avatar_hue: row.reply_avatar_hue,
|
|
1119
|
+
avatar_url: row.reply_avatar_url,
|
|
1120
|
+
created_at: row.reply_profile_created_at,
|
|
1121
|
+
}),
|
|
1122
|
+
}
|
|
1123
|
+
: {}),
|
|
1124
|
+
...(row.quoted_profile_id
|
|
1125
|
+
? {
|
|
1126
|
+
[String(row.quoted_profile_id)]: toProfile({
|
|
1127
|
+
id: row.quoted_profile_id,
|
|
1128
|
+
handle: row.quoted_handle,
|
|
1129
|
+
display_name: row.quoted_display_name,
|
|
1130
|
+
bio: row.quoted_bio,
|
|
1131
|
+
followers_count: row.quoted_followers_count,
|
|
1132
|
+
following_count: row.quoted_following_count,
|
|
1133
|
+
avatar_hue: row.quoted_avatar_hue,
|
|
1134
|
+
avatar_url: row.quoted_avatar_url,
|
|
1135
|
+
created_at: row.quoted_profile_created_at,
|
|
1136
|
+
}),
|
|
1137
|
+
}
|
|
1138
|
+
: {}),
|
|
1139
|
+
};
|
|
1140
|
+
const resolveProfileByHandle = (handle: string) =>
|
|
1141
|
+
getProfileByHandle(db, profileByHandleCache, handle, rowProfiles);
|
|
712
1142
|
const text = String(row.text);
|
|
713
1143
|
const entities = enrichTimelineEntities(
|
|
714
1144
|
db,
|
|
715
1145
|
urlExpansionCache,
|
|
716
1146
|
text,
|
|
717
1147
|
parseJsonField<TweetEntities>(row.entities_json, {}),
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
...(row.reply_profile_id
|
|
721
|
-
? {
|
|
722
|
-
[String(row.reply_profile_id)]: toProfile({
|
|
723
|
-
id: row.reply_profile_id,
|
|
724
|
-
handle: row.reply_handle,
|
|
725
|
-
display_name: row.reply_display_name,
|
|
726
|
-
bio: row.reply_bio,
|
|
727
|
-
followers_count: row.reply_followers_count,
|
|
728
|
-
following_count: row.reply_following_count,
|
|
729
|
-
avatar_hue: row.reply_avatar_hue,
|
|
730
|
-
avatar_url: row.reply_avatar_url,
|
|
731
|
-
created_at: row.reply_profile_created_at,
|
|
732
|
-
}),
|
|
733
|
-
}
|
|
734
|
-
: {}),
|
|
735
|
-
...(row.quoted_profile_id
|
|
736
|
-
? {
|
|
737
|
-
[String(row.quoted_profile_id)]: toProfile({
|
|
738
|
-
id: row.quoted_profile_id,
|
|
739
|
-
handle: row.quoted_handle,
|
|
740
|
-
display_name: row.quoted_display_name,
|
|
741
|
-
bio: row.quoted_bio,
|
|
742
|
-
followers_count: row.quoted_followers_count,
|
|
743
|
-
following_count: row.quoted_following_count,
|
|
744
|
-
avatar_hue: row.quoted_avatar_hue,
|
|
745
|
-
avatar_url: row.quoted_avatar_url,
|
|
746
|
-
created_at: row.quoted_profile_created_at,
|
|
747
|
-
}),
|
|
748
|
-
}
|
|
749
|
-
: {}),
|
|
750
|
-
},
|
|
1148
|
+
rowProfiles,
|
|
1149
|
+
resolveProfileByHandle,
|
|
751
1150
|
);
|
|
752
1151
|
const item = {
|
|
753
1152
|
id: String(row.id),
|
|
@@ -769,8 +1168,26 @@ export function listTimelineItems({
|
|
|
769
1168
|
author,
|
|
770
1169
|
entities,
|
|
771
1170
|
media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
|
|
772
|
-
replyToTweet: buildEmbeddedTweet(
|
|
773
|
-
|
|
1171
|
+
replyToTweet: buildEmbeddedTweet(
|
|
1172
|
+
db,
|
|
1173
|
+
urlExpansionCache,
|
|
1174
|
+
row,
|
|
1175
|
+
"reply_",
|
|
1176
|
+
resolveProfileByHandle,
|
|
1177
|
+
),
|
|
1178
|
+
quotedTweet: buildEmbeddedTweet(
|
|
1179
|
+
db,
|
|
1180
|
+
urlExpansionCache,
|
|
1181
|
+
row,
|
|
1182
|
+
"quoted_",
|
|
1183
|
+
resolveProfileByHandle,
|
|
1184
|
+
),
|
|
1185
|
+
retweetedTweet: buildRetweetedTweet(
|
|
1186
|
+
db,
|
|
1187
|
+
urlExpansionCache,
|
|
1188
|
+
row,
|
|
1189
|
+
resolveProfileByHandle,
|
|
1190
|
+
),
|
|
774
1191
|
};
|
|
775
1192
|
return includeQualityReason
|
|
776
1193
|
? {
|
|
@@ -784,12 +1201,42 @@ export function listTimelineItems({
|
|
|
784
1201
|
});
|
|
785
1202
|
}
|
|
786
1203
|
|
|
787
|
-
|
|
1204
|
+
function conversationTweetSelect(accountId?: string) {
|
|
1205
|
+
const collectionStateSelect = accountId
|
|
1206
|
+
? `
|
|
1207
|
+
case
|
|
1208
|
+
when exists (
|
|
1209
|
+
select 1 from tweet_collections collection
|
|
1210
|
+
where collection.account_id = ?
|
|
1211
|
+
and collection.tweet_id = t.id
|
|
1212
|
+
and collection.kind = 'bookmarks'
|
|
1213
|
+
) then 1
|
|
1214
|
+
when t.account_id = ? and t.bookmarked = 1 then 1
|
|
1215
|
+
else 0
|
|
1216
|
+
end as bookmarked,
|
|
1217
|
+
case
|
|
1218
|
+
when exists (
|
|
1219
|
+
select 1 from tweet_collections collection
|
|
1220
|
+
where collection.account_id = ?
|
|
1221
|
+
and collection.tweet_id = t.id
|
|
1222
|
+
and collection.kind = 'likes'
|
|
1223
|
+
) then 1
|
|
1224
|
+
when t.account_id = ? and t.liked = 1 then 1
|
|
1225
|
+
else 0
|
|
1226
|
+
end as liked,`
|
|
1227
|
+
: `
|
|
1228
|
+
t.bookmarked,
|
|
1229
|
+
t.liked,`;
|
|
1230
|
+
return `
|
|
788
1231
|
select
|
|
789
1232
|
t.id,
|
|
790
1233
|
t.text,
|
|
791
1234
|
t.created_at,
|
|
792
1235
|
t.reply_to_id,
|
|
1236
|
+
t.is_replied,
|
|
1237
|
+
t.like_count,
|
|
1238
|
+
t.media_count,
|
|
1239
|
+
${collectionStateSelect}
|
|
793
1240
|
t.entities_json,
|
|
794
1241
|
t.media_json,
|
|
795
1242
|
p.id as profile_id,
|
|
@@ -804,17 +1251,29 @@ const conversationTweetSelect = `
|
|
|
804
1251
|
from tweets t
|
|
805
1252
|
join profiles p on p.id = t.author_profile_id
|
|
806
1253
|
`;
|
|
1254
|
+
}
|
|
807
1255
|
|
|
808
1256
|
function getTweetById(
|
|
809
1257
|
db: Database,
|
|
810
1258
|
urlExpansionCache: UrlExpansionCache,
|
|
811
1259
|
tweetId: string,
|
|
1260
|
+
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
1261
|
+
accountId?: string,
|
|
812
1262
|
): EmbeddedTweet | null {
|
|
1263
|
+
const stateParams = accountId
|
|
1264
|
+
? [accountId, accountId, accountId, accountId]
|
|
1265
|
+
: [];
|
|
813
1266
|
const row = db
|
|
814
|
-
.prepare(`${conversationTweetSelect} where t.id = ?`)
|
|
815
|
-
.get(tweetId) as Record<string, unknown> | undefined;
|
|
1267
|
+
.prepare(`${conversationTweetSelect(accountId)} where t.id = ?`)
|
|
1268
|
+
.get(...stateParams, tweetId) as Record<string, unknown> | undefined;
|
|
816
1269
|
if (!row) return null;
|
|
817
|
-
return buildEmbeddedTweet(
|
|
1270
|
+
return buildEmbeddedTweet(
|
|
1271
|
+
db,
|
|
1272
|
+
urlExpansionCache,
|
|
1273
|
+
row,
|
|
1274
|
+
"",
|
|
1275
|
+
resolveProfileByHandle,
|
|
1276
|
+
);
|
|
818
1277
|
}
|
|
819
1278
|
|
|
820
1279
|
function listTweetDescendants(
|
|
@@ -822,6 +1281,7 @@ function listTweetDescendants(
|
|
|
822
1281
|
urlExpansionCache: UrlExpansionCache,
|
|
823
1282
|
rootId: string,
|
|
824
1283
|
limit: number,
|
|
1284
|
+
resolveProfileByHandle?: (handle: string) => ProfileRecord,
|
|
825
1285
|
) {
|
|
826
1286
|
if (limit <= 0) return [];
|
|
827
1287
|
const rows = db
|
|
@@ -837,7 +1297,7 @@ function listTweetDescendants(
|
|
|
837
1297
|
join branch on child.reply_to_id = branch.id
|
|
838
1298
|
where branch.depth < 8
|
|
839
1299
|
)
|
|
840
|
-
${conversationTweetSelect}
|
|
1300
|
+
${conversationTweetSelect()}
|
|
841
1301
|
join branch on branch.id = t.id
|
|
842
1302
|
where t.id != ?
|
|
843
1303
|
order by t.created_at asc
|
|
@@ -847,7 +1307,15 @@ function listTweetDescendants(
|
|
|
847
1307
|
.all(rootId, rootId, limit) as Array<Record<string, unknown>>;
|
|
848
1308
|
|
|
849
1309
|
return rows
|
|
850
|
-
.map((row) =>
|
|
1310
|
+
.map((row) =>
|
|
1311
|
+
buildEmbeddedTweet(
|
|
1312
|
+
db,
|
|
1313
|
+
urlExpansionCache,
|
|
1314
|
+
row,
|
|
1315
|
+
"",
|
|
1316
|
+
resolveProfileByHandle,
|
|
1317
|
+
),
|
|
1318
|
+
)
|
|
851
1319
|
.filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
|
|
852
1320
|
}
|
|
853
1321
|
|
|
@@ -870,13 +1338,26 @@ export function getTweetConversation(
|
|
|
870
1338
|
): TweetConversationResponse | null {
|
|
871
1339
|
const db = getNativeDb();
|
|
872
1340
|
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
873
|
-
const
|
|
1341
|
+
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1342
|
+
const resolveProfileByHandle = (handle: string) =>
|
|
1343
|
+
getProfileByHandle(db, profileByHandleCache, handle);
|
|
1344
|
+
const anchor = getTweetById(
|
|
1345
|
+
db,
|
|
1346
|
+
urlExpansionCache,
|
|
1347
|
+
tweetId,
|
|
1348
|
+
resolveProfileByHandle,
|
|
1349
|
+
);
|
|
874
1350
|
if (!anchor) return null;
|
|
875
1351
|
|
|
876
1352
|
const ancestors: EmbeddedTweet[] = [];
|
|
877
1353
|
let current = anchor;
|
|
878
1354
|
for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
|
|
879
|
-
const parent = getTweetById(
|
|
1355
|
+
const parent = getTweetById(
|
|
1356
|
+
db,
|
|
1357
|
+
urlExpansionCache,
|
|
1358
|
+
current.replyToId,
|
|
1359
|
+
resolveProfileByHandle,
|
|
1360
|
+
);
|
|
880
1361
|
if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
|
|
881
1362
|
ancestors.push(parent);
|
|
882
1363
|
current = parent;
|
|
@@ -897,6 +1378,7 @@ export function getTweetConversation(
|
|
|
897
1378
|
urlExpansionCache,
|
|
898
1379
|
anchor.id,
|
|
899
1380
|
remainingAfterRequired,
|
|
1381
|
+
resolveProfileByHandle,
|
|
900
1382
|
);
|
|
901
1383
|
appendConversationTweets(items, seen, focusedDescendants, limit);
|
|
902
1384
|
|
|
@@ -906,6 +1388,7 @@ export function getTweetConversation(
|
|
|
906
1388
|
urlExpansionCache,
|
|
907
1389
|
root.id,
|
|
908
1390
|
limit,
|
|
1391
|
+
resolveProfileByHandle,
|
|
909
1392
|
);
|
|
910
1393
|
appendConversationTweets(items, seen, ambientDescendants, limit);
|
|
911
1394
|
}
|
|
@@ -921,9 +1404,12 @@ export function getTweetConversation(
|
|
|
921
1404
|
export function listDmConversations({
|
|
922
1405
|
account,
|
|
923
1406
|
conversationIds,
|
|
1407
|
+
inbox = "all",
|
|
924
1408
|
participant,
|
|
925
1409
|
search,
|
|
926
1410
|
replyFilter = "all",
|
|
1411
|
+
since,
|
|
1412
|
+
until,
|
|
927
1413
|
minFollowers,
|
|
928
1414
|
maxFollowers,
|
|
929
1415
|
minInfluenceScore,
|
|
@@ -940,6 +1426,31 @@ export function listDmConversations({
|
|
|
940
1426
|
let where = "where 1 = 1";
|
|
941
1427
|
let searchSnippetSelect = "";
|
|
942
1428
|
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
1429
|
+
const influenceMinFollowers =
|
|
1430
|
+
typeof minInfluenceScore === "number"
|
|
1431
|
+
? getMinFollowersForInfluenceScore(minInfluenceScore)
|
|
1432
|
+
: undefined;
|
|
1433
|
+
const influenceMaxFollowers =
|
|
1434
|
+
typeof maxInfluenceScore === "number"
|
|
1435
|
+
? getMaxFollowersForInfluenceScore(maxInfluenceScore)
|
|
1436
|
+
: undefined;
|
|
1437
|
+
const effectiveMinFollowers =
|
|
1438
|
+
typeof minFollowers === "number" ||
|
|
1439
|
+
typeof influenceMinFollowers === "number"
|
|
1440
|
+
? Math.max(minFollowers ?? 0, influenceMinFollowers ?? 0)
|
|
1441
|
+
: undefined;
|
|
1442
|
+
const effectiveMaxFollowers =
|
|
1443
|
+
typeof maxFollowers === "number" ||
|
|
1444
|
+
typeof influenceMaxFollowers === "number"
|
|
1445
|
+
? Math.min(
|
|
1446
|
+
maxFollowers ?? Number.MAX_SAFE_INTEGER,
|
|
1447
|
+
influenceMaxFollowers ?? Number.MAX_SAFE_INTEGER,
|
|
1448
|
+
)
|
|
1449
|
+
: undefined;
|
|
1450
|
+
const orderBy =
|
|
1451
|
+
sort === "followers" || sort === "influence"
|
|
1452
|
+
? "p.followers_count desc, c.last_message_at desc"
|
|
1453
|
+
: "c.last_message_at desc";
|
|
943
1454
|
|
|
944
1455
|
if (account && account !== "all") {
|
|
945
1456
|
where += " and a.id = ?";
|
|
@@ -951,6 +1462,12 @@ export function listDmConversations({
|
|
|
951
1462
|
params.push(...conversationIds);
|
|
952
1463
|
}
|
|
953
1464
|
|
|
1465
|
+
if (inbox === "accepted") {
|
|
1466
|
+
where += " and c.inbox_kind = 'accepted'";
|
|
1467
|
+
} else if (inbox === "requests") {
|
|
1468
|
+
where += " and c.inbox_kind = 'request'";
|
|
1469
|
+
}
|
|
1470
|
+
|
|
954
1471
|
if (participant?.trim()) {
|
|
955
1472
|
where += " and (p.handle like ? or p.display_name like ?)";
|
|
956
1473
|
params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
|
|
@@ -962,14 +1479,23 @@ export function listDmConversations({
|
|
|
962
1479
|
where += " and c.needs_reply = 1";
|
|
963
1480
|
}
|
|
964
1481
|
|
|
965
|
-
if (
|
|
1482
|
+
if (since?.trim()) {
|
|
1483
|
+
where += " and c.last_message_at >= ?";
|
|
1484
|
+
params.push(since);
|
|
1485
|
+
}
|
|
1486
|
+
if (until?.trim()) {
|
|
1487
|
+
where += " and c.last_message_at < ?";
|
|
1488
|
+
params.push(until);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (typeof effectiveMinFollowers === "number") {
|
|
966
1492
|
where += " and p.followers_count >= ?";
|
|
967
|
-
params.push(
|
|
1493
|
+
params.push(effectiveMinFollowers);
|
|
968
1494
|
}
|
|
969
1495
|
|
|
970
|
-
if (typeof
|
|
1496
|
+
if (typeof effectiveMaxFollowers === "number") {
|
|
971
1497
|
where += " and p.followers_count <= ?";
|
|
972
|
-
params.push(
|
|
1498
|
+
params.push(effectiveMaxFollowers);
|
|
973
1499
|
}
|
|
974
1500
|
|
|
975
1501
|
if (ftsSearch) {
|
|
@@ -1012,6 +1538,7 @@ export function listDmConversations({
|
|
|
1012
1538
|
c.account_id,
|
|
1013
1539
|
a.handle as account_handle,
|
|
1014
1540
|
c.title,
|
|
1541
|
+
c.inbox_kind,
|
|
1015
1542
|
c.last_message_at,
|
|
1016
1543
|
c.unread_count,
|
|
1017
1544
|
c.needs_reply,
|
|
@@ -1042,7 +1569,7 @@ export function listDmConversations({
|
|
|
1042
1569
|
${join}
|
|
1043
1570
|
${where}
|
|
1044
1571
|
group by c.id
|
|
1045
|
-
order by
|
|
1572
|
+
order by ${orderBy}
|
|
1046
1573
|
limit ?
|
|
1047
1574
|
`,
|
|
1048
1575
|
)
|
|
@@ -1079,6 +1606,8 @@ export function listDmConversations({
|
|
|
1079
1606
|
...(typeof row.search_snippet === "string"
|
|
1080
1607
|
? { searchSnippet: row.search_snippet }
|
|
1081
1608
|
: {}),
|
|
1609
|
+
inboxKind: row.inbox_kind === "request" ? "request" : "accepted",
|
|
1610
|
+
isMessageRequest: row.inbox_kind === "request",
|
|
1082
1611
|
lastMessageAt: String(row.last_message_at),
|
|
1083
1612
|
lastMessagePreview: String(row.last_message_preview ?? ""),
|
|
1084
1613
|
unreadCount: Number(row.unread_count),
|
|
@@ -1115,7 +1644,7 @@ export function listDmConversations({
|
|
|
1115
1644
|
return true;
|
|
1116
1645
|
});
|
|
1117
1646
|
|
|
1118
|
-
if (sort === "influence") {
|
|
1647
|
+
if (sort === "followers" || sort === "influence") {
|
|
1119
1648
|
filtered.sort((left, right) => {
|
|
1120
1649
|
if (
|
|
1121
1650
|
right.participant.followersCount !== left.participant.followersCount
|
|
@@ -1152,10 +1681,13 @@ export function listDmConversations({
|
|
|
1152
1681
|
|
|
1153
1682
|
export function getConversationThread(
|
|
1154
1683
|
conversationId: string,
|
|
1684
|
+
filters: Pick<DmQuery, "account"> = {},
|
|
1155
1685
|
): { conversation: DmConversationItem; messages: DmMessageItem[] } | null {
|
|
1156
|
-
const conversation = listDmConversations({
|
|
1157
|
-
|
|
1158
|
-
|
|
1686
|
+
const conversation = listDmConversations({
|
|
1687
|
+
...filters,
|
|
1688
|
+
conversationIds: [conversationId],
|
|
1689
|
+
limit: 1,
|
|
1690
|
+
}).find((item) => item.id === conversationId);
|
|
1159
1691
|
|
|
1160
1692
|
if (!conversation) {
|
|
1161
1693
|
return null;
|
|
@@ -1215,6 +1747,54 @@ export function getConversationThread(
|
|
|
1215
1747
|
};
|
|
1216
1748
|
}
|
|
1217
1749
|
|
|
1750
|
+
export type DmRequestMutationAction = "accept" | "reject" | "block";
|
|
1751
|
+
|
|
1752
|
+
export function applyDmRequestMutationToLocalStore(
|
|
1753
|
+
conversationId: string,
|
|
1754
|
+
action: DmRequestMutationAction,
|
|
1755
|
+
) {
|
|
1756
|
+
return persistWrite((db) => {
|
|
1757
|
+
db.prepare(
|
|
1758
|
+
"delete from sync_cache where cache_key like 'dms:bird:%'",
|
|
1759
|
+
).run();
|
|
1760
|
+
if (action === "accept") {
|
|
1761
|
+
return db
|
|
1762
|
+
.prepare(
|
|
1763
|
+
`
|
|
1764
|
+
update dm_conversations
|
|
1765
|
+
set inbox_kind = 'accepted'
|
|
1766
|
+
where id = ?
|
|
1767
|
+
`,
|
|
1768
|
+
)
|
|
1769
|
+
.run(conversationId).changes;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
db.prepare(
|
|
1773
|
+
`
|
|
1774
|
+
delete from link_occurrences
|
|
1775
|
+
where source_kind = 'dm'
|
|
1776
|
+
and source_id in (
|
|
1777
|
+
select id from dm_messages where conversation_id = ?
|
|
1778
|
+
)
|
|
1779
|
+
`,
|
|
1780
|
+
).run(conversationId);
|
|
1781
|
+
db.prepare(
|
|
1782
|
+
`
|
|
1783
|
+
delete from dm_fts
|
|
1784
|
+
where message_id in (
|
|
1785
|
+
select id from dm_messages where conversation_id = ?
|
|
1786
|
+
)
|
|
1787
|
+
`,
|
|
1788
|
+
).run(conversationId);
|
|
1789
|
+
db.prepare("delete from dm_messages where conversation_id = ?").run(
|
|
1790
|
+
conversationId,
|
|
1791
|
+
);
|
|
1792
|
+
return db
|
|
1793
|
+
.prepare("delete from dm_conversations where id = ?")
|
|
1794
|
+
.run(conversationId).changes;
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1218
1798
|
function normalizeDmContext(value: number | undefined) {
|
|
1219
1799
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1220
1800
|
return 0;
|
|
@@ -1381,12 +1961,19 @@ export function queryResource(
|
|
|
1381
1961
|
if (resource === "dms") {
|
|
1382
1962
|
const dmFilters = filters as DmQuery & { conversationId?: string };
|
|
1383
1963
|
const items = listDmConversations(dmFilters);
|
|
1384
|
-
const
|
|
1964
|
+
const requestedConversationId = dmFilters.conversationId;
|
|
1965
|
+
const selectedConversationId =
|
|
1966
|
+
requestedConversationId &&
|
|
1967
|
+
items.some((item) => item.id === requestedConversationId)
|
|
1968
|
+
? requestedConversationId
|
|
1969
|
+
: items[0]?.id;
|
|
1385
1970
|
return {
|
|
1386
1971
|
resource,
|
|
1387
1972
|
items,
|
|
1388
1973
|
selectedConversation: selectedConversationId
|
|
1389
|
-
? getConversationThread(selectedConversationId
|
|
1974
|
+
? getConversationThread(selectedConversationId, {
|
|
1975
|
+
account: dmFilters.account,
|
|
1976
|
+
})
|
|
1390
1977
|
: null,
|
|
1391
1978
|
};
|
|
1392
1979
|
}
|
|
@@ -1407,16 +1994,36 @@ function refreshDmConversationState(
|
|
|
1407
1994
|
db: Database,
|
|
1408
1995
|
conversationId: string,
|
|
1409
1996
|
lastMessageAt: string,
|
|
1997
|
+
observedLastMessageAt = lastMessageAt,
|
|
1410
1998
|
) {
|
|
1411
1999
|
db.prepare(
|
|
1412
2000
|
`
|
|
1413
2001
|
update dm_conversations
|
|
1414
|
-
set last_message_at =
|
|
1415
|
-
|
|
1416
|
-
|
|
2002
|
+
set last_message_at = case
|
|
2003
|
+
when last_message_at < ? then ?
|
|
2004
|
+
else last_message_at
|
|
2005
|
+
end,
|
|
2006
|
+
unread_count = case
|
|
2007
|
+
when last_message_at = ? then 0
|
|
2008
|
+
when last_message_at <= ? then 0
|
|
2009
|
+
else unread_count
|
|
2010
|
+
end,
|
|
2011
|
+
needs_reply = case
|
|
2012
|
+
when last_message_at = ? then 0
|
|
2013
|
+
when last_message_at <= ? then 0
|
|
2014
|
+
else needs_reply
|
|
2015
|
+
end
|
|
1417
2016
|
where id = ?
|
|
1418
2017
|
`,
|
|
1419
|
-
).run(
|
|
2018
|
+
).run(
|
|
2019
|
+
lastMessageAt,
|
|
2020
|
+
lastMessageAt,
|
|
2021
|
+
observedLastMessageAt,
|
|
2022
|
+
lastMessageAt,
|
|
2023
|
+
observedLastMessageAt,
|
|
2024
|
+
lastMessageAt,
|
|
2025
|
+
conversationId,
|
|
2026
|
+
);
|
|
1420
2027
|
}
|
|
1421
2028
|
|
|
1422
2029
|
function getLocalAuthorProfileId(accountId: string) {
|
|
@@ -1435,16 +2042,60 @@ function getLocalAuthorProfileId(accountId: string) {
|
|
|
1435
2042
|
return row?.id;
|
|
1436
2043
|
}
|
|
1437
2044
|
|
|
1438
|
-
|
|
2045
|
+
let savepointCounter = 0;
|
|
2046
|
+
|
|
2047
|
+
function preflightWrite<T>(write: (db: Database) => T) {
|
|
2048
|
+
const db = getNativeDb();
|
|
2049
|
+
const savepoint = `__birdclaw_preflight_${++savepointCounter}`;
|
|
2050
|
+
db.exec(`savepoint ${savepoint}`);
|
|
2051
|
+
try {
|
|
2052
|
+
const result = write(db);
|
|
2053
|
+
db.exec(`rollback to ${savepoint}`);
|
|
2054
|
+
db.exec(`release ${savepoint}`);
|
|
2055
|
+
return result;
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
try {
|
|
2058
|
+
db.exec(`rollback to ${savepoint}`);
|
|
2059
|
+
db.exec(`release ${savepoint}`);
|
|
2060
|
+
} catch {
|
|
2061
|
+
// Preserve the original staging error; cleanup is best effort here.
|
|
2062
|
+
}
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function persistWrite<T>(write: (db: Database) => T) {
|
|
1439
2068
|
const db = getNativeDb();
|
|
2069
|
+
return db.transaction(() => write(db))();
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
type PostDraft = {
|
|
2073
|
+
actionId: string;
|
|
2074
|
+
authorProfileId: string;
|
|
2075
|
+
createdAt: string;
|
|
2076
|
+
tweetId: string;
|
|
2077
|
+
};
|
|
2078
|
+
|
|
2079
|
+
function preparePostDraft(accountId: string): PostDraft {
|
|
1440
2080
|
const authorProfileId = getLocalAuthorProfileId(accountId);
|
|
1441
2081
|
if (!authorProfileId) {
|
|
1442
2082
|
throw new Error("No local author profile for account");
|
|
1443
2083
|
}
|
|
1444
2084
|
|
|
1445
|
-
|
|
1446
|
-
|
|
2085
|
+
return {
|
|
2086
|
+
actionId: randomUUID(),
|
|
2087
|
+
authorProfileId,
|
|
2088
|
+
createdAt: new Date().toISOString(),
|
|
2089
|
+
tweetId: `tweet_${randomUUID()}`,
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
1447
2092
|
|
|
2093
|
+
function writePostDraft(
|
|
2094
|
+
db: Database,
|
|
2095
|
+
accountId: string,
|
|
2096
|
+
text: string,
|
|
2097
|
+
draft: PostDraft,
|
|
2098
|
+
) {
|
|
1448
2099
|
db.prepare(
|
|
1449
2100
|
`
|
|
1450
2101
|
insert into tweets (
|
|
@@ -1452,88 +2103,207 @@ export async function createPost(accountId: string, text: string) {
|
|
|
1452
2103
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked
|
|
1453
2104
|
) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
|
|
1454
2105
|
`,
|
|
1455
|
-
).run(tweetId, accountId, authorProfileId, text,
|
|
2106
|
+
).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
|
|
1456
2107
|
|
|
1457
2108
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
1458
|
-
tweetId,
|
|
2109
|
+
draft.tweetId,
|
|
1459
2110
|
text,
|
|
1460
2111
|
);
|
|
1461
2112
|
db.prepare(
|
|
1462
2113
|
"insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
|
|
1463
|
-
).run(
|
|
2114
|
+
).run(
|
|
2115
|
+
draft.actionId,
|
|
2116
|
+
accountId,
|
|
2117
|
+
draft.tweetId,
|
|
2118
|
+
"post",
|
|
2119
|
+
text,
|
|
2120
|
+
draft.createdAt,
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
export function createPostEffect(accountId: string, text: string) {
|
|
2125
|
+
return Effect.gen(function* () {
|
|
2126
|
+
const draft = yield* trySync(() => {
|
|
2127
|
+
const postDraft = preparePostDraft(accountId);
|
|
2128
|
+
preflightWrite((db) => writePostDraft(db, accountId, text, postDraft));
|
|
2129
|
+
return postDraft;
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
yield* verifySelectedXurlAccountEffect(accountId);
|
|
2133
|
+
const transport = yield* postViaXurlEffect(text);
|
|
2134
|
+
if (!transport.ok) {
|
|
2135
|
+
return yield* Effect.fail(new Error(transport.output || "post failed"));
|
|
2136
|
+
}
|
|
2137
|
+
yield* trySync(() =>
|
|
2138
|
+
persistWrite((db) => writePostDraft(db, accountId, text, draft)),
|
|
2139
|
+
);
|
|
2140
|
+
|
|
2141
|
+
return { ok: true, transport, tweetId: draft.tweetId };
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
1464
2144
|
|
|
1465
|
-
|
|
1466
|
-
return
|
|
2145
|
+
export function createPost(accountId: string, text: string) {
|
|
2146
|
+
return runEffectPromise(createPostEffect(accountId, text));
|
|
1467
2147
|
}
|
|
1468
2148
|
|
|
1469
|
-
export
|
|
2149
|
+
export function createTweetReplyEffect(
|
|
1470
2150
|
accountId: string,
|
|
1471
2151
|
tweetId: string,
|
|
1472
2152
|
text: string,
|
|
1473
2153
|
) {
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
2154
|
+
type ReplyDraft = PostDraft & { replyId: string };
|
|
2155
|
+
|
|
2156
|
+
function prepareReplyDraft(): ReplyDraft {
|
|
2157
|
+
const postDraft = preparePostDraft(accountId);
|
|
2158
|
+
return {
|
|
2159
|
+
...postDraft,
|
|
2160
|
+
replyId: postDraft.tweetId,
|
|
2161
|
+
};
|
|
1478
2162
|
}
|
|
1479
2163
|
|
|
1480
|
-
|
|
1481
|
-
|
|
2164
|
+
function writeReplyDraft(db: Database, draft: ReplyDraft) {
|
|
2165
|
+
db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
|
|
1482
2166
|
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
`
|
|
2167
|
+
db.prepare(
|
|
2168
|
+
`
|
|
1486
2169
|
insert into tweets (
|
|
1487
2170
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
1488
2171
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked
|
|
1489
2172
|
) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
|
|
1490
2173
|
`,
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
2174
|
+
).run(
|
|
2175
|
+
draft.replyId,
|
|
2176
|
+
accountId,
|
|
2177
|
+
draft.authorProfileId,
|
|
2178
|
+
text,
|
|
2179
|
+
draft.createdAt,
|
|
2180
|
+
tweetId,
|
|
2181
|
+
);
|
|
2182
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
2183
|
+
draft.replyId,
|
|
2184
|
+
text,
|
|
2185
|
+
);
|
|
1496
2186
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
2187
|
+
db.prepare(
|
|
2188
|
+
"insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
|
|
2189
|
+
).run(draft.actionId, accountId, tweetId, "reply", text, draft.createdAt);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
return Effect.gen(function* () {
|
|
2193
|
+
const draft = yield* trySync(() => {
|
|
2194
|
+
const replyDraft = prepareReplyDraft();
|
|
2195
|
+
preflightWrite((db) => writeReplyDraft(db, replyDraft));
|
|
2196
|
+
return replyDraft;
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
yield* verifySelectedXurlAccountEffect(accountId);
|
|
2200
|
+
const transport = yield* replyViaXurlEffect(tweetId, text);
|
|
2201
|
+
if (!transport.ok) {
|
|
2202
|
+
return yield* Effect.fail(new Error(transport.output || "reply failed"));
|
|
2203
|
+
}
|
|
2204
|
+
yield* trySync(() => persistWrite((db) => writeReplyDraft(db, draft)));
|
|
1500
2205
|
|
|
1501
|
-
|
|
1502
|
-
|
|
2206
|
+
return { ok: true, transport, replyId: draft.replyId };
|
|
2207
|
+
});
|
|
1503
2208
|
}
|
|
1504
2209
|
|
|
1505
|
-
export
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
conversation.conversation.accountId,
|
|
1513
|
-
);
|
|
1514
|
-
if (!authorProfileId) {
|
|
1515
|
-
throw new Error("No local author profile for account");
|
|
1516
|
-
}
|
|
2210
|
+
export function createTweetReply(
|
|
2211
|
+
accountId: string,
|
|
2212
|
+
tweetId: string,
|
|
2213
|
+
text: string,
|
|
2214
|
+
) {
|
|
2215
|
+
return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
|
|
2216
|
+
}
|
|
1517
2217
|
|
|
1518
|
-
|
|
1519
|
-
|
|
2218
|
+
export function createDmReplyEffect(conversationId: string, text: string) {
|
|
2219
|
+
return Effect.gen(function* () {
|
|
2220
|
+
const draft = yield* trySync(() => {
|
|
2221
|
+
const conversation = getConversationThread(conversationId);
|
|
2222
|
+
if (!conversation) {
|
|
2223
|
+
throw new Error("Conversation not found");
|
|
2224
|
+
}
|
|
2225
|
+
const authorProfileId = getLocalAuthorProfileId(
|
|
2226
|
+
conversation.conversation.accountId,
|
|
2227
|
+
);
|
|
2228
|
+
if (!authorProfileId) {
|
|
2229
|
+
throw new Error("No local author profile for account");
|
|
2230
|
+
}
|
|
1520
2231
|
|
|
1521
|
-
|
|
1522
|
-
|
|
2232
|
+
const dmDraft = {
|
|
2233
|
+
accountId: conversation.conversation.accountId,
|
|
2234
|
+
authorProfileId,
|
|
2235
|
+
createdAt: new Date().toISOString(),
|
|
2236
|
+
handle: conversation.conversation.participant.handle,
|
|
2237
|
+
observedLastMessageAt: conversation.conversation.lastMessageAt,
|
|
2238
|
+
outboundId: `msg_${randomUUID()}`,
|
|
2239
|
+
};
|
|
2240
|
+
preflightWrite((db) => {
|
|
2241
|
+
db.prepare(
|
|
2242
|
+
`
|
|
1523
2243
|
insert into dm_messages (
|
|
1524
2244
|
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
1525
2245
|
) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
|
|
1526
2246
|
`,
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
2247
|
+
).run(
|
|
2248
|
+
dmDraft.outboundId,
|
|
2249
|
+
conversationId,
|
|
2250
|
+
dmDraft.authorProfileId,
|
|
2251
|
+
text,
|
|
2252
|
+
dmDraft.createdAt,
|
|
2253
|
+
);
|
|
2254
|
+
db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
|
|
2255
|
+
dmDraft.outboundId,
|
|
2256
|
+
text,
|
|
2257
|
+
);
|
|
1532
2258
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
2259
|
+
refreshDmConversationState(
|
|
2260
|
+
db,
|
|
2261
|
+
conversationId,
|
|
2262
|
+
dmDraft.createdAt,
|
|
2263
|
+
dmDraft.observedLastMessageAt,
|
|
2264
|
+
);
|
|
2265
|
+
});
|
|
2266
|
+
return dmDraft;
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
yield* verifySelectedXurlAccountEffect(draft.accountId);
|
|
2270
|
+
const transport = yield* dmViaXurlEffect(draft.handle, text);
|
|
2271
|
+
if (!transport.ok) {
|
|
2272
|
+
return yield* Effect.fail(new Error(transport.output || "dm failed"));
|
|
2273
|
+
}
|
|
2274
|
+
yield* trySync(() =>
|
|
2275
|
+
persistWrite((db) => {
|
|
2276
|
+
db.prepare(
|
|
2277
|
+
`
|
|
2278
|
+
insert into dm_messages (
|
|
2279
|
+
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
2280
|
+
) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
|
|
2281
|
+
`,
|
|
2282
|
+
).run(
|
|
2283
|
+
draft.outboundId,
|
|
2284
|
+
conversationId,
|
|
2285
|
+
draft.authorProfileId,
|
|
2286
|
+
text,
|
|
2287
|
+
draft.createdAt,
|
|
2288
|
+
);
|
|
2289
|
+
db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
|
|
2290
|
+
draft.outboundId,
|
|
2291
|
+
text,
|
|
2292
|
+
);
|
|
2293
|
+
|
|
2294
|
+
refreshDmConversationState(
|
|
2295
|
+
db,
|
|
2296
|
+
conversationId,
|
|
2297
|
+
draft.createdAt,
|
|
2298
|
+
draft.observedLastMessageAt,
|
|
2299
|
+
);
|
|
2300
|
+
}),
|
|
2301
|
+
);
|
|
2302
|
+
|
|
2303
|
+
return { ok: true, transport, messageId: draft.outboundId };
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
export function createDmReply(conversationId: string, text: string) {
|
|
2308
|
+
return runEffectPromise(createDmReplyEffect(conversationId, text));
|
|
1539
2309
|
}
|