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