birdclaw 0.6.0 → 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 +48 -0
- package/README.md +25 -0
- package/package.json +6 -1
- package/src/cli.ts +438 -26
- package/src/components/AppNav.tsx +10 -0
- package/src/components/MarkdownViewer.tsx +438 -72
- 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 +5 -2
- package/src/components/TimelineCard.tsx +20 -4
- package/src/components/TimelineRouteFrame.tsx +16 -0
- package/src/components/TweetRichText.tsx +36 -12
- package/src/components/useTimelineRouteData.ts +74 -6
- package/src/lib/account-sync-job.ts +15 -3
- package/src/lib/archive-finder.ts +1 -1
- package/src/lib/archive-import.ts +245 -7
- package/src/lib/authored-live.ts +1 -0
- package/src/lib/avatar-cache.ts +50 -0
- package/src/lib/backup.ts +4 -3
- package/src/lib/bird.ts +33 -0
- package/src/lib/config.ts +35 -2
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +62 -1
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/location.ts +137 -0
- package/src/lib/mention-threads-live.ts +94 -1
- package/src/lib/mentions-live.ts +187 -40
- package/src/lib/network-map.ts +382 -0
- package/src/lib/period-digest.ts +377 -13
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/queries.ts +14 -4
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/timeline-live.ts +272 -19
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +37 -2
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +7 -2
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +551 -41
- package/src/routeTree.gen.ts +231 -0
- package/src/routes/__root.tsx +5 -6
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +11 -1
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/query.tsx +1 -0
- package/src/routes/api/search-discussion.tsx +169 -0
- 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/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 +22 -8
- package/src/styles.css +22 -0
package/src/lib/timeline-live.ts
CHANGED
|
@@ -5,18 +5,39 @@ import { getNativeDb } from "./db";
|
|
|
5
5
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
XurlMediaItem,
|
|
10
|
+
XurlMentionUser,
|
|
11
|
+
XurlMentionsResponse,
|
|
12
|
+
} from "./types";
|
|
9
13
|
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
10
14
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
15
|
+
import { listHomeTimelineViaXurlEffect } from "./xurl";
|
|
11
16
|
|
|
12
17
|
const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
|
|
18
|
+
const MAX_XURL_TIMELINE_PAGE_SIZE = 100;
|
|
13
19
|
|
|
20
|
+
export type HomeTimelineMode = "bird" | "xurl" | "auto";
|
|
21
|
+
export interface HomeTimelineProgress {
|
|
22
|
+
source: "bird" | "xurl" | "cache";
|
|
23
|
+
fetched: number;
|
|
24
|
+
total?: number;
|
|
25
|
+
page?: number;
|
|
26
|
+
maxPages?: number;
|
|
27
|
+
pageSize?: number;
|
|
28
|
+
done: boolean;
|
|
29
|
+
}
|
|
14
30
|
export interface SyncHomeTimelineOptions {
|
|
15
31
|
account?: string;
|
|
32
|
+
mode?: HomeTimelineMode;
|
|
16
33
|
limit?: number;
|
|
34
|
+
maxPages?: number;
|
|
35
|
+
startTime?: string;
|
|
17
36
|
following?: boolean;
|
|
18
37
|
refresh?: boolean;
|
|
19
38
|
cacheTtlMs?: number;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
onProgress?: (progress: HomeTimelineProgress) => void;
|
|
20
41
|
}
|
|
21
42
|
|
|
22
43
|
function parseCacheTtlMs(value?: number) {
|
|
@@ -27,32 +48,130 @@ function parseCacheTtlMs(value?: number) {
|
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
function assertLimit(limit: number) {
|
|
30
|
-
if (!Number.isFinite(limit) || limit < 1) {
|
|
51
|
+
if ((!Number.isFinite(limit) && limit !== Infinity) || limit < 1) {
|
|
31
52
|
throw new Error("--limit must be at least 1");
|
|
32
53
|
}
|
|
33
54
|
}
|
|
34
55
|
|
|
56
|
+
function parseMode(mode: HomeTimelineMode | undefined) {
|
|
57
|
+
const parsed = mode ?? "bird";
|
|
58
|
+
if (parsed !== "bird" && parsed !== "xurl" && parsed !== "auto") {
|
|
59
|
+
throw new Error("--mode must be bird, xurl, or auto");
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseMaxPages(maxPages: number | undefined) {
|
|
65
|
+
if (maxPages === undefined) return 1;
|
|
66
|
+
if (!Number.isFinite(maxPages) || maxPages < 1) {
|
|
67
|
+
throw new Error("--max-pages must be at least 1");
|
|
68
|
+
}
|
|
69
|
+
return Math.floor(maxPages);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseStartTime(value: string | undefined) {
|
|
73
|
+
if (!value?.trim()) return undefined;
|
|
74
|
+
const time = new Date(value).getTime();
|
|
75
|
+
if (!Number.isFinite(time)) {
|
|
76
|
+
throw new Error("--start-time must be a valid date");
|
|
77
|
+
}
|
|
78
|
+
return { iso: new Date(time).toISOString(), time };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reachedStartTimeBoundary(
|
|
82
|
+
payload: XurlMentionsResponse,
|
|
83
|
+
startTimeMs: number | undefined,
|
|
84
|
+
) {
|
|
85
|
+
if (startTimeMs === undefined) return false;
|
|
86
|
+
return payload.data.some((tweet) => {
|
|
87
|
+
const createdAt = new Date(tweet.created_at).getTime();
|
|
88
|
+
return Number.isFinite(createdAt) && createdAt <= startTimeMs;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getReferencedTweetId(
|
|
93
|
+
tweet: XurlMentionsResponse["data"][number],
|
|
94
|
+
type: "replied_to" | "quoted",
|
|
95
|
+
) {
|
|
96
|
+
return tweet.referenced_tweets?.find((reference) => reference.type === type)
|
|
97
|
+
?.id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mergeTimelinePayloads(
|
|
101
|
+
payloads: XurlMentionsResponse[],
|
|
102
|
+
limit: number,
|
|
103
|
+
) {
|
|
104
|
+
const data: XurlMentionsResponse["data"] = [];
|
|
105
|
+
const usersById = new Map<string, XurlMentionUser>();
|
|
106
|
+
const mediaByKey = new Map<string, XurlMediaItem>();
|
|
107
|
+
let meta: XurlMentionsResponse["meta"] | undefined;
|
|
108
|
+
|
|
109
|
+
for (const payload of payloads) {
|
|
110
|
+
meta = payload.meta;
|
|
111
|
+
for (const tweet of payload.data) {
|
|
112
|
+
if (data.some((existing) => existing.id === tweet.id)) continue;
|
|
113
|
+
data.push(tweet);
|
|
114
|
+
if (data.length >= limit) break;
|
|
115
|
+
}
|
|
116
|
+
for (const user of payload.includes?.users ?? []) {
|
|
117
|
+
usersById.set(user.id, user);
|
|
118
|
+
}
|
|
119
|
+
for (const media of payload.includes?.media ?? []) {
|
|
120
|
+
mediaByKey.set(media.media_key, media);
|
|
121
|
+
}
|
|
122
|
+
if (data.length >= limit) break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
data,
|
|
127
|
+
includes: {
|
|
128
|
+
users: [...usersById.values()],
|
|
129
|
+
media: [...mediaByKey.values()],
|
|
130
|
+
},
|
|
131
|
+
meta,
|
|
132
|
+
} satisfies XurlMentionsResponse;
|
|
133
|
+
}
|
|
134
|
+
|
|
35
135
|
function resolveAccount(db: Database, accountId?: string) {
|
|
36
136
|
const row = accountId
|
|
37
|
-
? (db
|
|
38
|
-
|
|
137
|
+
? (db
|
|
138
|
+
.prepare(
|
|
139
|
+
"select id, handle, external_user_id, is_default as isDefault from accounts where id = ?",
|
|
140
|
+
)
|
|
141
|
+
.get(accountId) as
|
|
142
|
+
| ({ id: string; handle: string; external_user_id: string | null } & {
|
|
143
|
+
isDefault: number;
|
|
144
|
+
})
|
|
39
145
|
| undefined)
|
|
40
146
|
: (db
|
|
41
147
|
.prepare(
|
|
42
148
|
`
|
|
43
|
-
select id
|
|
149
|
+
select id, handle, external_user_id, is_default as isDefault
|
|
44
150
|
from accounts
|
|
45
151
|
order by is_default desc, created_at asc
|
|
46
152
|
limit 1
|
|
47
153
|
`,
|
|
48
154
|
)
|
|
49
|
-
.get() as
|
|
155
|
+
.get() as
|
|
156
|
+
| ({ id: string; handle: string; external_user_id: string | null } & {
|
|
157
|
+
isDefault: number;
|
|
158
|
+
})
|
|
159
|
+
| undefined);
|
|
50
160
|
|
|
51
161
|
if (!row) {
|
|
52
162
|
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
53
163
|
}
|
|
54
164
|
|
|
55
|
-
return
|
|
165
|
+
return {
|
|
166
|
+
accountId: row.id,
|
|
167
|
+
isDefault: row.isDefault === 1,
|
|
168
|
+
username: row.handle.replace(/^@/, ""),
|
|
169
|
+
externalUserId:
|
|
170
|
+
typeof row.external_user_id === "string" &&
|
|
171
|
+
row.external_user_id.trim().length > 0
|
|
172
|
+
? row.external_user_id.trim()
|
|
173
|
+
: undefined,
|
|
174
|
+
};
|
|
56
175
|
}
|
|
57
176
|
|
|
58
177
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
@@ -67,6 +186,7 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
67
186
|
db: Database,
|
|
68
187
|
accountId: string,
|
|
69
188
|
payload: XurlMentionsResponse,
|
|
189
|
+
source: "bird" | "xurl",
|
|
70
190
|
) {
|
|
71
191
|
const usersById = new Map(
|
|
72
192
|
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
@@ -77,13 +197,15 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
77
197
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
78
198
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
79
199
|
entities_json, media_json, quoted_tweet_id
|
|
80
|
-
) values (?, ?, ?, 'home', ?, ?,
|
|
200
|
+
) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
81
201
|
on conflict(id) do update set
|
|
82
202
|
account_id = tweets.account_id,
|
|
83
203
|
author_profile_id = excluded.author_profile_id,
|
|
84
204
|
kind = tweets.kind,
|
|
85
205
|
text = excluded.text,
|
|
86
206
|
created_at = excluded.created_at,
|
|
207
|
+
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
208
|
+
reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
|
|
87
209
|
like_count = excluded.like_count,
|
|
88
210
|
media_count = max(tweets.media_count, excluded.media_count),
|
|
89
211
|
entities_json = excluded.entities_json,
|
|
@@ -92,7 +214,8 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
92
214
|
else tweets.media_json
|
|
93
215
|
end,
|
|
94
216
|
bookmarked = tweets.bookmarked,
|
|
95
|
-
liked = tweets.liked
|
|
217
|
+
liked = tweets.liked,
|
|
218
|
+
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
|
|
96
219
|
`,
|
|
97
220
|
);
|
|
98
221
|
|
|
@@ -109,22 +232,27 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
109
232
|
const profile = usersById.has(tweet.author_id)
|
|
110
233
|
? upsertProfileFromXUser(db, author)
|
|
111
234
|
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
235
|
+
const replyToId = getReferencedTweetId(tweet, "replied_to") ?? null;
|
|
236
|
+
const quotedTweetId = getReferencedTweetId(tweet, "quoted") ?? null;
|
|
112
237
|
upsertTweet.run(
|
|
113
238
|
tweet.id,
|
|
114
239
|
accountId,
|
|
115
240
|
profile.profile.id,
|
|
116
241
|
tweet.text,
|
|
117
242
|
tweet.created_at,
|
|
243
|
+
0,
|
|
244
|
+
replyToId,
|
|
118
245
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
119
246
|
countTweetMedia(tweet),
|
|
120
247
|
JSON.stringify(tweet.entities ?? {}),
|
|
121
248
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
249
|
+
quotedTweetId,
|
|
122
250
|
);
|
|
123
251
|
upsertTweetAccountEdge(db, {
|
|
124
252
|
accountId,
|
|
125
253
|
tweetId: tweet.id,
|
|
126
254
|
kind: "home",
|
|
127
|
-
source
|
|
255
|
+
source,
|
|
128
256
|
seenAt,
|
|
129
257
|
rawJson: JSON.stringify(tweet),
|
|
130
258
|
});
|
|
@@ -135,14 +263,19 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
135
263
|
|
|
136
264
|
export function syncHomeTimelineEffect({
|
|
137
265
|
account,
|
|
138
|
-
|
|
266
|
+
mode,
|
|
267
|
+
limit,
|
|
268
|
+
maxPages,
|
|
269
|
+
startTime,
|
|
139
270
|
following = true,
|
|
140
271
|
refresh = false,
|
|
141
272
|
cacheTtlMs,
|
|
273
|
+
timeoutMs,
|
|
274
|
+
onProgress,
|
|
142
275
|
}: SyncHomeTimelineOptions = {}): Effect.Effect<
|
|
143
276
|
{
|
|
144
277
|
ok: true;
|
|
145
|
-
source: "bird" | "cache";
|
|
278
|
+
source: "bird" | "xurl" | "cache";
|
|
146
279
|
kind: "timeline";
|
|
147
280
|
accountId: string;
|
|
148
281
|
feed: "following" | "for-you";
|
|
@@ -152,10 +285,32 @@ export function syncHomeTimelineEffect({
|
|
|
152
285
|
unknown
|
|
153
286
|
> {
|
|
154
287
|
return Effect.gen(function* () {
|
|
155
|
-
|
|
288
|
+
const parsedStartTime = yield* Effect.try({
|
|
289
|
+
try: () => parseStartTime(startTime),
|
|
290
|
+
catch: (error) => error,
|
|
291
|
+
});
|
|
292
|
+
const parsedMode = parseMode(mode);
|
|
293
|
+
const finiteFallbackLimit = limit ?? (parsedStartTime ? 300 : 100);
|
|
294
|
+
const effectiveLimit =
|
|
295
|
+
limit ??
|
|
296
|
+
(parsedStartTime && (parsedMode === "xurl" || parsedMode === "auto")
|
|
297
|
+
? Infinity
|
|
298
|
+
: finiteFallbackLimit);
|
|
299
|
+
assertLimit(effectiveLimit);
|
|
300
|
+
const parsedMaxPages =
|
|
301
|
+
maxPages === undefined && parsedStartTime
|
|
302
|
+
? Infinity
|
|
303
|
+
: parseMaxPages(maxPages);
|
|
156
304
|
const db = getNativeDb();
|
|
157
|
-
const
|
|
158
|
-
const
|
|
305
|
+
const resolvedAccount = resolveAccount(db, account);
|
|
306
|
+
const accountId = resolvedAccount.accountId;
|
|
307
|
+
const effectiveMode =
|
|
308
|
+
parsedMode === "auto" &&
|
|
309
|
+
account !== undefined &&
|
|
310
|
+
!resolvedAccount.isDefault
|
|
311
|
+
? "xurl"
|
|
312
|
+
: parsedMode;
|
|
313
|
+
const cacheKey = `timeline:${effectiveMode}:${accountId}:${following ? "following" : "for-you"}:${Number.isFinite(effectiveLimit) ? String(effectiveLimit) : "all"}:${Number.isFinite(parsedMaxPages) ? String(parsedMaxPages) : "all-pages"}:${parsedStartTime?.iso ?? "no-start"}`;
|
|
159
314
|
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
160
315
|
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
161
316
|
const cacheAgeMs = cached
|
|
@@ -163,6 +318,14 @@ export function syncHomeTimelineEffect({
|
|
|
163
318
|
: Number.POSITIVE_INFINITY;
|
|
164
319
|
|
|
165
320
|
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
321
|
+
yield* Effect.sync(() =>
|
|
322
|
+
onProgress?.({
|
|
323
|
+
source: "cache",
|
|
324
|
+
fetched: cached.value.data.length,
|
|
325
|
+
total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
|
|
326
|
+
done: true,
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
166
329
|
return {
|
|
167
330
|
ok: true,
|
|
168
331
|
source: "cache",
|
|
@@ -174,16 +337,106 @@ export function syncHomeTimelineEffect({
|
|
|
174
337
|
} as const;
|
|
175
338
|
}
|
|
176
339
|
|
|
177
|
-
const
|
|
178
|
-
|
|
340
|
+
const fetchViaXurl = Effect.gen(function* () {
|
|
341
|
+
if (!following) {
|
|
342
|
+
return yield* Effect.fail(
|
|
343
|
+
new Error("xurl home timeline mode does not support --for-you"),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const pages: XurlMentionsResponse[] = [];
|
|
347
|
+
let nextToken: string | undefined;
|
|
348
|
+
for (let page = 0; page < parsedMaxPages; page += 1) {
|
|
349
|
+
const fetchedCount = pages.reduce(
|
|
350
|
+
(sum, item) => sum + item.data.length,
|
|
351
|
+
0,
|
|
352
|
+
);
|
|
353
|
+
const remaining = Number.isFinite(effectiveLimit)
|
|
354
|
+
? Math.max(1, effectiveLimit - fetchedCount)
|
|
355
|
+
: Infinity;
|
|
356
|
+
const pageSize = Math.min(
|
|
357
|
+
MAX_XURL_TIMELINE_PAGE_SIZE,
|
|
358
|
+
Math.max(5, remaining),
|
|
359
|
+
);
|
|
360
|
+
const pagePayload = yield* listHomeTimelineViaXurlEffect({
|
|
361
|
+
maxResults: pageSize,
|
|
362
|
+
userId: resolvedAccount.externalUserId,
|
|
363
|
+
username: resolvedAccount.username,
|
|
364
|
+
paginationToken: nextToken,
|
|
365
|
+
timeoutMs,
|
|
366
|
+
});
|
|
367
|
+
pages.push(pagePayload);
|
|
368
|
+
nextToken =
|
|
369
|
+
typeof pagePayload.meta?.next_token === "string"
|
|
370
|
+
? pagePayload.meta.next_token
|
|
371
|
+
: undefined;
|
|
372
|
+
const totalFetched = fetchedCount + pagePayload.data.length;
|
|
373
|
+
const done =
|
|
374
|
+
!nextToken ||
|
|
375
|
+
(Number.isFinite(parsedMaxPages) && page + 1 >= parsedMaxPages) ||
|
|
376
|
+
(Number.isFinite(effectiveLimit) && totalFetched >= effectiveLimit) ||
|
|
377
|
+
reachedStartTimeBoundary(pagePayload, parsedStartTime?.time);
|
|
378
|
+
yield* Effect.sync(() =>
|
|
379
|
+
onProgress?.({
|
|
380
|
+
source: "xurl",
|
|
381
|
+
fetched: totalFetched,
|
|
382
|
+
total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
|
|
383
|
+
page: page + 1,
|
|
384
|
+
maxPages: Number.isFinite(parsedMaxPages)
|
|
385
|
+
? parsedMaxPages
|
|
386
|
+
: undefined,
|
|
387
|
+
pageSize,
|
|
388
|
+
done,
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
if (done) {
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return mergeTimelinePayloads(pages, effectiveLimit);
|
|
396
|
+
});
|
|
397
|
+
const fetchViaBird = listHomeTimelineViaBirdEffect({
|
|
398
|
+
maxResults: finiteFallbackLimit,
|
|
179
399
|
following,
|
|
180
400
|
});
|
|
181
|
-
|
|
401
|
+
let source: "bird" | "xurl";
|
|
402
|
+
let payload: XurlMentionsResponse;
|
|
403
|
+
if (effectiveMode === "xurl") {
|
|
404
|
+
payload = yield* fetchViaXurl;
|
|
405
|
+
source = "xurl";
|
|
406
|
+
} else if (effectiveMode === "auto") {
|
|
407
|
+
const fetched = yield* fetchViaXurl.pipe(
|
|
408
|
+
Effect.map((value) => ({ source: "xurl" as const, value })),
|
|
409
|
+
Effect.catchAll(() =>
|
|
410
|
+
fetchViaBird.pipe(
|
|
411
|
+
Effect.map((value) => ({ source: "bird" as const, value })),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
payload = fetched.value;
|
|
416
|
+
source = fetched.source;
|
|
417
|
+
} else {
|
|
418
|
+
payload = yield* listHomeTimelineViaBirdEffect({
|
|
419
|
+
maxResults: finiteFallbackLimit,
|
|
420
|
+
following,
|
|
421
|
+
});
|
|
422
|
+
source = "bird";
|
|
423
|
+
}
|
|
424
|
+
if (source === "bird") {
|
|
425
|
+
yield* Effect.sync(() =>
|
|
426
|
+
onProgress?.({
|
|
427
|
+
source: "bird",
|
|
428
|
+
fetched: payload.data.length,
|
|
429
|
+
total: finiteFallbackLimit,
|
|
430
|
+
done: true,
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
mergeHomeTimelineIntoLocalStore(db, accountId, payload, source);
|
|
182
435
|
writeSyncCache(cacheKey, payload, db);
|
|
183
436
|
|
|
184
437
|
return {
|
|
185
438
|
ok: true,
|
|
186
|
-
source
|
|
439
|
+
source,
|
|
187
440
|
kind: "timeline",
|
|
188
441
|
accountId,
|
|
189
442
|
feed: following ? "following" : "for-you",
|
package/src/lib/tweet-render.ts
CHANGED
|
@@ -57,6 +57,77 @@ export function displayUrlForLink(url: string) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function asRecord(value: unknown) {
|
|
61
|
+
return value && typeof value === "object"
|
|
62
|
+
? (value as Record<string, unknown>)
|
|
63
|
+
: {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
67
|
+
const entities = asRecord(raw);
|
|
68
|
+
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
69
|
+
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
70
|
+
const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...(rawMentions.length
|
|
74
|
+
? {
|
|
75
|
+
mentions: rawMentions.map((mention) => {
|
|
76
|
+
const value = asRecord(mention);
|
|
77
|
+
return {
|
|
78
|
+
username: String(value.username ?? ""),
|
|
79
|
+
id: typeof value.id === "string" ? String(value.id) : undefined,
|
|
80
|
+
start: Number(value.start ?? 0),
|
|
81
|
+
end: Number(value.end ?? 0),
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
...(rawUrls.length
|
|
87
|
+
? {
|
|
88
|
+
urls: rawUrls.map((url) => {
|
|
89
|
+
const value = asRecord(url);
|
|
90
|
+
const expandedUrl = String(
|
|
91
|
+
value.expandedUrl ?? value.expanded_url ?? value.url ?? "",
|
|
92
|
+
);
|
|
93
|
+
return {
|
|
94
|
+
url: String(value.url ?? ""),
|
|
95
|
+
expandedUrl,
|
|
96
|
+
displayUrl: String(
|
|
97
|
+
value.displayUrl ??
|
|
98
|
+
value.display_url ??
|
|
99
|
+
expandedUrl ??
|
|
100
|
+
value.url ??
|
|
101
|
+
"",
|
|
102
|
+
),
|
|
103
|
+
start: Number(value.start ?? 0),
|
|
104
|
+
end: Number(value.end ?? 0),
|
|
105
|
+
};
|
|
106
|
+
}),
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
109
|
+
...(rawHashtags.length
|
|
110
|
+
? {
|
|
111
|
+
hashtags: rawHashtags.map((hashtag) => {
|
|
112
|
+
const value = asRecord(hashtag);
|
|
113
|
+
return {
|
|
114
|
+
tag: String(value.tag ?? value.text ?? ""),
|
|
115
|
+
start: Number(value.start ?? 0),
|
|
116
|
+
end: Number(value.end ?? 0),
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function profileDescriptionEntitiesFromXurl(
|
|
125
|
+
raw: unknown,
|
|
126
|
+
): TweetEntities {
|
|
127
|
+
const entities = asRecord(raw);
|
|
128
|
+
return tweetEntitiesFromXurl(entities.description);
|
|
129
|
+
}
|
|
130
|
+
|
|
60
131
|
function spansOverlap(
|
|
61
132
|
leftStart: number,
|
|
62
133
|
leftEnd: number,
|
|
@@ -66,6 +137,66 @@ function spansOverlap(
|
|
|
66
137
|
return leftStart < rightEnd && rightStart < leftEnd;
|
|
67
138
|
}
|
|
68
139
|
|
|
140
|
+
function stringIndexFromCodePointIndex(text: string, index: number) {
|
|
141
|
+
if (index <= 0) return 0;
|
|
142
|
+
let stringIndex = 0;
|
|
143
|
+
let codePointIndex = 0;
|
|
144
|
+
for (const character of text) {
|
|
145
|
+
if (codePointIndex >= index) break;
|
|
146
|
+
stringIndex += character.length;
|
|
147
|
+
codePointIndex += 1;
|
|
148
|
+
}
|
|
149
|
+
return stringIndex;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function segmentTextMatches(
|
|
153
|
+
text: string,
|
|
154
|
+
segment: TweetSegment,
|
|
155
|
+
start: number,
|
|
156
|
+
end: number,
|
|
157
|
+
) {
|
|
158
|
+
const slice = text.slice(start, end);
|
|
159
|
+
if (segment.kind === "mention") {
|
|
160
|
+
return slice.toLowerCase() === `@${segment.username}`.toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
if (segment.kind === "hashtag") {
|
|
163
|
+
return slice.toLowerCase() === `#${segment.tag}`.toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
return slice === segment.url;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeSegmentTextRange(text: string, segment: TweetSegment) {
|
|
169
|
+
if (
|
|
170
|
+
segment.start >= 0 &&
|
|
171
|
+
segment.end > segment.start &&
|
|
172
|
+
segment.end <= text.length &&
|
|
173
|
+
segmentTextMatches(text, segment, segment.start, segment.end)
|
|
174
|
+
) {
|
|
175
|
+
return segment;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const start = stringIndexFromCodePointIndex(text, segment.start);
|
|
179
|
+
const end = stringIndexFromCodePointIndex(text, segment.end);
|
|
180
|
+
if (
|
|
181
|
+
start >= 0 &&
|
|
182
|
+
end > start &&
|
|
183
|
+
end <= text.length &&
|
|
184
|
+
segmentTextMatches(text, segment, start, end)
|
|
185
|
+
) {
|
|
186
|
+
return { ...segment, start, end };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return segment;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function normalizeTweetUrlEntityRangeForText(
|
|
193
|
+
text: string,
|
|
194
|
+
entry: TweetUrlEntity,
|
|
195
|
+
) {
|
|
196
|
+
const normalized = normalizeSegmentTextRange(text, { ...entry, kind: "url" });
|
|
197
|
+
return { start: normalized.start, end: normalized.end };
|
|
198
|
+
}
|
|
199
|
+
|
|
69
200
|
export function enrichFallbackUrlEntities(
|
|
70
201
|
text: string,
|
|
71
202
|
entities: TweetEntities,
|
|
@@ -150,12 +281,21 @@ export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
|
|
|
150
281
|
].sort((left, right) => left.start - right.start);
|
|
151
282
|
}
|
|
152
283
|
|
|
284
|
+
export function collectTweetSegmentsForText(
|
|
285
|
+
text: string,
|
|
286
|
+
entities: TweetEntities,
|
|
287
|
+
) {
|
|
288
|
+
return collectTweetSegments(entities)
|
|
289
|
+
.map((segment) => normalizeSegmentTextRange(text, segment))
|
|
290
|
+
.sort((left, right) => left.start - right.start);
|
|
291
|
+
}
|
|
292
|
+
|
|
153
293
|
function renderTweetText(
|
|
154
294
|
text: string,
|
|
155
295
|
entities: TweetEntities,
|
|
156
296
|
renderSegment: (segment: TweetSegment, fallback: string) => string,
|
|
157
297
|
) {
|
|
158
|
-
const segments =
|
|
298
|
+
const segments = collectTweetSegmentsForText(text, entities);
|
|
159
299
|
let cursor = 0;
|
|
160
300
|
let output = "";
|
|
161
301
|
|