birdclaw 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
package/src/lib/timeline-live.ts
CHANGED
|
@@ -1,13 +1,44 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import type { Database } from "./sqlite";
|
|
2
|
-
import {
|
|
3
|
+
import { listHomeTimelineViaBirdEffect } from "./bird";
|
|
3
4
|
import { getNativeDb } from "./db";
|
|
5
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
4
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
5
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
6
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
XurlMediaItem,
|
|
10
|
+
XurlMentionUser,
|
|
11
|
+
XurlMentionsResponse,
|
|
12
|
+
} from "./types";
|
|
7
13
|
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
8
14
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
15
|
+
import { listHomeTimelineViaXurlEffect } from "./xurl";
|
|
9
16
|
|
|
10
17
|
const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
|
|
18
|
+
const MAX_XURL_TIMELINE_PAGE_SIZE = 100;
|
|
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
|
+
}
|
|
30
|
+
export interface SyncHomeTimelineOptions {
|
|
31
|
+
account?: string;
|
|
32
|
+
mode?: HomeTimelineMode;
|
|
33
|
+
limit?: number;
|
|
34
|
+
maxPages?: number;
|
|
35
|
+
startTime?: string;
|
|
36
|
+
following?: boolean;
|
|
37
|
+
refresh?: boolean;
|
|
38
|
+
cacheTtlMs?: number;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
onProgress?: (progress: HomeTimelineProgress) => void;
|
|
41
|
+
}
|
|
11
42
|
|
|
12
43
|
function parseCacheTtlMs(value?: number) {
|
|
13
44
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
@@ -17,32 +48,130 @@ function parseCacheTtlMs(value?: number) {
|
|
|
17
48
|
}
|
|
18
49
|
|
|
19
50
|
function assertLimit(limit: number) {
|
|
20
|
-
if (!Number.isFinite(limit) || limit < 1) {
|
|
51
|
+
if ((!Number.isFinite(limit) && limit !== Infinity) || limit < 1) {
|
|
21
52
|
throw new Error("--limit must be at least 1");
|
|
22
53
|
}
|
|
23
54
|
}
|
|
24
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
|
+
|
|
25
135
|
function resolveAccount(db: Database, accountId?: string) {
|
|
26
136
|
const row = accountId
|
|
27
|
-
? (db
|
|
28
|
-
|
|
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
|
+
})
|
|
29
145
|
| undefined)
|
|
30
146
|
: (db
|
|
31
147
|
.prepare(
|
|
32
148
|
`
|
|
33
|
-
select id
|
|
149
|
+
select id, handle, external_user_id, is_default as isDefault
|
|
34
150
|
from accounts
|
|
35
151
|
order by is_default desc, created_at asc
|
|
36
152
|
limit 1
|
|
37
153
|
`,
|
|
38
154
|
)
|
|
39
|
-
.get() as
|
|
155
|
+
.get() as
|
|
156
|
+
| ({ id: string; handle: string; external_user_id: string | null } & {
|
|
157
|
+
isDefault: number;
|
|
158
|
+
})
|
|
159
|
+
| undefined);
|
|
40
160
|
|
|
41
161
|
if (!row) {
|
|
42
162
|
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
43
163
|
}
|
|
44
164
|
|
|
45
|
-
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
|
+
};
|
|
46
175
|
}
|
|
47
176
|
|
|
48
177
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
@@ -57,6 +186,7 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
57
186
|
db: Database,
|
|
58
187
|
accountId: string,
|
|
59
188
|
payload: XurlMentionsResponse,
|
|
189
|
+
source: "bird" | "xurl",
|
|
60
190
|
) {
|
|
61
191
|
const usersById = new Map(
|
|
62
192
|
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
@@ -67,13 +197,15 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
67
197
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
68
198
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
69
199
|
entities_json, media_json, quoted_tweet_id
|
|
70
|
-
) values (?, ?, ?, 'home', ?, ?,
|
|
200
|
+
) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
71
201
|
on conflict(id) do update set
|
|
72
202
|
account_id = tweets.account_id,
|
|
73
203
|
author_profile_id = excluded.author_profile_id,
|
|
74
204
|
kind = tweets.kind,
|
|
75
205
|
text = excluded.text,
|
|
76
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),
|
|
77
209
|
like_count = excluded.like_count,
|
|
78
210
|
media_count = max(tweets.media_count, excluded.media_count),
|
|
79
211
|
entities_json = excluded.entities_json,
|
|
@@ -82,7 +214,8 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
82
214
|
else tweets.media_json
|
|
83
215
|
end,
|
|
84
216
|
bookmarked = tweets.bookmarked,
|
|
85
|
-
liked = tweets.liked
|
|
217
|
+
liked = tweets.liked,
|
|
218
|
+
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
|
|
86
219
|
`,
|
|
87
220
|
);
|
|
88
221
|
|
|
@@ -99,22 +232,27 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
99
232
|
const profile = usersById.has(tweet.author_id)
|
|
100
233
|
? upsertProfileFromXUser(db, author)
|
|
101
234
|
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
235
|
+
const replyToId = getReferencedTweetId(tweet, "replied_to") ?? null;
|
|
236
|
+
const quotedTweetId = getReferencedTweetId(tweet, "quoted") ?? null;
|
|
102
237
|
upsertTweet.run(
|
|
103
238
|
tweet.id,
|
|
104
239
|
accountId,
|
|
105
240
|
profile.profile.id,
|
|
106
241
|
tweet.text,
|
|
107
242
|
tweet.created_at,
|
|
243
|
+
0,
|
|
244
|
+
replyToId,
|
|
108
245
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
109
246
|
countTweetMedia(tweet),
|
|
110
247
|
JSON.stringify(tweet.entities ?? {}),
|
|
111
248
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
249
|
+
quotedTweetId,
|
|
112
250
|
);
|
|
113
251
|
upsertTweetAccountEdge(db, {
|
|
114
252
|
accountId,
|
|
115
253
|
tweetId: tweet.id,
|
|
116
254
|
kind: "home",
|
|
117
|
-
source
|
|
255
|
+
source,
|
|
118
256
|
seenAt,
|
|
119
257
|
rawJson: JSON.stringify(tweet),
|
|
120
258
|
});
|
|
@@ -123,55 +261,191 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
123
261
|
})();
|
|
124
262
|
}
|
|
125
263
|
|
|
126
|
-
export
|
|
264
|
+
export function syncHomeTimelineEffect({
|
|
127
265
|
account,
|
|
128
|
-
|
|
266
|
+
mode,
|
|
267
|
+
limit,
|
|
268
|
+
maxPages,
|
|
269
|
+
startTime,
|
|
129
270
|
following = true,
|
|
130
271
|
refresh = false,
|
|
131
272
|
cacheTtlMs,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
273
|
+
timeoutMs,
|
|
274
|
+
onProgress,
|
|
275
|
+
}: SyncHomeTimelineOptions = {}): Effect.Effect<
|
|
276
|
+
{
|
|
277
|
+
ok: true;
|
|
278
|
+
source: "bird" | "xurl" | "cache";
|
|
279
|
+
kind: "timeline";
|
|
280
|
+
accountId: string;
|
|
281
|
+
feed: "following" | "for-you";
|
|
282
|
+
count: number;
|
|
283
|
+
payload: XurlMentionsResponse;
|
|
284
|
+
},
|
|
285
|
+
unknown
|
|
286
|
+
> {
|
|
287
|
+
return Effect.gen(function* () {
|
|
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);
|
|
304
|
+
const db = getNativeDb();
|
|
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"}`;
|
|
314
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
315
|
+
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
316
|
+
const cacheAgeMs = cached
|
|
317
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
318
|
+
: Number.POSITIVE_INFINITY;
|
|
319
|
+
|
|
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
|
+
);
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
source: "cache",
|
|
332
|
+
kind: "timeline",
|
|
333
|
+
accountId,
|
|
334
|
+
feed: following ? "following" : "for-you",
|
|
335
|
+
count: cached.value.data.length,
|
|
336
|
+
payload: cached.value,
|
|
337
|
+
} as const;
|
|
338
|
+
}
|
|
339
|
+
|
|
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,
|
|
399
|
+
following,
|
|
400
|
+
});
|
|
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);
|
|
435
|
+
writeSyncCache(cacheKey, payload, db);
|
|
436
|
+
|
|
150
437
|
return {
|
|
151
438
|
ok: true,
|
|
152
|
-
source
|
|
439
|
+
source,
|
|
153
440
|
kind: "timeline",
|
|
154
441
|
accountId,
|
|
155
442
|
feed: following ? "following" : "for-you",
|
|
156
|
-
count:
|
|
157
|
-
payload
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const payload = await listHomeTimelineViaBird({
|
|
162
|
-
maxResults: limit,
|
|
163
|
-
following,
|
|
443
|
+
count: payload.data.length,
|
|
444
|
+
payload,
|
|
445
|
+
} as const;
|
|
164
446
|
});
|
|
165
|
-
|
|
166
|
-
writeSyncCache(cacheKey, payload, db);
|
|
447
|
+
}
|
|
167
448
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
source: "bird",
|
|
171
|
-
kind: "timeline",
|
|
172
|
-
accountId,
|
|
173
|
-
feed: following ? "following" : "for-you",
|
|
174
|
-
count: payload.data.length,
|
|
175
|
-
payload,
|
|
176
|
-
};
|
|
449
|
+
export function syncHomeTimeline(options: SyncHomeTimelineOptions = {}) {
|
|
450
|
+
return runEffectPromise(syncHomeTimelineEffect(options));
|
|
177
451
|
}
|
package/src/lib/tweet-lookup.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { lookupTweetsByIdsViaBirdEffect } from "./bird";
|
|
3
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
2
4
|
import type { XurlTweetsResponse } from "./types";
|
|
3
|
-
import {
|
|
5
|
+
import { lookupTweetsByIdsEffect as lookupTweetsByIdsViaXurlEffect } from "./xurl";
|
|
4
6
|
|
|
5
7
|
export type TweetLookupMode = "auto" | "xurl" | "bird";
|
|
6
8
|
|
|
@@ -8,28 +10,37 @@ function errorMessage(error: unknown) {
|
|
|
8
10
|
return error instanceof Error ? error.message : String(error);
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
export
|
|
13
|
+
export function lookupTweetsByIdsEffect(
|
|
12
14
|
ids: string[],
|
|
13
15
|
mode: TweetLookupMode = "auto",
|
|
14
|
-
):
|
|
16
|
+
): Effect.Effect<XurlTweetsResponse, unknown> {
|
|
15
17
|
if (mode === "bird") {
|
|
16
|
-
return
|
|
18
|
+
return lookupTweetsByIdsViaBirdEffect(ids);
|
|
17
19
|
}
|
|
18
20
|
if (mode === "xurl") {
|
|
19
|
-
return
|
|
21
|
+
return lookupTweetsByIdsViaXurlEffect(ids);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
return lookupTweetsByIdsViaXurlEffect(ids).pipe(
|
|
25
|
+
Effect.catchAll((xurlError) =>
|
|
26
|
+
lookupTweetsByIdsViaBirdEffect(ids).pipe(
|
|
27
|
+
Effect.catchAll((birdError) =>
|
|
28
|
+
Effect.fail(
|
|
29
|
+
new Error(
|
|
30
|
+
`Tweet lookup failed via xurl and bird: xurl: ${errorMessage(
|
|
31
|
+
xurlError,
|
|
32
|
+
)}; bird: ${errorMessage(birdError)}`,
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function lookupTweetsByIds(
|
|
42
|
+
ids: string[],
|
|
43
|
+
mode: TweetLookupMode = "auto",
|
|
44
|
+
): Promise<XurlTweetsResponse> {
|
|
45
|
+
return runEffectPromise(lookupTweetsByIdsEffect(ids, mode));
|
|
35
46
|
}
|