birdclaw 0.4.1 → 0.5.1
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 +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +326 -35
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
|
@@ -100,6 +100,12 @@ export async function resolveProfile(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const local = resolveLocalProfile(db, normalizedQuery);
|
|
103
|
+
if (process.env.BIRDCLAW_DISABLE_LIVE_PROFILE_LOOKUP === "1") {
|
|
104
|
+
if (local) {
|
|
105
|
+
return local;
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`Profile not found locally: ${query}`);
|
|
108
|
+
}
|
|
103
109
|
if (
|
|
104
110
|
local &&
|
|
105
111
|
!local.profile.id.startsWith("profile_group_") &&
|
|
@@ -44,7 +44,7 @@ export async function hydrateProfilesFromX() {
|
|
|
44
44
|
|
|
45
45
|
const candidateIds = candidateRows
|
|
46
46
|
.map((row) => row.id.replace(/^profile_user_/, ""))
|
|
47
|
-
.filter((id) => id
|
|
47
|
+
.filter((id) => /^\d+$/.test(id));
|
|
48
48
|
|
|
49
49
|
const updateConversationTitle = db.prepare(`
|
|
50
50
|
update dm_conversations
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./profile-affiliation-hydration";
|
|
8
8
|
import type { ProfileRecord, XurlMentionUser } from "./types";
|
|
9
9
|
import { getExternalUserId, upsertProfileFromXUser } from "./x-profile";
|
|
10
|
-
import { lookupUsersByIds } from "./xurl";
|
|
10
|
+
import { lookupUsersByHandles, lookupUsersByIds } from "./xurl";
|
|
11
11
|
|
|
12
12
|
const PROFILE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
13
13
|
const PROFILE_NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -39,6 +39,14 @@ export interface ProfileResolveResult {
|
|
|
39
39
|
error?: string;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export interface HandleProfileResolveResult {
|
|
43
|
+
handle: string;
|
|
44
|
+
status: ProfileLookupStatus;
|
|
45
|
+
source: Exclude<ProfileLookupSource, "local">;
|
|
46
|
+
profile?: ProfileRecord;
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
43
51
|
const followingCount = Number(row.following_count ?? 0);
|
|
44
52
|
let entities: Record<string, unknown> | undefined;
|
|
@@ -139,6 +147,10 @@ async function lookupViaXurl(externalUserId: string) {
|
|
|
139
147
|
return user ?? null;
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
function normalizeHandle(value: string) {
|
|
151
|
+
return value.trim().replace(/^@/, "").toLowerCase();
|
|
152
|
+
}
|
|
153
|
+
|
|
142
154
|
async function fetchProfileUser(
|
|
143
155
|
externalUserId: string,
|
|
144
156
|
xurlFallback: boolean,
|
|
@@ -387,6 +399,108 @@ export async function resolveProfilesForIds(
|
|
|
387
399
|
return results;
|
|
388
400
|
}
|
|
389
401
|
|
|
402
|
+
export async function resolveProfilesForHandles(
|
|
403
|
+
handles: string[],
|
|
404
|
+
options: Pick<ResolveProfilesOptions, "xurlFallback"> = {},
|
|
405
|
+
): Promise<HandleProfileResolveResult[]> {
|
|
406
|
+
const xurlFallback = options.xurlFallback ?? true;
|
|
407
|
+
const targets = Array.from(
|
|
408
|
+
new Set(handles.map(normalizeHandle).filter((handle) => handle.length > 0)),
|
|
409
|
+
);
|
|
410
|
+
if (targets.length === 0) {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const results = new Map<string, HandleProfileResolveResult>();
|
|
415
|
+
let unresolved = targets;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const birdResults = await lookupProfilesViaBird(targets);
|
|
419
|
+
for (const item of birdResults) {
|
|
420
|
+
const handle = normalizeHandle(item.target);
|
|
421
|
+
if (item.user) {
|
|
422
|
+
const resolved = upsertProfileFromXUser(getNativeDb(), item.user);
|
|
423
|
+
updateConversationTitles(resolved.profile);
|
|
424
|
+
results.set(handle, {
|
|
425
|
+
handle,
|
|
426
|
+
status: "hit",
|
|
427
|
+
source: "bird",
|
|
428
|
+
profile: resolved.profile,
|
|
429
|
+
});
|
|
430
|
+
} else if (item.error && !xurlFallback) {
|
|
431
|
+
results.set(handle, {
|
|
432
|
+
handle,
|
|
433
|
+
status: "error",
|
|
434
|
+
source: "bird",
|
|
435
|
+
error: item.error,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
unresolved = targets.filter((handle) => !results.has(handle));
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (!xurlFallback) {
|
|
442
|
+
for (const handle of targets) {
|
|
443
|
+
results.set(handle, {
|
|
444
|
+
handle,
|
|
445
|
+
status: "error",
|
|
446
|
+
source: "bird",
|
|
447
|
+
error: error instanceof Error ? error.message : String(error),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
unresolved = [];
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (unresolved.length > 0 && xurlFallback) {
|
|
455
|
+
try {
|
|
456
|
+
const users = await lookupUsersByHandles(unresolved);
|
|
457
|
+
const usersByHandle = new Map(
|
|
458
|
+
users.map((user) => [
|
|
459
|
+
normalizeHandle(String(user.username ?? "")),
|
|
460
|
+
user,
|
|
461
|
+
]),
|
|
462
|
+
);
|
|
463
|
+
for (const handle of unresolved) {
|
|
464
|
+
const user = usersByHandle.get(handle);
|
|
465
|
+
if (user) {
|
|
466
|
+
const resolved = upsertProfileFromXUser(getNativeDb(), user);
|
|
467
|
+
updateConversationTitles(resolved.profile);
|
|
468
|
+
results.set(handle, {
|
|
469
|
+
handle,
|
|
470
|
+
status: "hit",
|
|
471
|
+
source: "xurl",
|
|
472
|
+
profile: resolved.profile,
|
|
473
|
+
});
|
|
474
|
+
} else {
|
|
475
|
+
results.set(handle, {
|
|
476
|
+
handle,
|
|
477
|
+
status: "miss",
|
|
478
|
+
source: "xurl",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
for (const handle of unresolved) {
|
|
484
|
+
results.set(handle, {
|
|
485
|
+
handle,
|
|
486
|
+
status: "error",
|
|
487
|
+
source: "xurl",
|
|
488
|
+
error: error instanceof Error ? error.message : String(error),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return targets.map(
|
|
495
|
+
(handle) =>
|
|
496
|
+
results.get(handle) ?? {
|
|
497
|
+
handle,
|
|
498
|
+
status: "miss",
|
|
499
|
+
source: "bird",
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
390
504
|
export async function resolvePlaceholderProfiles(
|
|
391
505
|
options: ResolveProfilesOptions & { limit?: number } = {},
|
|
392
506
|
) {
|
package/src/lib/queries.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Database } from "./sqlite";
|
|
|
3
3
|
import { findArchives } from "./archive-finder";
|
|
4
4
|
import { getDb, getNativeDb } from "./db";
|
|
5
5
|
import { fetchProfileAffiliations } from "./profile-affiliations";
|
|
6
|
+
import { displayUrlForLink, enrichFallbackUrlEntities } from "./tweet-render";
|
|
6
7
|
import type {
|
|
7
8
|
AccountRecord,
|
|
8
9
|
DmConversationItem,
|
|
@@ -17,7 +18,9 @@ import type {
|
|
|
17
18
|
TimelineItem,
|
|
18
19
|
TimelineQuery,
|
|
19
20
|
TweetEntities,
|
|
21
|
+
TweetConversationResponse,
|
|
20
22
|
TweetMediaItem,
|
|
23
|
+
TweetUrlEntity,
|
|
21
24
|
} from "./types";
|
|
22
25
|
import {
|
|
23
26
|
dmViaXurl,
|
|
@@ -107,7 +110,78 @@ function enrichEntities(
|
|
|
107
110
|
};
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
type UrlExpansionCache = Map<
|
|
114
|
+
string,
|
|
115
|
+
| (Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
|
|
116
|
+
Partial<
|
|
117
|
+
Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
|
|
118
|
+
>)
|
|
119
|
+
| null
|
|
120
|
+
>;
|
|
121
|
+
|
|
122
|
+
function getUrlExpansion(
|
|
123
|
+
db: Database,
|
|
124
|
+
cache: UrlExpansionCache,
|
|
125
|
+
rawUrl: string,
|
|
126
|
+
) {
|
|
127
|
+
if (cache.has(rawUrl)) {
|
|
128
|
+
return cache.get(rawUrl);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const row = db
|
|
132
|
+
.prepare(
|
|
133
|
+
`
|
|
134
|
+
select expanded_url, final_url, title, description, image_url, site_name
|
|
135
|
+
from url_expansions
|
|
136
|
+
where short_url = ?
|
|
137
|
+
and status = 'hit'
|
|
138
|
+
`,
|
|
139
|
+
)
|
|
140
|
+
.get(rawUrl) as
|
|
141
|
+
| {
|
|
142
|
+
expanded_url: string;
|
|
143
|
+
final_url: string;
|
|
144
|
+
title: string | null;
|
|
145
|
+
description: string | null;
|
|
146
|
+
image_url: string | null;
|
|
147
|
+
site_name: string | null;
|
|
148
|
+
}
|
|
149
|
+
| undefined;
|
|
150
|
+
if (!row) {
|
|
151
|
+
cache.set(rawUrl, null);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const expandedUrl = row.final_url || row.expanded_url || rawUrl;
|
|
156
|
+
const expansion = {
|
|
157
|
+
expandedUrl,
|
|
158
|
+
displayUrl: displayUrlForLink(expandedUrl),
|
|
159
|
+
...(row.title ? { title: row.title } : {}),
|
|
160
|
+
...(row.description ? { description: row.description } : {}),
|
|
161
|
+
...(row.image_url ? { imageUrl: row.image_url } : {}),
|
|
162
|
+
...(row.site_name ? { siteName: row.site_name } : {}),
|
|
163
|
+
};
|
|
164
|
+
cache.set(rawUrl, expansion);
|
|
165
|
+
return expansion;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function enrichTimelineEntities(
|
|
169
|
+
db: Database,
|
|
170
|
+
urlExpansionCache: UrlExpansionCache,
|
|
171
|
+
text: string,
|
|
172
|
+
entities: TweetEntities,
|
|
173
|
+
profiles: Record<string, ProfileRecord>,
|
|
174
|
+
): TweetEntities {
|
|
175
|
+
return enrichFallbackUrlEntities(
|
|
176
|
+
text,
|
|
177
|
+
enrichEntities(entities, profiles),
|
|
178
|
+
(rawUrl) => getUrlExpansion(db, urlExpansionCache, rawUrl),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
110
182
|
function buildEmbeddedTweet(
|
|
183
|
+
db: Database,
|
|
184
|
+
urlExpansionCache: UrlExpansionCache,
|
|
111
185
|
row: Record<string, unknown>,
|
|
112
186
|
prefix: string,
|
|
113
187
|
): EmbeddedTweet | null {
|
|
@@ -127,12 +201,20 @@ function buildEmbeddedTweet(
|
|
|
127
201
|
created_at: row[`${prefix}profile_created_at`],
|
|
128
202
|
});
|
|
129
203
|
|
|
204
|
+
const text = String(row[`${prefix}text`] ?? "");
|
|
130
205
|
return {
|
|
131
206
|
id: String(row[`${prefix}id`]),
|
|
132
|
-
text
|
|
207
|
+
text,
|
|
133
208
|
createdAt: String(row[`${prefix}created_at`] ?? new Date(0).toISOString()),
|
|
209
|
+
replyToId:
|
|
210
|
+
typeof row[`${prefix}reply_to_id`] === "string"
|
|
211
|
+
? String(row[`${prefix}reply_to_id`])
|
|
212
|
+
: null,
|
|
134
213
|
author,
|
|
135
|
-
entities:
|
|
214
|
+
entities: enrichTimelineEntities(
|
|
215
|
+
db,
|
|
216
|
+
urlExpansionCache,
|
|
217
|
+
text,
|
|
136
218
|
parseJsonField<TweetEntities>(row[`${prefix}entities_json`], {}),
|
|
137
219
|
{
|
|
138
220
|
[author.id]: author,
|
|
@@ -239,32 +321,39 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
|
|
|
239
321
|
const row = db
|
|
240
322
|
.prepare(
|
|
241
323
|
`
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
324
|
+
select (
|
|
325
|
+
(
|
|
326
|
+
select count(*)
|
|
327
|
+
from tweet_account_edges edge
|
|
328
|
+
where edge.kind = ?
|
|
329
|
+
and exists (
|
|
330
|
+
select 1
|
|
331
|
+
from tweets t
|
|
332
|
+
where t.id = edge.tweet_id
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
+
|
|
336
|
+
(
|
|
337
|
+
select count(*)
|
|
338
|
+
from tweets legacy
|
|
339
|
+
where legacy.kind = ?
|
|
340
|
+
and not exists (
|
|
341
|
+
select 1
|
|
342
|
+
from tweet_account_edges edge
|
|
343
|
+
where edge.account_id = legacy.account_id
|
|
344
|
+
and edge.tweet_id = legacy.id
|
|
345
|
+
and edge.kind = legacy.kind
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
) as count
|
|
262
349
|
`,
|
|
263
350
|
)
|
|
264
|
-
.get(kind, kind
|
|
351
|
+
.get(kind, kind) as { count: number | bigint } | undefined;
|
|
265
352
|
return Number(row?.count ?? 0);
|
|
266
353
|
}
|
|
267
354
|
|
|
355
|
+
const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
|
|
356
|
+
|
|
268
357
|
export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
269
358
|
const db = getDb();
|
|
270
359
|
const nativeDb = getNativeDb();
|
|
@@ -350,10 +439,23 @@ export function listTimelineItems({
|
|
|
350
439
|
)
|
|
351
440
|
)
|
|
352
441
|
`;
|
|
442
|
+
const unwindowedTimelineEdgesCte = timelineEdgesCte;
|
|
443
|
+
let usedRecentEdgeWindow = false;
|
|
353
444
|
let join = "";
|
|
354
445
|
let where = "where t.kind = ?";
|
|
355
446
|
let searchSnippetSelect = "";
|
|
356
447
|
|
|
448
|
+
const canUseRecentEdgeWindow =
|
|
449
|
+
!likedOnly &&
|
|
450
|
+
!bookmarkedOnly &&
|
|
451
|
+
!account &&
|
|
452
|
+
!search?.trim() &&
|
|
453
|
+
replyFilter === "all" &&
|
|
454
|
+
!since?.trim() &&
|
|
455
|
+
!until?.trim() &&
|
|
456
|
+
includeReplies &&
|
|
457
|
+
qualityFilter === "all";
|
|
458
|
+
|
|
357
459
|
if (likedOnly || bookmarkedOnly) {
|
|
358
460
|
if (likedOnly && bookmarkedOnly) {
|
|
359
461
|
timelineEdgesCte = `
|
|
@@ -399,10 +501,49 @@ export function listTimelineItems({
|
|
|
399
501
|
and collection.kind = ?
|
|
400
502
|
)
|
|
401
503
|
)
|
|
402
|
-
|
|
504
|
+
`;
|
|
403
505
|
params.push(collectionKind, collectionKind);
|
|
404
506
|
}
|
|
405
507
|
where = "where 1 = 1";
|
|
508
|
+
} else if (canUseRecentEdgeWindow) {
|
|
509
|
+
usedRecentEdgeWindow = true;
|
|
510
|
+
timelineEdgesCte = `
|
|
511
|
+
with timeline_edges as (
|
|
512
|
+
select account_id, tweet_id, kind
|
|
513
|
+
from tweet_account_edges
|
|
514
|
+
where kind = ?
|
|
515
|
+
and tweet_id in (
|
|
516
|
+
select id
|
|
517
|
+
from tweets
|
|
518
|
+
order by created_at desc
|
|
519
|
+
limit ?
|
|
520
|
+
)
|
|
521
|
+
union all
|
|
522
|
+
select legacy.account_id, legacy.id as tweet_id, legacy.kind
|
|
523
|
+
from tweets legacy
|
|
524
|
+
where legacy.kind = ?
|
|
525
|
+
and legacy.id in (
|
|
526
|
+
select id
|
|
527
|
+
from tweets
|
|
528
|
+
order by created_at desc
|
|
529
|
+
limit ?
|
|
530
|
+
)
|
|
531
|
+
and not exists (
|
|
532
|
+
select 1
|
|
533
|
+
from tweet_account_edges edge
|
|
534
|
+
where edge.account_id = legacy.account_id
|
|
535
|
+
and edge.tweet_id = legacy.id
|
|
536
|
+
and edge.kind = legacy.kind
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
`;
|
|
540
|
+
const candidateLimit = Math.max(
|
|
541
|
+
RECENT_TIMELINE_EDGE_CANDIDATES,
|
|
542
|
+
limit * 50,
|
|
543
|
+
);
|
|
544
|
+
params.push(kind, candidateLimit, kind, candidateLimit);
|
|
545
|
+
where = "where e.kind = ?";
|
|
546
|
+
params.push(kind);
|
|
406
547
|
} else {
|
|
407
548
|
params.push(kind, kind);
|
|
408
549
|
where = "where e.kind = ?";
|
|
@@ -450,10 +591,8 @@ export function listTimelineItems({
|
|
|
450
591
|
|
|
451
592
|
params.push(limit);
|
|
452
593
|
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
`
|
|
456
|
-
${timelineEdgesCte}
|
|
594
|
+
const buildTimelineSelectSql = (timelineEdgesSql: string) => `
|
|
595
|
+
${timelineEdgesSql}
|
|
457
596
|
select
|
|
458
597
|
t.id,
|
|
459
598
|
e.account_id,
|
|
@@ -461,6 +600,7 @@ export function listTimelineItems({
|
|
|
461
600
|
e.kind,
|
|
462
601
|
t.text,
|
|
463
602
|
t.created_at,
|
|
603
|
+
t.reply_to_id,
|
|
464
604
|
t.is_replied,
|
|
465
605
|
t.like_count,
|
|
466
606
|
t.media_count,
|
|
@@ -503,6 +643,7 @@ export function listTimelineItems({
|
|
|
503
643
|
rt.id as reply_id,
|
|
504
644
|
rt.text as reply_text,
|
|
505
645
|
rt.created_at as reply_created_at,
|
|
646
|
+
rt.reply_to_id as reply_reply_to_id,
|
|
506
647
|
rt.entities_json as reply_entities_json,
|
|
507
648
|
rt.media_json as reply_media_json,
|
|
508
649
|
rp.id as reply_profile_id,
|
|
@@ -517,6 +658,7 @@ export function listTimelineItems({
|
|
|
517
658
|
qt.id as quoted_id,
|
|
518
659
|
qt.text as quoted_text,
|
|
519
660
|
qt.created_at as quoted_created_at,
|
|
661
|
+
qt.reply_to_id as quoted_reply_to_id,
|
|
520
662
|
qt.entities_json as quoted_entities_json,
|
|
521
663
|
qt.media_json as quoted_media_json,
|
|
522
664
|
qp.id as quoted_profile_id,
|
|
@@ -541,10 +683,19 @@ export function listTimelineItems({
|
|
|
541
683
|
${where}
|
|
542
684
|
order by t.created_at desc
|
|
543
685
|
limit ?
|
|
544
|
-
|
|
545
|
-
|
|
686
|
+
`;
|
|
687
|
+
|
|
688
|
+
let rows = db
|
|
689
|
+
.prepare(buildTimelineSelectSql(timelineEdgesCte))
|
|
546
690
|
.all(...params) as Array<Record<string, unknown>>;
|
|
547
691
|
|
|
692
|
+
if (usedRecentEdgeWindow && rows.length < limit) {
|
|
693
|
+
rows = db
|
|
694
|
+
.prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
|
|
695
|
+
.all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
548
699
|
return rows.map((row) => {
|
|
549
700
|
const author = {
|
|
550
701
|
id: String(row.profile_id),
|
|
@@ -558,7 +709,11 @@ export function listTimelineItems({
|
|
|
558
709
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
559
710
|
createdAt: String(row.profile_created_at),
|
|
560
711
|
};
|
|
561
|
-
const
|
|
712
|
+
const text = String(row.text);
|
|
713
|
+
const entities = enrichTimelineEntities(
|
|
714
|
+
db,
|
|
715
|
+
urlExpansionCache,
|
|
716
|
+
text,
|
|
562
717
|
parseJsonField<TweetEntities>(row.entities_json, {}),
|
|
563
718
|
{
|
|
564
719
|
[author.id]: author,
|
|
@@ -599,11 +754,13 @@ export function listTimelineItems({
|
|
|
599
754
|
accountId: String(row.account_id),
|
|
600
755
|
accountHandle: String(row.account_handle),
|
|
601
756
|
kind: row.kind as TimelineItem["kind"],
|
|
602
|
-
text
|
|
757
|
+
text,
|
|
603
758
|
...(typeof row.search_snippet === "string"
|
|
604
759
|
? { searchSnippet: row.search_snippet }
|
|
605
760
|
: {}),
|
|
606
761
|
createdAt: String(row.created_at),
|
|
762
|
+
replyToId:
|
|
763
|
+
typeof row.reply_to_id === "string" ? String(row.reply_to_id) : null,
|
|
607
764
|
isReplied: Boolean(row.is_replied),
|
|
608
765
|
likeCount: Number(row.like_count),
|
|
609
766
|
mediaCount: Number(row.media_count),
|
|
@@ -612,8 +769,8 @@ export function listTimelineItems({
|
|
|
612
769
|
author,
|
|
613
770
|
entities,
|
|
614
771
|
media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
|
|
615
|
-
replyToTweet: buildEmbeddedTweet(row, "reply_"),
|
|
616
|
-
quotedTweet: buildEmbeddedTweet(row, "quoted_"),
|
|
772
|
+
replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
|
|
773
|
+
quotedTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "quoted_"),
|
|
617
774
|
};
|
|
618
775
|
return includeQualityReason
|
|
619
776
|
? {
|
|
@@ -627,6 +784,140 @@ export function listTimelineItems({
|
|
|
627
784
|
});
|
|
628
785
|
}
|
|
629
786
|
|
|
787
|
+
const conversationTweetSelect = `
|
|
788
|
+
select
|
|
789
|
+
t.id,
|
|
790
|
+
t.text,
|
|
791
|
+
t.created_at,
|
|
792
|
+
t.reply_to_id,
|
|
793
|
+
t.entities_json,
|
|
794
|
+
t.media_json,
|
|
795
|
+
p.id as profile_id,
|
|
796
|
+
p.handle,
|
|
797
|
+
p.display_name,
|
|
798
|
+
p.bio,
|
|
799
|
+
p.followers_count,
|
|
800
|
+
p.following_count,
|
|
801
|
+
p.avatar_hue,
|
|
802
|
+
p.avatar_url,
|
|
803
|
+
p.created_at as profile_created_at
|
|
804
|
+
from tweets t
|
|
805
|
+
join profiles p on p.id = t.author_profile_id
|
|
806
|
+
`;
|
|
807
|
+
|
|
808
|
+
function getTweetById(
|
|
809
|
+
db: Database,
|
|
810
|
+
urlExpansionCache: UrlExpansionCache,
|
|
811
|
+
tweetId: string,
|
|
812
|
+
): EmbeddedTweet | null {
|
|
813
|
+
const row = db
|
|
814
|
+
.prepare(`${conversationTweetSelect} where t.id = ?`)
|
|
815
|
+
.get(tweetId) as Record<string, unknown> | undefined;
|
|
816
|
+
if (!row) return null;
|
|
817
|
+
return buildEmbeddedTweet(db, urlExpansionCache, row, "");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function listTweetDescendants(
|
|
821
|
+
db: Database,
|
|
822
|
+
urlExpansionCache: UrlExpansionCache,
|
|
823
|
+
rootId: string,
|
|
824
|
+
limit: number,
|
|
825
|
+
) {
|
|
826
|
+
if (limit <= 0) return [];
|
|
827
|
+
const rows = db
|
|
828
|
+
.prepare(
|
|
829
|
+
`
|
|
830
|
+
with recursive branch(id, depth) as (
|
|
831
|
+
select t.id, 0
|
|
832
|
+
from tweets t
|
|
833
|
+
where t.id = ?
|
|
834
|
+
union all
|
|
835
|
+
select child.id, branch.depth + 1
|
|
836
|
+
from tweets child
|
|
837
|
+
join branch on child.reply_to_id = branch.id
|
|
838
|
+
where branch.depth < 8
|
|
839
|
+
)
|
|
840
|
+
${conversationTweetSelect}
|
|
841
|
+
join branch on branch.id = t.id
|
|
842
|
+
where t.id != ?
|
|
843
|
+
order by t.created_at asc
|
|
844
|
+
limit ?
|
|
845
|
+
`,
|
|
846
|
+
)
|
|
847
|
+
.all(rootId, rootId, limit) as Array<Record<string, unknown>>;
|
|
848
|
+
|
|
849
|
+
return rows
|
|
850
|
+
.map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
|
|
851
|
+
.filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function appendConversationTweets(
|
|
855
|
+
target: EmbeddedTweet[],
|
|
856
|
+
seen: Set<string>,
|
|
857
|
+
items: EmbeddedTweet[],
|
|
858
|
+
remaining: number,
|
|
859
|
+
) {
|
|
860
|
+
for (const tweet of items) {
|
|
861
|
+
if (target.length >= remaining || seen.has(tweet.id)) continue;
|
|
862
|
+
seen.add(tweet.id);
|
|
863
|
+
target.push(tweet);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export function getTweetConversation(
|
|
868
|
+
tweetId: string,
|
|
869
|
+
limit = 80,
|
|
870
|
+
): TweetConversationResponse | null {
|
|
871
|
+
const db = getNativeDb();
|
|
872
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
873
|
+
const anchor = getTweetById(db, urlExpansionCache, tweetId);
|
|
874
|
+
if (!anchor) return null;
|
|
875
|
+
|
|
876
|
+
const ancestors: EmbeddedTweet[] = [];
|
|
877
|
+
let current = anchor;
|
|
878
|
+
for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
|
|
879
|
+
const parent = getTweetById(db, urlExpansionCache, current.replyToId);
|
|
880
|
+
if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
|
|
881
|
+
ancestors.push(parent);
|
|
882
|
+
current = parent;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const required = [...ancestors].reverse();
|
|
886
|
+
required.push(anchor);
|
|
887
|
+
const root = required[0] ?? anchor;
|
|
888
|
+
const seen = new Set<string>();
|
|
889
|
+
const items = required.filter((tweet) => {
|
|
890
|
+
if (seen.has(tweet.id)) return false;
|
|
891
|
+
seen.add(tweet.id);
|
|
892
|
+
return true;
|
|
893
|
+
});
|
|
894
|
+
const remainingAfterRequired = Math.max(0, limit - items.length);
|
|
895
|
+
const focusedDescendants = listTweetDescendants(
|
|
896
|
+
db,
|
|
897
|
+
urlExpansionCache,
|
|
898
|
+
anchor.id,
|
|
899
|
+
remainingAfterRequired,
|
|
900
|
+
);
|
|
901
|
+
appendConversationTweets(items, seen, focusedDescendants, limit);
|
|
902
|
+
|
|
903
|
+
if (items.length < limit && root.id !== anchor.id) {
|
|
904
|
+
const ambientDescendants = listTweetDescendants(
|
|
905
|
+
db,
|
|
906
|
+
urlExpansionCache,
|
|
907
|
+
root.id,
|
|
908
|
+
limit,
|
|
909
|
+
);
|
|
910
|
+
appendConversationTweets(items, seen, ambientDescendants, limit);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
anchorId: anchor.id,
|
|
917
|
+
items,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
630
921
|
export function listDmConversations({
|
|
631
922
|
account,
|
|
632
923
|
conversationIds,
|
|
@@ -1084,7 +1375,7 @@ function getDmSearchMatches({
|
|
|
1084
1375
|
}
|
|
1085
1376
|
|
|
1086
1377
|
export function queryResource(
|
|
1087
|
-
resource: "home" | "mentions" | "dms",
|
|
1378
|
+
resource: "home" | "mentions" | "authored" | "dms",
|
|
1088
1379
|
filters: (TimelineQuery | DmQuery) & { conversationId?: string },
|
|
1089
1380
|
): QueryResponse {
|
|
1090
1381
|
if (resource === "dms") {
|