birdclaw 0.4.1 → 0.5.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 +33 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -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/src/cli.ts +450 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1565 -65
- 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/db.ts +191 -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 +233 -7
- 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 +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -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/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- package/src/styles.css +106 -43
|
@@ -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,
|
|
@@ -461,6 +543,7 @@ export function listTimelineItems({
|
|
|
461
543
|
e.kind,
|
|
462
544
|
t.text,
|
|
463
545
|
t.created_at,
|
|
546
|
+
t.reply_to_id,
|
|
464
547
|
t.is_replied,
|
|
465
548
|
t.like_count,
|
|
466
549
|
t.media_count,
|
|
@@ -503,6 +586,7 @@ export function listTimelineItems({
|
|
|
503
586
|
rt.id as reply_id,
|
|
504
587
|
rt.text as reply_text,
|
|
505
588
|
rt.created_at as reply_created_at,
|
|
589
|
+
rt.reply_to_id as reply_reply_to_id,
|
|
506
590
|
rt.entities_json as reply_entities_json,
|
|
507
591
|
rt.media_json as reply_media_json,
|
|
508
592
|
rp.id as reply_profile_id,
|
|
@@ -517,6 +601,7 @@ export function listTimelineItems({
|
|
|
517
601
|
qt.id as quoted_id,
|
|
518
602
|
qt.text as quoted_text,
|
|
519
603
|
qt.created_at as quoted_created_at,
|
|
604
|
+
qt.reply_to_id as quoted_reply_to_id,
|
|
520
605
|
qt.entities_json as quoted_entities_json,
|
|
521
606
|
qt.media_json as quoted_media_json,
|
|
522
607
|
qp.id as quoted_profile_id,
|
|
@@ -545,6 +630,7 @@ export function listTimelineItems({
|
|
|
545
630
|
)
|
|
546
631
|
.all(...params) as Array<Record<string, unknown>>;
|
|
547
632
|
|
|
633
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
548
634
|
return rows.map((row) => {
|
|
549
635
|
const author = {
|
|
550
636
|
id: String(row.profile_id),
|
|
@@ -558,7 +644,11 @@ export function listTimelineItems({
|
|
|
558
644
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
559
645
|
createdAt: String(row.profile_created_at),
|
|
560
646
|
};
|
|
561
|
-
const
|
|
647
|
+
const text = String(row.text);
|
|
648
|
+
const entities = enrichTimelineEntities(
|
|
649
|
+
db,
|
|
650
|
+
urlExpansionCache,
|
|
651
|
+
text,
|
|
562
652
|
parseJsonField<TweetEntities>(row.entities_json, {}),
|
|
563
653
|
{
|
|
564
654
|
[author.id]: author,
|
|
@@ -599,11 +689,13 @@ export function listTimelineItems({
|
|
|
599
689
|
accountId: String(row.account_id),
|
|
600
690
|
accountHandle: String(row.account_handle),
|
|
601
691
|
kind: row.kind as TimelineItem["kind"],
|
|
602
|
-
text
|
|
692
|
+
text,
|
|
603
693
|
...(typeof row.search_snippet === "string"
|
|
604
694
|
? { searchSnippet: row.search_snippet }
|
|
605
695
|
: {}),
|
|
606
696
|
createdAt: String(row.created_at),
|
|
697
|
+
replyToId:
|
|
698
|
+
typeof row.reply_to_id === "string" ? String(row.reply_to_id) : null,
|
|
607
699
|
isReplied: Boolean(row.is_replied),
|
|
608
700
|
likeCount: Number(row.like_count),
|
|
609
701
|
mediaCount: Number(row.media_count),
|
|
@@ -612,8 +704,8 @@ export function listTimelineItems({
|
|
|
612
704
|
author,
|
|
613
705
|
entities,
|
|
614
706
|
media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
|
|
615
|
-
replyToTweet: buildEmbeddedTweet(row, "reply_"),
|
|
616
|
-
quotedTweet: buildEmbeddedTweet(row, "quoted_"),
|
|
707
|
+
replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
|
|
708
|
+
quotedTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "quoted_"),
|
|
617
709
|
};
|
|
618
710
|
return includeQualityReason
|
|
619
711
|
? {
|
|
@@ -627,6 +719,140 @@ export function listTimelineItems({
|
|
|
627
719
|
});
|
|
628
720
|
}
|
|
629
721
|
|
|
722
|
+
const conversationTweetSelect = `
|
|
723
|
+
select
|
|
724
|
+
t.id,
|
|
725
|
+
t.text,
|
|
726
|
+
t.created_at,
|
|
727
|
+
t.reply_to_id,
|
|
728
|
+
t.entities_json,
|
|
729
|
+
t.media_json,
|
|
730
|
+
p.id as profile_id,
|
|
731
|
+
p.handle,
|
|
732
|
+
p.display_name,
|
|
733
|
+
p.bio,
|
|
734
|
+
p.followers_count,
|
|
735
|
+
p.following_count,
|
|
736
|
+
p.avatar_hue,
|
|
737
|
+
p.avatar_url,
|
|
738
|
+
p.created_at as profile_created_at
|
|
739
|
+
from tweets t
|
|
740
|
+
join profiles p on p.id = t.author_profile_id
|
|
741
|
+
`;
|
|
742
|
+
|
|
743
|
+
function getTweetById(
|
|
744
|
+
db: Database,
|
|
745
|
+
urlExpansionCache: UrlExpansionCache,
|
|
746
|
+
tweetId: string,
|
|
747
|
+
): EmbeddedTweet | null {
|
|
748
|
+
const row = db
|
|
749
|
+
.prepare(`${conversationTweetSelect} where t.id = ?`)
|
|
750
|
+
.get(tweetId) as Record<string, unknown> | undefined;
|
|
751
|
+
if (!row) return null;
|
|
752
|
+
return buildEmbeddedTweet(db, urlExpansionCache, row, "");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function listTweetDescendants(
|
|
756
|
+
db: Database,
|
|
757
|
+
urlExpansionCache: UrlExpansionCache,
|
|
758
|
+
rootId: string,
|
|
759
|
+
limit: number,
|
|
760
|
+
) {
|
|
761
|
+
if (limit <= 0) return [];
|
|
762
|
+
const rows = db
|
|
763
|
+
.prepare(
|
|
764
|
+
`
|
|
765
|
+
with recursive branch(id, depth) as (
|
|
766
|
+
select t.id, 0
|
|
767
|
+
from tweets t
|
|
768
|
+
where t.id = ?
|
|
769
|
+
union all
|
|
770
|
+
select child.id, branch.depth + 1
|
|
771
|
+
from tweets child
|
|
772
|
+
join branch on child.reply_to_id = branch.id
|
|
773
|
+
where branch.depth < 8
|
|
774
|
+
)
|
|
775
|
+
${conversationTweetSelect}
|
|
776
|
+
join branch on branch.id = t.id
|
|
777
|
+
where t.id != ?
|
|
778
|
+
order by t.created_at asc
|
|
779
|
+
limit ?
|
|
780
|
+
`,
|
|
781
|
+
)
|
|
782
|
+
.all(rootId, rootId, limit) as Array<Record<string, unknown>>;
|
|
783
|
+
|
|
784
|
+
return rows
|
|
785
|
+
.map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
|
|
786
|
+
.filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function appendConversationTweets(
|
|
790
|
+
target: EmbeddedTweet[],
|
|
791
|
+
seen: Set<string>,
|
|
792
|
+
items: EmbeddedTweet[],
|
|
793
|
+
remaining: number,
|
|
794
|
+
) {
|
|
795
|
+
for (const tweet of items) {
|
|
796
|
+
if (target.length >= remaining || seen.has(tweet.id)) continue;
|
|
797
|
+
seen.add(tweet.id);
|
|
798
|
+
target.push(tweet);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function getTweetConversation(
|
|
803
|
+
tweetId: string,
|
|
804
|
+
limit = 80,
|
|
805
|
+
): TweetConversationResponse | null {
|
|
806
|
+
const db = getNativeDb();
|
|
807
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
808
|
+
const anchor = getTweetById(db, urlExpansionCache, tweetId);
|
|
809
|
+
if (!anchor) return null;
|
|
810
|
+
|
|
811
|
+
const ancestors: EmbeddedTweet[] = [];
|
|
812
|
+
let current = anchor;
|
|
813
|
+
for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
|
|
814
|
+
const parent = getTweetById(db, urlExpansionCache, current.replyToId);
|
|
815
|
+
if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
|
|
816
|
+
ancestors.push(parent);
|
|
817
|
+
current = parent;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const required = [...ancestors].reverse();
|
|
821
|
+
required.push(anchor);
|
|
822
|
+
const root = required[0] ?? anchor;
|
|
823
|
+
const seen = new Set<string>();
|
|
824
|
+
const items = required.filter((tweet) => {
|
|
825
|
+
if (seen.has(tweet.id)) return false;
|
|
826
|
+
seen.add(tweet.id);
|
|
827
|
+
return true;
|
|
828
|
+
});
|
|
829
|
+
const remainingAfterRequired = Math.max(0, limit - items.length);
|
|
830
|
+
const focusedDescendants = listTweetDescendants(
|
|
831
|
+
db,
|
|
832
|
+
urlExpansionCache,
|
|
833
|
+
anchor.id,
|
|
834
|
+
remainingAfterRequired,
|
|
835
|
+
);
|
|
836
|
+
appendConversationTweets(items, seen, focusedDescendants, limit);
|
|
837
|
+
|
|
838
|
+
if (items.length < limit && root.id !== anchor.id) {
|
|
839
|
+
const ambientDescendants = listTweetDescendants(
|
|
840
|
+
db,
|
|
841
|
+
urlExpansionCache,
|
|
842
|
+
root.id,
|
|
843
|
+
limit,
|
|
844
|
+
);
|
|
845
|
+
appendConversationTweets(items, seen, ambientDescendants, limit);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
anchorId: anchor.id,
|
|
852
|
+
items,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
630
856
|
export function listDmConversations({
|
|
631
857
|
account,
|
|
632
858
|
conversationIds,
|
|
@@ -1084,7 +1310,7 @@ function getDmSearchMatches({
|
|
|
1084
1310
|
}
|
|
1085
1311
|
|
|
1086
1312
|
export function queryResource(
|
|
1087
|
-
resource: "home" | "mentions" | "dms",
|
|
1313
|
+
resource: "home" | "mentions" | "authored" | "dms",
|
|
1088
1314
|
filters: (TimelineQuery | DmQuery) & { conversationId?: string },
|
|
1089
1315
|
): QueryResponse {
|
|
1090
1316
|
if (resource === "dms") {
|
package/src/lib/seed.ts
CHANGED
|
@@ -32,6 +32,9 @@ export function seedDemoData(db: Database) {
|
|
|
32
32
|
if (accountCount.count > 0) {
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
const linkNow = new Date();
|
|
36
|
+
const linkMinutesAgo = (minutes: number) =>
|
|
37
|
+
new Date(linkNow.getTime() - minutes * 60_000).toISOString();
|
|
35
38
|
|
|
36
39
|
const insertAccount = db.prepare(`
|
|
37
40
|
insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
|
|
@@ -75,6 +78,24 @@ export function seedDemoData(db: Database) {
|
|
|
75
78
|
const insertDmFts = db.prepare(
|
|
76
79
|
"insert into dm_fts (message_id, text) values (?, ?)",
|
|
77
80
|
);
|
|
81
|
+
const insertUrlExpansion = db.prepare(`
|
|
82
|
+
insert into url_expansions (
|
|
83
|
+
short_url, expanded_url, final_url, status, expanded_tweet_id,
|
|
84
|
+
expanded_handle, title, description, image_url, site_name, error, source, updated_at
|
|
85
|
+
) values (
|
|
86
|
+
@shortUrl, @expandedUrl, @finalUrl, @status, @expandedTweetId,
|
|
87
|
+
@expandedHandle, @title, @description, @imageUrl, @siteName, @error, @source, @updatedAt
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
const insertLinkOccurrence = db.prepare(`
|
|
91
|
+
insert into link_occurrences (
|
|
92
|
+
source_kind, source_id, source_position, short_url, account_id,
|
|
93
|
+
conversation_id, direction, created_at
|
|
94
|
+
) values (
|
|
95
|
+
@sourceKind, @sourceId, @sourcePosition, @shortUrl, @accountId,
|
|
96
|
+
@conversationId, @direction, @createdAt
|
|
97
|
+
)
|
|
98
|
+
`);
|
|
78
99
|
|
|
79
100
|
const accounts = [
|
|
80
101
|
{
|
|
@@ -172,8 +193,8 @@ export function seedDemoData(db: Database) {
|
|
|
172
193
|
accountId: "acct_primary",
|
|
173
194
|
authorProfileId: "profile_sam",
|
|
174
195
|
kind: "home",
|
|
175
|
-
text: "We need more software that defaults to local-first, legible state, and repairable failure modes.",
|
|
176
|
-
createdAt:
|
|
196
|
+
text: "We need more software that defaults to local-first, legible state, and repairable failure modes. https://t.co/local",
|
|
197
|
+
createdAt: linkMinutesAgo(18),
|
|
177
198
|
isReplied: 0,
|
|
178
199
|
replyToId: null,
|
|
179
200
|
likeCount: 1240,
|
|
@@ -186,8 +207,8 @@ export function seedDemoData(db: Database) {
|
|
|
186
207
|
url: "https://t.co/local",
|
|
187
208
|
expandedUrl: "https://birdclaw.dev/local-first-systems",
|
|
188
209
|
displayUrl: "birdclaw.dev/local-first-systems",
|
|
189
|
-
start:
|
|
190
|
-
end:
|
|
210
|
+
start: 97,
|
|
211
|
+
end: 115,
|
|
191
212
|
title: "Local-first systems",
|
|
192
213
|
description: "Design notes on durable local software.",
|
|
193
214
|
},
|
|
@@ -227,8 +248,8 @@ export function seedDemoData(db: Database) {
|
|
|
227
248
|
accountId: "acct_primary",
|
|
228
249
|
authorProfileId: "profile_ava",
|
|
229
250
|
kind: "home",
|
|
230
|
-
text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more.",
|
|
231
|
-
createdAt:
|
|
251
|
+
text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more. https://t.co/survey https://t.co/video",
|
|
252
|
+
createdAt: linkMinutesAgo(91),
|
|
232
253
|
isReplied: 0,
|
|
233
254
|
replyToId: null,
|
|
234
255
|
likeCount: 128,
|
|
@@ -241,12 +262,21 @@ export function seedDemoData(db: Database) {
|
|
|
241
262
|
url: "https://t.co/survey",
|
|
242
263
|
expandedUrl: "https://example.com/developer-platform-pricing",
|
|
243
264
|
displayUrl: "example.com/developer-platform-pricing",
|
|
244
|
-
start:
|
|
245
|
-
end:
|
|
265
|
+
start: 98,
|
|
266
|
+
end: 117,
|
|
246
267
|
title: "Developer platform pricing survey",
|
|
247
268
|
description:
|
|
248
269
|
"A simple inline link preview card from tweet URL entities.",
|
|
249
270
|
},
|
|
271
|
+
{
|
|
272
|
+
url: "https://t.co/video",
|
|
273
|
+
expandedUrl: "https://youtu.be/GMIWm5y90xA",
|
|
274
|
+
displayUrl: "youtu.be/GMIWm5y90xA",
|
|
275
|
+
start: 118,
|
|
276
|
+
end: 136,
|
|
277
|
+
title: "Agent query walkthrough",
|
|
278
|
+
description: "Short demo video for local query workflows.",
|
|
279
|
+
},
|
|
250
280
|
],
|
|
251
281
|
}),
|
|
252
282
|
mediaJson: JSON.stringify([
|
|
@@ -466,6 +496,87 @@ export function seedDemoData(db: Database) {
|
|
|
466
496
|
},
|
|
467
497
|
];
|
|
468
498
|
|
|
499
|
+
const urlExpansions = [
|
|
500
|
+
{
|
|
501
|
+
shortUrl: "https://t.co/local",
|
|
502
|
+
expandedUrl: "https://birdclaw.dev/local-first-systems",
|
|
503
|
+
finalUrl: "https://birdclaw.dev/local-first-systems",
|
|
504
|
+
status: "hit",
|
|
505
|
+
expandedTweetId: null,
|
|
506
|
+
expandedHandle: null,
|
|
507
|
+
title: "Local-first systems",
|
|
508
|
+
description: "Design notes on durable local software.",
|
|
509
|
+
imageUrl: null,
|
|
510
|
+
siteName: "birdclaw",
|
|
511
|
+
error: null,
|
|
512
|
+
source: "demo",
|
|
513
|
+
updatedAt: linkNow.toISOString(),
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
shortUrl: "https://t.co/survey",
|
|
517
|
+
expandedUrl: "https://example.com/developer-platform-pricing",
|
|
518
|
+
finalUrl: "https://example.com/developer-platform-pricing",
|
|
519
|
+
status: "hit",
|
|
520
|
+
expandedTweetId: null,
|
|
521
|
+
expandedHandle: null,
|
|
522
|
+
title: "Developer platform pricing survey",
|
|
523
|
+
description: "A simple inline link preview card from tweet URL entities.",
|
|
524
|
+
imageUrl: null,
|
|
525
|
+
siteName: "Example",
|
|
526
|
+
error: null,
|
|
527
|
+
source: "demo",
|
|
528
|
+
updatedAt: linkNow.toISOString(),
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
shortUrl: "https://t.co/video",
|
|
532
|
+
expandedUrl: "https://youtu.be/GMIWm5y90xA",
|
|
533
|
+
finalUrl: "https://youtu.be/GMIWm5y90xA",
|
|
534
|
+
status: "hit",
|
|
535
|
+
expandedTweetId: null,
|
|
536
|
+
expandedHandle: null,
|
|
537
|
+
title: "Agent query walkthrough",
|
|
538
|
+
description: "Short demo video for local query workflows.",
|
|
539
|
+
imageUrl: null,
|
|
540
|
+
siteName: "YouTube",
|
|
541
|
+
error: null,
|
|
542
|
+
source: "demo",
|
|
543
|
+
updatedAt: linkNow.toISOString(),
|
|
544
|
+
},
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const linkOccurrences = [
|
|
548
|
+
{
|
|
549
|
+
sourceKind: "tweet",
|
|
550
|
+
sourceId: "tweet_001",
|
|
551
|
+
sourcePosition: 0,
|
|
552
|
+
shortUrl: "https://t.co/local",
|
|
553
|
+
accountId: "acct_primary",
|
|
554
|
+
conversationId: null,
|
|
555
|
+
direction: null,
|
|
556
|
+
createdAt: linkMinutesAgo(18),
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
sourceKind: "tweet",
|
|
560
|
+
sourceId: "tweet_003",
|
|
561
|
+
sourcePosition: 0,
|
|
562
|
+
shortUrl: "https://t.co/survey",
|
|
563
|
+
accountId: "acct_primary",
|
|
564
|
+
conversationId: null,
|
|
565
|
+
direction: null,
|
|
566
|
+
createdAt: linkMinutesAgo(91),
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
sourceKind: "tweet",
|
|
570
|
+
sourceId: "tweet_003",
|
|
571
|
+
sourcePosition: 1,
|
|
572
|
+
shortUrl: "https://t.co/video",
|
|
573
|
+
accountId: "acct_primary",
|
|
574
|
+
conversationId: null,
|
|
575
|
+
direction: null,
|
|
576
|
+
createdAt: linkMinutesAgo(91),
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
|
|
469
580
|
const transaction = db.transaction(() => {
|
|
470
581
|
for (const account of accounts) {
|
|
471
582
|
insertAccount.run(account);
|
|
@@ -488,6 +599,14 @@ export function seedDemoData(db: Database) {
|
|
|
488
599
|
insertMessage.run(message);
|
|
489
600
|
insertDmFts.run(message.id, message.text);
|
|
490
601
|
}
|
|
602
|
+
|
|
603
|
+
for (const expansion of urlExpansions) {
|
|
604
|
+
insertUrlExpansion.run(expansion);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const occurrence of linkOccurrences) {
|
|
608
|
+
insertLinkOccurrence.run(occurrence);
|
|
609
|
+
}
|
|
491
610
|
});
|
|
492
611
|
|
|
493
612
|
transaction();
|