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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { searchTweetsViaBirdEffect } from "./bird";
|
|
3
|
+
import type { Database } from "./sqlite";
|
|
4
|
+
import { getNativeDb } from "./db";
|
|
5
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
6
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
|
|
9
|
+
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
10
|
+
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
11
|
+
import { searchRecentTweetsEffect } from "./xurl";
|
|
12
|
+
|
|
13
|
+
export type TweetSearchMode = "auto" | "bird" | "xurl" | "local";
|
|
14
|
+
|
|
15
|
+
export interface SyncTweetSearchOptions {
|
|
16
|
+
query: string;
|
|
17
|
+
account?: string;
|
|
18
|
+
mode?: TweetSearchMode;
|
|
19
|
+
limit?: number;
|
|
20
|
+
maxPages?: number;
|
|
21
|
+
since?: string;
|
|
22
|
+
until?: string;
|
|
23
|
+
refresh?: boolean;
|
|
24
|
+
cacheTtlMs?: number;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SyncTweetSearchResult =
|
|
29
|
+
| {
|
|
30
|
+
ok: true;
|
|
31
|
+
source: "bird" | "xurl" | "bird+xurl" | "cache";
|
|
32
|
+
accountId: string;
|
|
33
|
+
query: string;
|
|
34
|
+
count: number;
|
|
35
|
+
pageCount: number;
|
|
36
|
+
tweetIds: string[];
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
ok: false;
|
|
40
|
+
source: "bird" | "xurl" | "auto";
|
|
41
|
+
accountId: string;
|
|
42
|
+
query: string;
|
|
43
|
+
error: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SEARCH_LIMIT = 20_000;
|
|
47
|
+
const DEFAULT_MAX_PAGES = 200;
|
|
48
|
+
const DEFAULT_CACHE_TTL_MS = 2 * 60_000;
|
|
49
|
+
const XURL_PAGE_SIZE = 100;
|
|
50
|
+
|
|
51
|
+
function toError(error: unknown) {
|
|
52
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function trySync<T>(try_: () => T) {
|
|
56
|
+
return Effect.try({
|
|
57
|
+
try: try_,
|
|
58
|
+
catch: toError,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeLimit(limit: number | undefined) {
|
|
63
|
+
if (limit === undefined) return DEFAULT_SEARCH_LIMIT;
|
|
64
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
65
|
+
throw new Error("--limit must be at least 1");
|
|
66
|
+
}
|
|
67
|
+
return Math.floor(limit);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeMaxPages(maxPages: number | undefined) {
|
|
71
|
+
if (maxPages === undefined) return DEFAULT_MAX_PAGES;
|
|
72
|
+
if (!Number.isFinite(maxPages) || maxPages < 1) {
|
|
73
|
+
throw new Error("--max-pages must be at least 1");
|
|
74
|
+
}
|
|
75
|
+
return Math.floor(maxPages);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeTime(value: string | undefined, optionName: string) {
|
|
79
|
+
if (!value?.trim()) return undefined;
|
|
80
|
+
const date = new Date(value);
|
|
81
|
+
if (!Number.isFinite(date.getTime())) {
|
|
82
|
+
throw new Error(`${optionName} must be a valid date`);
|
|
83
|
+
}
|
|
84
|
+
return date.toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeCacheTtlMs(value: number | undefined) {
|
|
88
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
89
|
+
return DEFAULT_CACHE_TTL_MS;
|
|
90
|
+
}
|
|
91
|
+
return Math.floor(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveAccount(db: Database, accountId?: string) {
|
|
95
|
+
const row = accountId
|
|
96
|
+
? (db
|
|
97
|
+
.prepare("select id, handle from accounts where id = ?")
|
|
98
|
+
.get(accountId) as { id: string; handle: string } | undefined)
|
|
99
|
+
: (db
|
|
100
|
+
.prepare(
|
|
101
|
+
`
|
|
102
|
+
select id, handle
|
|
103
|
+
from accounts
|
|
104
|
+
order by is_default desc, created_at asc
|
|
105
|
+
limit 1
|
|
106
|
+
`,
|
|
107
|
+
)
|
|
108
|
+
.get() as { id: string; handle: string } | undefined);
|
|
109
|
+
|
|
110
|
+
if (!row) {
|
|
111
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
accountId: row.id,
|
|
116
|
+
username: row.handle.replace(/^@/, ""),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
121
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
122
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
123
|
+
tweetId,
|
|
124
|
+
text,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mergeTweetSearchIntoLocalStore(
|
|
129
|
+
db: Database,
|
|
130
|
+
accountId: string,
|
|
131
|
+
payload: XurlMentionsResponse,
|
|
132
|
+
source: "bird" | "xurl" | "cache",
|
|
133
|
+
) {
|
|
134
|
+
const usersById = new Map(
|
|
135
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
136
|
+
);
|
|
137
|
+
const upsertTweet = db.prepare(
|
|
138
|
+
`
|
|
139
|
+
insert into tweets (
|
|
140
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
141
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
142
|
+
entities_json, media_json, quoted_tweet_id
|
|
143
|
+
) values (?, ?, ?, 'search', ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
144
|
+
on conflict(id) do update set
|
|
145
|
+
account_id = tweets.account_id,
|
|
146
|
+
author_profile_id = excluded.author_profile_id,
|
|
147
|
+
kind = tweets.kind,
|
|
148
|
+
text = excluded.text,
|
|
149
|
+
created_at = excluded.created_at,
|
|
150
|
+
reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
|
|
151
|
+
like_count = excluded.like_count,
|
|
152
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
153
|
+
entities_json = excluded.entities_json,
|
|
154
|
+
media_json = case
|
|
155
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
156
|
+
else tweets.media_json
|
|
157
|
+
end,
|
|
158
|
+
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id),
|
|
159
|
+
bookmarked = tweets.bookmarked,
|
|
160
|
+
liked = tweets.liked
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
const tweetIds: string[] = [];
|
|
164
|
+
|
|
165
|
+
db.transaction(() => {
|
|
166
|
+
const seenAt = new Date().toISOString();
|
|
167
|
+
for (const tweet of payload.data) {
|
|
168
|
+
const author =
|
|
169
|
+
usersById.get(tweet.author_id) ??
|
|
170
|
+
({
|
|
171
|
+
id: tweet.author_id,
|
|
172
|
+
username: `user_${tweet.author_id}`,
|
|
173
|
+
name: `user_${tweet.author_id}`,
|
|
174
|
+
} as const);
|
|
175
|
+
const profile = usersById.has(tweet.author_id)
|
|
176
|
+
? upsertProfileFromXUser(db, author)
|
|
177
|
+
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
178
|
+
const replyToId =
|
|
179
|
+
tweet.referenced_tweets?.find((item) => item.type === "replied_to")
|
|
180
|
+
?.id ?? null;
|
|
181
|
+
const quotedTweetId =
|
|
182
|
+
tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
|
|
183
|
+
null;
|
|
184
|
+
upsertTweet.run(
|
|
185
|
+
tweet.id,
|
|
186
|
+
accountId,
|
|
187
|
+
profile.profile.id,
|
|
188
|
+
tweet.text,
|
|
189
|
+
tweet.created_at,
|
|
190
|
+
replyToId,
|
|
191
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
192
|
+
countTweetMedia(tweet),
|
|
193
|
+
JSON.stringify(tweet.entities ?? {}),
|
|
194
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
195
|
+
quotedTweetId,
|
|
196
|
+
);
|
|
197
|
+
upsertTweetAccountEdge(db, {
|
|
198
|
+
accountId,
|
|
199
|
+
tweetId: tweet.id,
|
|
200
|
+
kind: "search",
|
|
201
|
+
source,
|
|
202
|
+
seenAt,
|
|
203
|
+
rawJson: JSON.stringify(tweet),
|
|
204
|
+
});
|
|
205
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
206
|
+
tweetIds.push(tweet.id);
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
|
|
210
|
+
return tweetIds;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toMentionsResponse(payload: XurlTweetsResponse): XurlMentionsResponse {
|
|
214
|
+
return {
|
|
215
|
+
data: payload.data,
|
|
216
|
+
includes: payload.includes,
|
|
217
|
+
meta: payload.meta,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function mergeResponses(
|
|
222
|
+
responses: XurlMentionsResponse[],
|
|
223
|
+
): XurlMentionsResponse {
|
|
224
|
+
const seenTweetIds = new Set<string>();
|
|
225
|
+
const data = [];
|
|
226
|
+
const usersById = new Map();
|
|
227
|
+
const mediaByKey = new Map();
|
|
228
|
+
let pageCount = 0;
|
|
229
|
+
|
|
230
|
+
for (const response of responses) {
|
|
231
|
+
pageCount += Number(response.meta?.page_count ?? 1);
|
|
232
|
+
for (const user of response.includes?.users ?? []) {
|
|
233
|
+
usersById.set(user.id, user);
|
|
234
|
+
}
|
|
235
|
+
for (const media of response.includes?.media ?? []) {
|
|
236
|
+
mediaByKey.set(media.media_key, media);
|
|
237
|
+
}
|
|
238
|
+
for (const tweet of response.data) {
|
|
239
|
+
if (seenTweetIds.has(tweet.id)) continue;
|
|
240
|
+
seenTweetIds.add(tweet.id);
|
|
241
|
+
data.push(tweet);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
data,
|
|
247
|
+
includes: {
|
|
248
|
+
users: [...usersById.values()],
|
|
249
|
+
media: [...mediaByKey.values()],
|
|
250
|
+
},
|
|
251
|
+
meta: {
|
|
252
|
+
result_count: data.length,
|
|
253
|
+
page_count: pageCount,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function limitResponse(
|
|
259
|
+
response: XurlMentionsResponse,
|
|
260
|
+
limit: number,
|
|
261
|
+
): XurlMentionsResponse {
|
|
262
|
+
if (response.data.length <= limit) {
|
|
263
|
+
return response;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...response,
|
|
267
|
+
data: response.data.slice(0, limit),
|
|
268
|
+
meta: {
|
|
269
|
+
...response.meta,
|
|
270
|
+
result_count: limit,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function cacheKey({
|
|
276
|
+
query,
|
|
277
|
+
accountId,
|
|
278
|
+
mode,
|
|
279
|
+
limit,
|
|
280
|
+
maxPages,
|
|
281
|
+
since,
|
|
282
|
+
until,
|
|
283
|
+
}: {
|
|
284
|
+
query: string;
|
|
285
|
+
accountId: string;
|
|
286
|
+
mode: Exclude<TweetSearchMode, "auto" | "local">;
|
|
287
|
+
limit: number;
|
|
288
|
+
maxPages: number;
|
|
289
|
+
since?: string;
|
|
290
|
+
until?: string;
|
|
291
|
+
}) {
|
|
292
|
+
return `tweet-search:${mode}:${accountId}:${encodeURIComponent(query)}:${String(limit)}:${String(maxPages)}:${since ?? "no-since"}:${until ?? "no-until"}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function fetchBirdSearchEffect({
|
|
296
|
+
query,
|
|
297
|
+
limit,
|
|
298
|
+
maxPages,
|
|
299
|
+
}: {
|
|
300
|
+
query: string;
|
|
301
|
+
limit: number;
|
|
302
|
+
maxPages: number;
|
|
303
|
+
}) {
|
|
304
|
+
return searchTweetsViaBirdEffect(query, {
|
|
305
|
+
maxResults: Math.min(limit, XURL_PAGE_SIZE),
|
|
306
|
+
all: maxPages > 1 || limit > XURL_PAGE_SIZE,
|
|
307
|
+
maxPages,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function fetchXurlSearchEffect({
|
|
312
|
+
query,
|
|
313
|
+
limit,
|
|
314
|
+
maxPages,
|
|
315
|
+
timeoutMs,
|
|
316
|
+
since,
|
|
317
|
+
until,
|
|
318
|
+
}: {
|
|
319
|
+
query: string;
|
|
320
|
+
limit: number;
|
|
321
|
+
maxPages: number;
|
|
322
|
+
timeoutMs?: number;
|
|
323
|
+
since?: string;
|
|
324
|
+
until?: string;
|
|
325
|
+
}): Effect.Effect<XurlMentionsResponse, Error> {
|
|
326
|
+
return Effect.gen(function* () {
|
|
327
|
+
const responses: XurlMentionsResponse[] = [];
|
|
328
|
+
let nextToken: string | undefined;
|
|
329
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
330
|
+
const remaining =
|
|
331
|
+
limit -
|
|
332
|
+
responses.reduce((total, response) => total + response.data.length, 0);
|
|
333
|
+
if (remaining <= 0) break;
|
|
334
|
+
const response = yield* searchRecentTweetsEffect(query, {
|
|
335
|
+
maxResults: Math.max(10, Math.min(XURL_PAGE_SIZE, remaining)),
|
|
336
|
+
paginationToken: nextToken,
|
|
337
|
+
startTime: since,
|
|
338
|
+
endTime: until,
|
|
339
|
+
timeoutMs,
|
|
340
|
+
});
|
|
341
|
+
responses.push(toMentionsResponse(response));
|
|
342
|
+
nextToken =
|
|
343
|
+
typeof response.meta?.next_token === "string"
|
|
344
|
+
? String(response.meta.next_token)
|
|
345
|
+
: undefined;
|
|
346
|
+
if (!nextToken) break;
|
|
347
|
+
}
|
|
348
|
+
return mergeResponses(responses);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function runModeEffect(
|
|
353
|
+
mode: Exclude<TweetSearchMode, "auto" | "local">,
|
|
354
|
+
options: {
|
|
355
|
+
query: string;
|
|
356
|
+
accountId: string;
|
|
357
|
+
username: string;
|
|
358
|
+
limit: number;
|
|
359
|
+
maxPages: number;
|
|
360
|
+
since?: string;
|
|
361
|
+
until?: string;
|
|
362
|
+
refresh: boolean;
|
|
363
|
+
cacheTtlMs: number;
|
|
364
|
+
timeoutMs?: number;
|
|
365
|
+
},
|
|
366
|
+
): Effect.Effect<SyncTweetSearchResult, Error> {
|
|
367
|
+
return Effect.gen(function* () {
|
|
368
|
+
const db = getNativeDb();
|
|
369
|
+
const key = cacheKey({ ...options, mode });
|
|
370
|
+
const cached = yield* trySync(() =>
|
|
371
|
+
readSyncCache<XurlMentionsResponse>(key, db),
|
|
372
|
+
);
|
|
373
|
+
const ageMs = cached
|
|
374
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
375
|
+
: Number.POSITIVE_INFINITY;
|
|
376
|
+
const payload =
|
|
377
|
+
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
378
|
+
? cached.value
|
|
379
|
+
: yield* (
|
|
380
|
+
mode === "bird"
|
|
381
|
+
? fetchBirdSearchEffect(options).pipe(Effect.mapError(toError))
|
|
382
|
+
: fetchXurlSearchEffect(options)
|
|
383
|
+
).pipe(
|
|
384
|
+
Effect.map((response) => limitResponse(response, options.limit)),
|
|
385
|
+
);
|
|
386
|
+
if (!cached || options.refresh || ageMs > options.cacheTtlMs) {
|
|
387
|
+
yield* trySync(() => writeSyncCache(key, payload, db));
|
|
388
|
+
}
|
|
389
|
+
const tweetIds = yield* trySync(() =>
|
|
390
|
+
mergeTweetSearchIntoLocalStore(
|
|
391
|
+
db,
|
|
392
|
+
options.accountId,
|
|
393
|
+
payload,
|
|
394
|
+
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
395
|
+
? "cache"
|
|
396
|
+
: mode,
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
source:
|
|
403
|
+
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
404
|
+
? "cache"
|
|
405
|
+
: mode,
|
|
406
|
+
accountId: options.accountId,
|
|
407
|
+
query: options.query,
|
|
408
|
+
count: tweetIds.length,
|
|
409
|
+
pageCount: Number(payload.meta?.page_count ?? 1),
|
|
410
|
+
tweetIds,
|
|
411
|
+
} as const;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function combineTweetSearchResults(
|
|
416
|
+
left: SyncTweetSearchResult,
|
|
417
|
+
right: SyncTweetSearchResult,
|
|
418
|
+
limit: number,
|
|
419
|
+
): SyncTweetSearchResult {
|
|
420
|
+
if (left.ok && right.ok) {
|
|
421
|
+
const tweetIds = [...new Set([...left.tweetIds, ...right.tweetIds])].slice(
|
|
422
|
+
0,
|
|
423
|
+
limit,
|
|
424
|
+
);
|
|
425
|
+
const liveSources = new Set(
|
|
426
|
+
[left.source, right.source].filter((source) => source !== "cache"),
|
|
427
|
+
);
|
|
428
|
+
return {
|
|
429
|
+
ok: true,
|
|
430
|
+
source:
|
|
431
|
+
liveSources.has("bird") && liveSources.has("xurl")
|
|
432
|
+
? "bird+xurl"
|
|
433
|
+
: liveSources.has("bird")
|
|
434
|
+
? "bird"
|
|
435
|
+
: liveSources.has("xurl")
|
|
436
|
+
? "xurl"
|
|
437
|
+
: "cache",
|
|
438
|
+
accountId: left.accountId,
|
|
439
|
+
query: left.query,
|
|
440
|
+
count: tweetIds.length,
|
|
441
|
+
pageCount: left.pageCount + right.pageCount,
|
|
442
|
+
tweetIds,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (left.ok) return left;
|
|
446
|
+
if (right.ok) return right;
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
source: "auto",
|
|
450
|
+
accountId: left.accountId,
|
|
451
|
+
query: left.query,
|
|
452
|
+
error: `${left.error}; ${right.error}`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function syncTweetSearchEffect({
|
|
457
|
+
query,
|
|
458
|
+
account,
|
|
459
|
+
mode = "auto",
|
|
460
|
+
limit,
|
|
461
|
+
maxPages,
|
|
462
|
+
since,
|
|
463
|
+
until,
|
|
464
|
+
refresh = false,
|
|
465
|
+
cacheTtlMs,
|
|
466
|
+
timeoutMs,
|
|
467
|
+
}: SyncTweetSearchOptions): Effect.Effect<SyncTweetSearchResult, Error> {
|
|
468
|
+
return Effect.gen(function* () {
|
|
469
|
+
const normalizedQuery = query.trim();
|
|
470
|
+
if (!normalizedQuery) {
|
|
471
|
+
return yield* Effect.fail(new Error("Search query is required"));
|
|
472
|
+
}
|
|
473
|
+
const normalizedLimit = normalizeLimit(limit);
|
|
474
|
+
const normalizedMaxPages = normalizeMaxPages(maxPages);
|
|
475
|
+
const normalizedSince = yield* trySync(() =>
|
|
476
|
+
normalizeTime(since, "--since"),
|
|
477
|
+
);
|
|
478
|
+
const normalizedUntil = yield* trySync(() =>
|
|
479
|
+
normalizeTime(until, "--until"),
|
|
480
|
+
);
|
|
481
|
+
const ttlMs = normalizeCacheTtlMs(cacheTtlMs);
|
|
482
|
+
const db = getNativeDb();
|
|
483
|
+
const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
|
|
484
|
+
const accountId = resolvedAccount.accountId;
|
|
485
|
+
if (mode === "local") {
|
|
486
|
+
return {
|
|
487
|
+
ok: true,
|
|
488
|
+
source: "cache",
|
|
489
|
+
accountId,
|
|
490
|
+
query: normalizedQuery,
|
|
491
|
+
count: 0,
|
|
492
|
+
pageCount: 0,
|
|
493
|
+
tweetIds: [],
|
|
494
|
+
} as const;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const runOptions = {
|
|
498
|
+
query: normalizedQuery,
|
|
499
|
+
accountId,
|
|
500
|
+
username: resolvedAccount.username,
|
|
501
|
+
limit: normalizedLimit,
|
|
502
|
+
maxPages: normalizedMaxPages,
|
|
503
|
+
since: normalizedSince,
|
|
504
|
+
until: normalizedUntil,
|
|
505
|
+
refresh,
|
|
506
|
+
cacheTtlMs: ttlMs,
|
|
507
|
+
timeoutMs,
|
|
508
|
+
};
|
|
509
|
+
if (mode === "bird" || mode === "xurl") {
|
|
510
|
+
return yield* runModeEffect(mode, runOptions).pipe(
|
|
511
|
+
Effect.catchAll((error) =>
|
|
512
|
+
Effect.succeed({
|
|
513
|
+
ok: false,
|
|
514
|
+
source: mode,
|
|
515
|
+
accountId,
|
|
516
|
+
query: normalizedQuery,
|
|
517
|
+
error: error.message,
|
|
518
|
+
} as const),
|
|
519
|
+
),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (normalizedSince || normalizedUntil) {
|
|
524
|
+
return yield* runModeEffect("xurl", runOptions).pipe(
|
|
525
|
+
Effect.catchAll((error) =>
|
|
526
|
+
Effect.succeed({
|
|
527
|
+
ok: false,
|
|
528
|
+
source: "auto",
|
|
529
|
+
accountId,
|
|
530
|
+
query: normalizedQuery,
|
|
531
|
+
error: error.message,
|
|
532
|
+
} as const),
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const birdResult = yield* runModeEffect("bird", runOptions).pipe(
|
|
538
|
+
Effect.catchAll((error) =>
|
|
539
|
+
Effect.succeed({
|
|
540
|
+
ok: false,
|
|
541
|
+
source: "bird",
|
|
542
|
+
accountId,
|
|
543
|
+
query: normalizedQuery,
|
|
544
|
+
error: error.message,
|
|
545
|
+
} as const),
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
const xurlResult = yield* runModeEffect("xurl", runOptions).pipe(
|
|
549
|
+
Effect.catchAll((error) =>
|
|
550
|
+
Effect.succeed({
|
|
551
|
+
ok: false,
|
|
552
|
+
source: "xurl",
|
|
553
|
+
accountId,
|
|
554
|
+
query: normalizedQuery,
|
|
555
|
+
error: error.message,
|
|
556
|
+
} as const),
|
|
557
|
+
),
|
|
558
|
+
);
|
|
559
|
+
return combineTweetSearchResults(birdResult, xurlResult, normalizedLimit);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function syncTweetSearch(options: SyncTweetSearchOptions) {
|
|
564
|
+
return runEffectPromise(syncTweetSearchEffect(options));
|
|
565
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ResourceKind = "home" | "mentions" | "authored" | "dms";
|
|
1
|
+
export type ResourceKind = "home" | "mentions" | "authored" | "search" | "dms";
|
|
2
2
|
export type InboxKind = "mixed" | "mentions" | "dms";
|
|
3
3
|
|
|
4
4
|
export type ReplyFilter = "all" | "replied" | "unreplied";
|
|
@@ -165,7 +165,7 @@ export interface TimelineItem {
|
|
|
165
165
|
id: string;
|
|
166
166
|
accountId: string;
|
|
167
167
|
accountHandle: string;
|
|
168
|
-
kind: "home" | "mention" | "authored" | "like" | "bookmark";
|
|
168
|
+
kind: "home" | "mention" | "authored" | "search" | "like" | "bookmark";
|
|
169
169
|
text: string;
|
|
170
170
|
searchSnippet?: string;
|
|
171
171
|
createdAt: string;
|
|
@@ -356,6 +356,7 @@ export interface TimelineQuery {
|
|
|
356
356
|
replyFilter?: ReplyFilter;
|
|
357
357
|
since?: string;
|
|
358
358
|
until?: string;
|
|
359
|
+
untilId?: string;
|
|
359
360
|
includeReplies?: boolean;
|
|
360
361
|
qualityFilter?: TimelineQualityFilter;
|
|
361
362
|
lowQualityThreshold?: number;
|
|
@@ -390,6 +391,40 @@ export interface TransportStatus {
|
|
|
390
391
|
rawStatus?: string;
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
export type LiveDataSourceKind = "birdclaw" | "bird" | "xurl";
|
|
395
|
+
|
|
396
|
+
export interface LiveDataSourceAccount {
|
|
397
|
+
id?: string;
|
|
398
|
+
username?: string;
|
|
399
|
+
handle?: string;
|
|
400
|
+
app?: string;
|
|
401
|
+
isDefault?: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export interface LiveDataSourceStatus {
|
|
405
|
+
source: LiveDataSourceKind;
|
|
406
|
+
label: string;
|
|
407
|
+
works: boolean;
|
|
408
|
+
installed?: boolean;
|
|
409
|
+
status: "ok" | "warning" | "error";
|
|
410
|
+
detail: string;
|
|
411
|
+
accounts: LiveDataSourceAccount[];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface LiveDataSourceCapability {
|
|
415
|
+
key: string;
|
|
416
|
+
label: string;
|
|
417
|
+
primary: LiveDataSourceKind;
|
|
418
|
+
fallbacks: LiveDataSourceKind[];
|
|
419
|
+
notes?: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface LiveDataSourcesResponse {
|
|
423
|
+
generatedAt: string;
|
|
424
|
+
sources: LiveDataSourceStatus[];
|
|
425
|
+
capabilities: LiveDataSourceCapability[];
|
|
426
|
+
}
|
|
427
|
+
|
|
393
428
|
export type ModerationAction = "block" | "unblock" | "mute" | "unmute";
|
|
394
429
|
export type ModerationTransportKind = "bird" | "xurl" | "x-web";
|
|
395
430
|
|
package/src/lib/ui.ts
CHANGED
|
@@ -311,7 +311,7 @@ export const profilePreviewTriggerClass =
|
|
|
311
311
|
"profile-preview-trigger inline-flex text-inherit";
|
|
312
312
|
|
|
313
313
|
export const profilePreviewCardClass =
|
|
314
|
-
"pointer-events-none absolute left-0
|
|
314
|
+
"pointer-events-none absolute left-0 z-30 grid w-[280px] gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
|
|
315
315
|
|
|
316
316
|
export const profilePreviewHeaderClass = "flex items-center gap-3";
|
|
317
317
|
|
package/src/lib/web-sync.ts
CHANGED
|
@@ -131,12 +131,17 @@ function summarizeSteps(steps: WebSyncStep[]) {
|
|
|
131
131
|
const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
|
|
132
132
|
timeline: {
|
|
133
133
|
label: "Home timeline",
|
|
134
|
-
accountAware:
|
|
134
|
+
accountAware: true,
|
|
135
135
|
run: (account) =>
|
|
136
136
|
Effect.gen(function* () {
|
|
137
137
|
const result = yield* syncHomeTimelineEffect({
|
|
138
138
|
account,
|
|
139
|
+
mode:
|
|
140
|
+
!account || account === resolveDefaultSyncAccountId()
|
|
141
|
+
? "auto"
|
|
142
|
+
: "xurl",
|
|
139
143
|
limit: 100,
|
|
144
|
+
maxPages: 3,
|
|
140
145
|
following: true,
|
|
141
146
|
refresh: true,
|
|
142
147
|
});
|
|
@@ -157,7 +162,7 @@ const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
|
|
|
157
162
|
Effect.gen(function* () {
|
|
158
163
|
const mentions = yield* syncMentionsEffect({
|
|
159
164
|
account,
|
|
160
|
-
mode: "
|
|
165
|
+
mode: "auto",
|
|
161
166
|
limit: 100,
|
|
162
167
|
maxPages: 3,
|
|
163
168
|
refresh: true,
|