birdclaw 0.4.0 → 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 +39 -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 +563 -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 +1567 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +318 -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 +282 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +629 -0
- 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 +219 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +33 -8
- 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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Database } from "./sqlite";
|
|
2
2
|
import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
|
|
3
3
|
import { getNativeDb } from "./db";
|
|
4
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
4
5
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
5
6
|
import type {
|
|
6
7
|
XurlMentionData,
|
|
7
8
|
XurlMentionsResponse,
|
|
9
|
+
XurlMediaItem,
|
|
8
10
|
XurlMentionUser,
|
|
9
11
|
} from "./types";
|
|
10
12
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
@@ -18,6 +20,7 @@ export type TimelineCollectionKind = "likes" | "bookmarks";
|
|
|
18
20
|
export type TimelineCollectionMode = "auto" | "xurl" | "bird";
|
|
19
21
|
|
|
20
22
|
const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
|
|
23
|
+
const DEFAULT_EARLY_STOP_MAX_PAGES = 10;
|
|
21
24
|
const MIN_XURL_LIMIT = 5;
|
|
22
25
|
const MAX_XURL_LIMIT = 100;
|
|
23
26
|
|
|
@@ -87,16 +90,6 @@ function resolveAccount(db: Database, accountId?: string) {
|
|
|
87
90
|
};
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
function getMediaCount(tweet: XurlMentionData) {
|
|
91
|
-
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
92
|
-
return urls.filter(
|
|
93
|
-
(url) =>
|
|
94
|
-
url &&
|
|
95
|
-
typeof url === "object" &&
|
|
96
|
-
typeof (url as Record<string, unknown>).media_key === "string",
|
|
97
|
-
).length;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
93
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
101
94
|
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
102
95
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
@@ -116,6 +109,8 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
|
116
109
|
const seenTweetIds = new Set<string>();
|
|
117
110
|
const users: XurlMentionUser[] = [];
|
|
118
111
|
const seenUserIds = new Set<string>();
|
|
112
|
+
const media: XurlMediaItem[] = [];
|
|
113
|
+
const seenMediaKeys = new Set<string>();
|
|
119
114
|
|
|
120
115
|
for (const page of pages) {
|
|
121
116
|
for (const tweet of page.data) {
|
|
@@ -133,12 +128,24 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
|
133
128
|
seenUserIds.add(user.id);
|
|
134
129
|
users.push(user);
|
|
135
130
|
}
|
|
131
|
+
|
|
132
|
+
for (const item of page.includes?.media ?? []) {
|
|
133
|
+
if (seenMediaKeys.has(item.media_key)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
seenMediaKeys.add(item.media_key);
|
|
137
|
+
media.push(item);
|
|
138
|
+
}
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
const lastPage = pages.at(-1);
|
|
142
|
+
const includes = {
|
|
143
|
+
...(users.length > 0 ? { users } : {}),
|
|
144
|
+
...(media.length > 0 ? { media } : {}),
|
|
145
|
+
};
|
|
139
146
|
return {
|
|
140
147
|
data: tweets,
|
|
141
|
-
includes: users.length > 0
|
|
148
|
+
includes: users.length > 0 || media.length > 0 ? includes : undefined,
|
|
142
149
|
meta: {
|
|
143
150
|
result_count: tweets.length,
|
|
144
151
|
page_count: pages.length,
|
|
@@ -149,6 +156,52 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
|
149
156
|
};
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
function getCollectionPageDedupe(
|
|
160
|
+
db: Database,
|
|
161
|
+
accountId: string,
|
|
162
|
+
kind: TimelineCollectionKind,
|
|
163
|
+
tweetIds: string[],
|
|
164
|
+
) {
|
|
165
|
+
const uniqueTweetIds = [...new Set(tweetIds)];
|
|
166
|
+
if (uniqueTweetIds.length === 0) {
|
|
167
|
+
return { existingTweetIds: new Set<string>(), uniqueTweetCount: 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const rows = db
|
|
171
|
+
.prepare(
|
|
172
|
+
`
|
|
173
|
+
select tweet_id
|
|
174
|
+
from tweet_collections
|
|
175
|
+
where account_id = ?
|
|
176
|
+
and kind = ?
|
|
177
|
+
and tweet_id in (${uniqueTweetIds.map(() => "?").join(", ")})
|
|
178
|
+
`,
|
|
179
|
+
)
|
|
180
|
+
.all(accountId, kind, ...uniqueTweetIds) as { tweet_id: string }[];
|
|
181
|
+
return {
|
|
182
|
+
existingTweetIds: new Set(rows.map((row) => row.tweet_id)),
|
|
183
|
+
uniqueTweetCount: uniqueTweetIds.length,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function filterExistingCollectionTweets(
|
|
188
|
+
payload: XurlMentionsResponse,
|
|
189
|
+
existingTweetIds: Set<string>,
|
|
190
|
+
) {
|
|
191
|
+
if (existingTweetIds.size === 0) {
|
|
192
|
+
return payload;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
...payload,
|
|
196
|
+
data: payload.data.filter((tweet) => !existingTweetIds.has(tweet.id)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readSaturatedAtPage(payload: XurlMentionsResponse) {
|
|
201
|
+
const value = payload.meta?.saturated_at_page;
|
|
202
|
+
return typeof value === "number" ? value : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
152
205
|
function mergeTimelineCollectionIntoLocalStore(
|
|
153
206
|
db: Database,
|
|
154
207
|
accountId: string,
|
|
@@ -168,20 +221,23 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
168
221
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
169
222
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
170
223
|
entities_json, media_json, quoted_tweet_id
|
|
171
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
224
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
172
225
|
on conflict(id) do update set
|
|
173
226
|
account_id = tweets.account_id,
|
|
174
227
|
author_profile_id = excluded.author_profile_id,
|
|
175
228
|
kind = case
|
|
176
|
-
when tweets.kind in ('home', 'mention') then tweets.kind
|
|
229
|
+
when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
|
|
177
230
|
else excluded.kind
|
|
178
231
|
end,
|
|
179
232
|
text = excluded.text,
|
|
180
233
|
created_at = excluded.created_at,
|
|
181
234
|
like_count = excluded.like_count,
|
|
182
|
-
media_count = excluded.media_count,
|
|
235
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
183
236
|
entities_json = excluded.entities_json,
|
|
184
|
-
media_json =
|
|
237
|
+
media_json = case
|
|
238
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
239
|
+
else tweets.media_json
|
|
240
|
+
end,
|
|
185
241
|
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
186
242
|
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
187
243
|
quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
|
|
@@ -224,10 +280,11 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
224
280
|
replyToId ? 1 : 0,
|
|
225
281
|
replyToId,
|
|
226
282
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
227
|
-
|
|
283
|
+
countTweetMedia(tweet),
|
|
228
284
|
bookmarked,
|
|
229
285
|
liked,
|
|
230
286
|
JSON.stringify(tweet.entities ?? {}),
|
|
287
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
231
288
|
quotedTweetId,
|
|
232
289
|
);
|
|
233
290
|
upsertCollection.run(
|
|
@@ -244,19 +301,25 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
244
301
|
}
|
|
245
302
|
|
|
246
303
|
async function fetchXurlCollection({
|
|
304
|
+
db,
|
|
247
305
|
kind,
|
|
306
|
+
accountId,
|
|
248
307
|
username,
|
|
249
308
|
userId,
|
|
250
309
|
limit,
|
|
251
310
|
all,
|
|
252
311
|
maxPages,
|
|
312
|
+
earlyStop,
|
|
253
313
|
}: {
|
|
314
|
+
db: Database;
|
|
254
315
|
kind: TimelineCollectionKind;
|
|
316
|
+
accountId: string;
|
|
255
317
|
username: string;
|
|
256
318
|
userId?: string;
|
|
257
319
|
limit: number;
|
|
258
320
|
all: boolean;
|
|
259
321
|
maxPages: number | null;
|
|
322
|
+
earlyStop: boolean;
|
|
260
323
|
}) {
|
|
261
324
|
let resolvedUserId = userId;
|
|
262
325
|
if (!resolvedUserId) {
|
|
@@ -270,6 +333,7 @@ async function fetchXurlCollection({
|
|
|
270
333
|
const pages: XurlMentionsResponse[] = [];
|
|
271
334
|
let nextToken: string | undefined;
|
|
272
335
|
let pageCount = 0;
|
|
336
|
+
let saturatedAtPage: number | undefined;
|
|
273
337
|
do {
|
|
274
338
|
const payload =
|
|
275
339
|
kind === "likes"
|
|
@@ -283,17 +347,51 @@ async function fetchXurlCollection({
|
|
|
283
347
|
maxResults: limit,
|
|
284
348
|
username,
|
|
285
349
|
userId: resolvedUserId,
|
|
350
|
+
isPaginatedWalk: all,
|
|
286
351
|
paginationToken: nextToken,
|
|
287
352
|
});
|
|
288
|
-
|
|
353
|
+
pageCount += 1;
|
|
354
|
+
if (earlyStop) {
|
|
355
|
+
const tweetIds = payload.data.map((tweet) => tweet.id);
|
|
356
|
+
const { existingTweetIds, uniqueTweetCount } = getCollectionPageDedupe(
|
|
357
|
+
db,
|
|
358
|
+
accountId,
|
|
359
|
+
kind,
|
|
360
|
+
tweetIds,
|
|
361
|
+
);
|
|
362
|
+
if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
|
|
363
|
+
saturatedAtPage = pageCount;
|
|
364
|
+
console.error(
|
|
365
|
+
`${kind} saturated at page ${pageCount} (100% existing rows)`,
|
|
366
|
+
);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
|
|
370
|
+
} else {
|
|
371
|
+
pages.push(payload);
|
|
372
|
+
}
|
|
289
373
|
nextToken =
|
|
290
374
|
typeof payload.meta?.next_token === "string"
|
|
291
375
|
? payload.meta.next_token
|
|
292
376
|
: undefined;
|
|
293
|
-
|
|
294
|
-
|
|
377
|
+
} while (
|
|
378
|
+
(all || earlyStop) &&
|
|
379
|
+
nextToken &&
|
|
380
|
+
(maxPages === null || pageCount < maxPages)
|
|
381
|
+
);
|
|
295
382
|
|
|
296
|
-
|
|
383
|
+
const merged = mergePayloads(pages);
|
|
384
|
+
// A saturated page may expose another token, but our walk is complete.
|
|
385
|
+
const saturationMeta =
|
|
386
|
+
saturatedAtPage === undefined
|
|
387
|
+
? {}
|
|
388
|
+
: { saturated_at_page: saturatedAtPage, next_token: null };
|
|
389
|
+
merged.meta = {
|
|
390
|
+
...merged.meta,
|
|
391
|
+
page_count: pageCount,
|
|
392
|
+
...saturationMeta,
|
|
393
|
+
};
|
|
394
|
+
return merged;
|
|
297
395
|
}
|
|
298
396
|
|
|
299
397
|
async function fetchBirdCollection({
|
|
@@ -329,6 +427,7 @@ export async function syncTimelineCollection({
|
|
|
329
427
|
maxPages,
|
|
330
428
|
refresh = false,
|
|
331
429
|
cacheTtlMs,
|
|
430
|
+
earlyStop = false,
|
|
332
431
|
}: {
|
|
333
432
|
kind: TimelineCollectionKind;
|
|
334
433
|
account?: string;
|
|
@@ -338,16 +437,23 @@ export async function syncTimelineCollection({
|
|
|
338
437
|
maxPages?: number;
|
|
339
438
|
refresh?: boolean;
|
|
340
439
|
cacheTtlMs?: number;
|
|
440
|
+
earlyStop?: boolean;
|
|
341
441
|
}) {
|
|
342
442
|
assertLimit(limit);
|
|
343
443
|
const parsedMaxPages = parseMaxPages(maxPages);
|
|
444
|
+
const shouldApplyEarlyStopCap =
|
|
445
|
+
earlyStop && !all && parsedMaxPages === null && mode !== "bird";
|
|
446
|
+
const xurlMaxPages = shouldApplyEarlyStopCap
|
|
447
|
+
? DEFAULT_EARLY_STOP_MAX_PAGES
|
|
448
|
+
: parsedMaxPages;
|
|
344
449
|
if (mode === "xurl" || mode === "auto") {
|
|
345
450
|
assertXurlLimit(limit);
|
|
346
451
|
}
|
|
347
452
|
|
|
348
453
|
const db = getNativeDb();
|
|
349
454
|
const resolvedAccount = resolveAccount(db, account);
|
|
350
|
-
const
|
|
455
|
+
const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
|
|
456
|
+
const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
|
|
351
457
|
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
352
458
|
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
353
459
|
const cacheAgeMs = cached
|
|
@@ -355,6 +461,7 @@ export async function syncTimelineCollection({
|
|
|
355
461
|
: Number.POSITIVE_INFINITY;
|
|
356
462
|
|
|
357
463
|
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
464
|
+
const saturatedAtPage = readSaturatedAtPage(cached.value);
|
|
358
465
|
return {
|
|
359
466
|
ok: true,
|
|
360
467
|
source: "cache",
|
|
@@ -362,9 +469,18 @@ export async function syncTimelineCollection({
|
|
|
362
469
|
accountId: resolvedAccount.accountId,
|
|
363
470
|
count: cached.value.data.length,
|
|
364
471
|
payload: cached.value,
|
|
472
|
+
...(saturatedAtPage === undefined
|
|
473
|
+
? {}
|
|
474
|
+
: { saturated_at_page: saturatedAtPage }),
|
|
365
475
|
};
|
|
366
476
|
}
|
|
367
477
|
|
|
478
|
+
if (shouldApplyEarlyStopCap) {
|
|
479
|
+
console.error(
|
|
480
|
+
`${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
368
484
|
let source: "xurl" | "bird";
|
|
369
485
|
let payload: XurlMentionsResponse;
|
|
370
486
|
if (mode === "bird") {
|
|
@@ -378,12 +494,15 @@ export async function syncTimelineCollection({
|
|
|
378
494
|
} else {
|
|
379
495
|
try {
|
|
380
496
|
payload = await fetchXurlCollection({
|
|
497
|
+
db,
|
|
381
498
|
kind,
|
|
499
|
+
accountId: resolvedAccount.accountId,
|
|
382
500
|
username: resolvedAccount.username,
|
|
383
501
|
userId: resolvedAccount.externalUserId,
|
|
384
502
|
limit,
|
|
385
503
|
all,
|
|
386
|
-
maxPages:
|
|
504
|
+
maxPages: xurlMaxPages,
|
|
505
|
+
earlyStop,
|
|
387
506
|
});
|
|
388
507
|
source = "xurl";
|
|
389
508
|
} catch (error) {
|
|
@@ -408,6 +527,7 @@ export async function syncTimelineCollection({
|
|
|
408
527
|
source,
|
|
409
528
|
);
|
|
410
529
|
writeSyncCache(cacheKey, payload, db);
|
|
530
|
+
const saturatedAtPage = readSaturatedAtPage(payload);
|
|
411
531
|
|
|
412
532
|
return {
|
|
413
533
|
ok: true,
|
|
@@ -416,5 +536,8 @@ export async function syncTimelineCollection({
|
|
|
416
536
|
accountId: resolvedAccount.accountId,
|
|
417
537
|
count: payload.data.length,
|
|
418
538
|
payload,
|
|
539
|
+
...(saturatedAtPage === undefined
|
|
540
|
+
? {}
|
|
541
|
+
: { saturated_at_page: saturatedAtPage }),
|
|
419
542
|
};
|
|
420
543
|
}
|
package/src/lib/timeline-live.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Database } from "./sqlite";
|
|
2
2
|
import { listHomeTimelineViaBird } from "./bird";
|
|
3
3
|
import { getNativeDb } from "./db";
|
|
4
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
4
5
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
5
|
-
import type {
|
|
6
|
+
import type { XurlMentionsResponse } from "./types";
|
|
6
7
|
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
7
8
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
8
9
|
|
|
@@ -44,16 +45,6 @@ function resolveAccount(db: Database, accountId?: string) {
|
|
|
44
45
|
return row.id;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function getMediaCount(tweet: XurlMentionData) {
|
|
48
|
-
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
49
|
-
return urls.filter(
|
|
50
|
-
(url) =>
|
|
51
|
-
url &&
|
|
52
|
-
typeof url === "object" &&
|
|
53
|
-
typeof (url as Record<string, unknown>).media_key === "string",
|
|
54
|
-
).length;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
48
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
58
49
|
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
59
50
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
@@ -76,7 +67,7 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
76
67
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
77
68
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
78
69
|
entities_json, media_json, quoted_tweet_id
|
|
79
|
-
) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?,
|
|
70
|
+
) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
|
|
80
71
|
on conflict(id) do update set
|
|
81
72
|
account_id = tweets.account_id,
|
|
82
73
|
author_profile_id = excluded.author_profile_id,
|
|
@@ -84,9 +75,12 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
84
75
|
text = excluded.text,
|
|
85
76
|
created_at = excluded.created_at,
|
|
86
77
|
like_count = excluded.like_count,
|
|
87
|
-
media_count = excluded.media_count,
|
|
78
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
88
79
|
entities_json = excluded.entities_json,
|
|
89
|
-
media_json =
|
|
80
|
+
media_json = case
|
|
81
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
82
|
+
else tweets.media_json
|
|
83
|
+
end,
|
|
90
84
|
bookmarked = tweets.bookmarked,
|
|
91
85
|
liked = tweets.liked
|
|
92
86
|
`,
|
|
@@ -112,8 +106,9 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
112
106
|
tweet.text,
|
|
113
107
|
tweet.created_at,
|
|
114
108
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
115
|
-
|
|
109
|
+
countTweetMedia(tweet),
|
|
116
110
|
JSON.stringify(tweet.entities ?? {}),
|
|
111
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
117
112
|
);
|
|
118
113
|
upsertTweetAccountEdge(db, {
|
|
119
114
|
accountId,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Database } from "./sqlite";
|
|
2
2
|
|
|
3
|
-
export type TweetAccountEdgeKind =
|
|
3
|
+
export type TweetAccountEdgeKind =
|
|
4
|
+
| "home"
|
|
5
|
+
| "mention"
|
|
6
|
+
| "authored"
|
|
7
|
+
| "thread_context";
|
|
4
8
|
|
|
5
9
|
export function upsertTweetAccountEdge(
|
|
6
10
|
db: Database,
|
|
@@ -29,7 +33,10 @@ export function upsertTweetAccountEdge(
|
|
|
29
33
|
first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
|
|
30
34
|
last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
|
|
31
35
|
seen_count = tweet_account_edges.seen_count + 1,
|
|
32
|
-
|
|
36
|
+
source = case
|
|
37
|
+
when tweet_account_edges.source = 'archive' then 'archive'
|
|
38
|
+
else coalesce(nullif(excluded.source, ''), tweet_account_edges.source)
|
|
39
|
+
end,
|
|
33
40
|
raw_json = case
|
|
34
41
|
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
35
42
|
else tweet_account_edges.raw_json
|
package/src/lib/tweet-render.ts
CHANGED
|
@@ -10,6 +10,14 @@ type TweetSegment =
|
|
|
10
10
|
| ({ kind: "url" } & TweetUrlEntity)
|
|
11
11
|
| ({ kind: "hashtag" } & TweetHashtagEntity);
|
|
12
12
|
|
|
13
|
+
type UrlExpansion = Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
|
|
14
|
+
Partial<
|
|
15
|
+
Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
const RAW_URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g;
|
|
19
|
+
const TRAILING_URL_PUNCTUATION = /[),.;:!?]+$/;
|
|
20
|
+
|
|
13
21
|
const MARKDOWN_ESCAPE_CHARACTERS = new Set([
|
|
14
22
|
"\\",
|
|
15
23
|
"`",
|
|
@@ -38,6 +46,95 @@ function escapeMarkdown(text: string) {
|
|
|
38
46
|
.join("");
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
export function displayUrlForLink(url: string) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(url);
|
|
52
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
53
|
+
const suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
54
|
+
return suffix === "/" ? host : `${host}${suffix}`;
|
|
55
|
+
} catch {
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function spansOverlap(
|
|
61
|
+
leftStart: number,
|
|
62
|
+
leftEnd: number,
|
|
63
|
+
rightStart: number,
|
|
64
|
+
rightEnd: number,
|
|
65
|
+
) {
|
|
66
|
+
return leftStart < rightEnd && rightStart < leftEnd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function enrichFallbackUrlEntities(
|
|
70
|
+
text: string,
|
|
71
|
+
entities: TweetEntities,
|
|
72
|
+
resolveExpansion?: (rawUrl: string) => UrlExpansion | null | undefined,
|
|
73
|
+
): TweetEntities {
|
|
74
|
+
const existingUrls = entities.urls ?? [];
|
|
75
|
+
const enrichedExistingUrls = existingUrls.map((entry) => {
|
|
76
|
+
const expansion = resolveExpansion?.(entry.url);
|
|
77
|
+
if (!expansion) return entry;
|
|
78
|
+
const expandedUrl = expansion.expandedUrl || entry.expandedUrl;
|
|
79
|
+
return {
|
|
80
|
+
...entry,
|
|
81
|
+
expandedUrl,
|
|
82
|
+
displayUrl: expansion.displayUrl || displayUrlForLink(expandedUrl),
|
|
83
|
+
...(expansion.title ? { title: expansion.title } : {}),
|
|
84
|
+
...(expansion && "description" in expansion
|
|
85
|
+
? { description: expansion.description ?? null }
|
|
86
|
+
: {}),
|
|
87
|
+
...(expansion.imageUrl ? { imageUrl: expansion.imageUrl } : {}),
|
|
88
|
+
...(expansion.siteName ? { siteName: expansion.siteName } : {}),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const fallbackUrls: TweetUrlEntity[] = [];
|
|
92
|
+
|
|
93
|
+
for (const match of text.matchAll(RAW_URL_PATTERN)) {
|
|
94
|
+
const rawMatch = match[0];
|
|
95
|
+
const url = rawMatch.replace(TRAILING_URL_PUNCTUATION, "");
|
|
96
|
+
const start = match.index ?? 0;
|
|
97
|
+
const end = start + url.length;
|
|
98
|
+
if (!url) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
enrichedExistingUrls.some((entry) =>
|
|
103
|
+
spansOverlap(start, end, entry.start, entry.end),
|
|
104
|
+
)
|
|
105
|
+
) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const expansion = resolveExpansion?.(url);
|
|
110
|
+
const expandedUrl = expansion?.expandedUrl || url;
|
|
111
|
+
fallbackUrls.push({
|
|
112
|
+
url,
|
|
113
|
+
expandedUrl,
|
|
114
|
+
displayUrl: expansion?.displayUrl || displayUrlForLink(expandedUrl),
|
|
115
|
+
start,
|
|
116
|
+
end,
|
|
117
|
+
...(expansion?.title ? { title: expansion.title } : {}),
|
|
118
|
+
...(expansion && "description" in expansion
|
|
119
|
+
? { description: expansion.description ?? null }
|
|
120
|
+
: {}),
|
|
121
|
+
...(expansion?.imageUrl ? { imageUrl: expansion.imageUrl } : {}),
|
|
122
|
+
...(expansion?.siteName ? { siteName: expansion.siteName } : {}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (fallbackUrls.length === 0) {
|
|
127
|
+
return { ...entities, urls: enrichedExistingUrls };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...entities,
|
|
132
|
+
urls: [...enrichedExistingUrls, ...fallbackUrls].sort(
|
|
133
|
+
(left, right) => left.start - right.start,
|
|
134
|
+
),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
41
138
|
export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
|
|
42
139
|
return [
|
|
43
140
|
...(entities.mentions?.map((entry) => ({
|