birdclaw 0.8.1 → 0.8.2
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 +9 -0
- package/package.json +1 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +4 -0
- package/src/components/EmbeddedTweetCard.tsx +4 -0
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/MarkdownViewer.tsx +57 -99
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/TimelineCard.tsx +18 -2
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +149 -47
- package/src/lib/api-client.ts +117 -2
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/authored-live.ts +2 -62
- package/src/lib/bird.ts +32 -1
- package/src/lib/client-cache.ts +109 -0
- package/src/lib/db.ts +57 -4
- package/src/lib/mention-threads-live.ts +2 -1
- package/src/lib/mentions-live.ts +2 -46
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +7 -6
- package/src/lib/sqlite.ts +5 -2
- package/src/lib/timeline-collections-live.ts +2 -1
- package/src/lib/timeline-live.ts +2 -1
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-search-live.ts +2 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +0 -13
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +71 -6
- package/vite.config.ts +0 -2
package/src/lib/api-client.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Data, Effect } from "effect";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
deleteClientCache,
|
|
5
|
+
deleteClientCacheByPrefix,
|
|
6
|
+
loadClientCache,
|
|
7
|
+
readClientCache,
|
|
8
|
+
} from "./client-cache";
|
|
3
9
|
import { runEffectPromise } from "./effect-runtime";
|
|
4
10
|
import type {
|
|
5
11
|
DmConversationItem,
|
|
@@ -105,6 +111,15 @@ const webSyncJobSchema = z
|
|
|
105
111
|
|
|
106
112
|
const actionResponseSchema = jsonRecordSchema;
|
|
107
113
|
const SYNC_POLL_INTERVAL_MS = 500;
|
|
114
|
+
const STATUS_CACHE_KEY = "api:/status";
|
|
115
|
+
const QUERY_CACHE_PREFIX = "api:/query:";
|
|
116
|
+
const STATUS_CACHE_MAX_AGE_MS = 60_000;
|
|
117
|
+
const QUERY_CACHE_MAX_AGE_MS = 5 * 60_000;
|
|
118
|
+
|
|
119
|
+
interface ClientFetchCacheOptions {
|
|
120
|
+
force?: boolean;
|
|
121
|
+
maxAgeMs?: number;
|
|
122
|
+
}
|
|
108
123
|
|
|
109
124
|
export class ApiFetchError extends Data.TaggedError("ApiFetchError")<{
|
|
110
125
|
readonly message: string;
|
|
@@ -148,6 +163,55 @@ function runApiEffect<T, E>(effect: Effect.Effect<T, E>) {
|
|
|
148
163
|
return runEffectPromise(effect);
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
function splitSignal(init?: RequestInit) {
|
|
167
|
+
if (!init) return { requestInit: undefined, signal: undefined };
|
|
168
|
+
const { signal, ...requestInit } = init;
|
|
169
|
+
return { requestInit, signal: signal ?? undefined };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function waitForSignal<T>(promise: Promise<T>, signal?: AbortSignal) {
|
|
173
|
+
if (!signal) return promise;
|
|
174
|
+
if (signal.aborted) {
|
|
175
|
+
return Promise.reject(
|
|
176
|
+
new DOMException("The operation was aborted.", "AbortError"),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return new Promise<T>((resolve, reject) => {
|
|
181
|
+
const onAbort = () => {
|
|
182
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
183
|
+
};
|
|
184
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
promise.then(
|
|
186
|
+
(value) => {
|
|
187
|
+
signal.removeEventListener("abort", onAbort);
|
|
188
|
+
resolve(value);
|
|
189
|
+
},
|
|
190
|
+
(error: unknown) => {
|
|
191
|
+
signal.removeEventListener("abort", onAbort);
|
|
192
|
+
reject(error);
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function queryCacheKey(input: RequestInfo | URL) {
|
|
199
|
+
const raw =
|
|
200
|
+
typeof input === "string"
|
|
201
|
+
? input
|
|
202
|
+
: input instanceof URL
|
|
203
|
+
? input.toString()
|
|
204
|
+
: input.url;
|
|
205
|
+
const base =
|
|
206
|
+
typeof window === "undefined"
|
|
207
|
+
? "http://birdclaw.local"
|
|
208
|
+
: window.location.origin;
|
|
209
|
+
const url = new URL(raw, base);
|
|
210
|
+
url.searchParams.delete("refresh");
|
|
211
|
+
url.searchParams.sort();
|
|
212
|
+
return `${QUERY_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
151
215
|
export function fetchJsonEffect<T>(
|
|
152
216
|
input: RequestInfo | URL,
|
|
153
217
|
init: RequestInit | undefined,
|
|
@@ -191,8 +255,27 @@ export function fetchJson<T>(
|
|
|
191
255
|
return runApiEffect(fetchJsonEffect(input, init, schema, fallbackMessage));
|
|
192
256
|
}
|
|
193
257
|
|
|
194
|
-
export function
|
|
195
|
-
return
|
|
258
|
+
export function readCachedQueryEnvelope() {
|
|
259
|
+
return readClientCache<QueryEnvelope>(
|
|
260
|
+
STATUS_CACHE_KEY,
|
|
261
|
+
STATUS_CACHE_MAX_AGE_MS,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function fetchQueryEnvelope(
|
|
266
|
+
init?: RequestInit,
|
|
267
|
+
{
|
|
268
|
+
force = false,
|
|
269
|
+
maxAgeMs = STATUS_CACHE_MAX_AGE_MS,
|
|
270
|
+
}: ClientFetchCacheOptions = {},
|
|
271
|
+
) {
|
|
272
|
+
const { requestInit, signal } = splitSignal(init);
|
|
273
|
+
const request = loadClientCache(
|
|
274
|
+
STATUS_CACHE_KEY,
|
|
275
|
+
() => runApiEffect(fetchQueryEnvelopeEffect(requestInit)),
|
|
276
|
+
{ force, maxAgeMs },
|
|
277
|
+
);
|
|
278
|
+
return waitForSignal(request, signal);
|
|
196
279
|
}
|
|
197
280
|
|
|
198
281
|
export function fetchQueryEnvelopeEffect(init?: RequestInit) {
|
|
@@ -211,6 +294,38 @@ export function fetchQueryResponse(
|
|
|
211
294
|
return runApiEffect(fetchQueryResponseEffect(input, init));
|
|
212
295
|
}
|
|
213
296
|
|
|
297
|
+
export function readCachedQueryResponse(input: RequestInfo | URL) {
|
|
298
|
+
return readClientCache<QueryResponse>(
|
|
299
|
+
queryCacheKey(input),
|
|
300
|
+
QUERY_CACHE_MAX_AGE_MS,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function fetchCachedQueryResponse(
|
|
305
|
+
input: RequestInfo | URL,
|
|
306
|
+
init?: RequestInit,
|
|
307
|
+
{
|
|
308
|
+
force = false,
|
|
309
|
+
maxAgeMs = QUERY_CACHE_MAX_AGE_MS,
|
|
310
|
+
}: ClientFetchCacheOptions = {},
|
|
311
|
+
) {
|
|
312
|
+
const { requestInit, signal } = splitSignal(init);
|
|
313
|
+
const request = loadClientCache(
|
|
314
|
+
queryCacheKey(input),
|
|
315
|
+
() => runApiEffect(fetchQueryResponseEffect(input, requestInit)),
|
|
316
|
+
{ force, maxAgeMs },
|
|
317
|
+
);
|
|
318
|
+
return waitForSignal(request, signal);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function invalidateCachedQueryResponse(input: RequestInfo | URL) {
|
|
322
|
+
deleteClientCache(queryCacheKey(input));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function invalidateCachedQueryResponses() {
|
|
326
|
+
deleteClientCacheByPrefix(QUERY_CACHE_PREFIX);
|
|
327
|
+
}
|
|
328
|
+
|
|
214
329
|
export function fetchQueryResponseEffect(
|
|
215
330
|
input: RequestInfo | URL,
|
|
216
331
|
init?: RequestInit,
|
|
@@ -8,11 +8,16 @@ import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
|
8
8
|
import type { ArchiveCandidate } from "./types";
|
|
9
9
|
|
|
10
10
|
const execAsync = promisify(exec);
|
|
11
|
+
const ARCHIVE_DISCOVERY_CACHE_MS = 5 * 60_000;
|
|
11
12
|
const ARCHIVE_NAME_PATTERNS = [
|
|
12
13
|
/^twitter-.*\.zip$/i,
|
|
13
14
|
/^x-.*\.zip$/i,
|
|
14
15
|
/archive.*\.zip$/i,
|
|
15
16
|
];
|
|
17
|
+
let archiveDiscoveryCache:
|
|
18
|
+
| { value: ArchiveCandidate[]; updatedAt: number }
|
|
19
|
+
| undefined;
|
|
20
|
+
let archiveDiscoveryInFlight: Promise<ArchiveCandidate[]> | undefined;
|
|
16
21
|
|
|
17
22
|
function formatFileSize(bytes: number): string {
|
|
18
23
|
const units = ["B", "KB", "MB", "GB"];
|
|
@@ -142,6 +147,39 @@ export function findArchivesEffect(): Effect.Effect<
|
|
|
142
147
|
});
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
export function findArchivesCachedEffect(): Effect.Effect<
|
|
151
|
+
ArchiveCandidate[],
|
|
152
|
+
unknown
|
|
153
|
+
> {
|
|
154
|
+
return Effect.suspend(() => {
|
|
155
|
+
const cached = archiveDiscoveryCache;
|
|
156
|
+
if (cached && Date.now() - cached.updatedAt < ARCHIVE_DISCOVERY_CACHE_MS) {
|
|
157
|
+
return Effect.succeed(cached.value);
|
|
158
|
+
}
|
|
159
|
+
if (archiveDiscoveryInFlight) {
|
|
160
|
+
return Effect.promise(() => archiveDiscoveryInFlight!);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const request = runEffectPromise(findArchivesEffect())
|
|
164
|
+
.then((value) => {
|
|
165
|
+
archiveDiscoveryCache = { value, updatedAt: Date.now() };
|
|
166
|
+
return value;
|
|
167
|
+
})
|
|
168
|
+
.finally(() => {
|
|
169
|
+
if (archiveDiscoveryInFlight === request) {
|
|
170
|
+
archiveDiscoveryInFlight = undefined;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
archiveDiscoveryInFlight = request;
|
|
174
|
+
return Effect.promise(() => request);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
145
178
|
export function findArchives(): Promise<ArchiveCandidate[]> {
|
|
146
179
|
return runEffectPromise(findArchivesEffect());
|
|
147
180
|
}
|
|
181
|
+
|
|
182
|
+
export function clearArchiveFinderCacheForTests() {
|
|
183
|
+
archiveDiscoveryCache = undefined;
|
|
184
|
+
archiveDiscoveryInFlight = undefined;
|
|
185
|
+
}
|
package/src/lib/authored-live.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Effect } from "effect";
|
|
|
3
3
|
import { getNativeDb } from "./db";
|
|
4
4
|
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
5
5
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
6
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
6
7
|
import type {
|
|
7
8
|
TweetEntities,
|
|
8
9
|
TweetMediaItem,
|
|
@@ -523,68 +524,7 @@ function toMentionData(tweet: XurlUserTweet, fallbackAuthorId: string) {
|
|
|
523
524
|
}
|
|
524
525
|
|
|
525
526
|
function toLocalEntities(tweet: XurlMentionData): TweetEntities {
|
|
526
|
-
|
|
527
|
-
if (!raw || typeof raw !== "object") {
|
|
528
|
-
return {};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const entities = raw as Record<string, unknown>;
|
|
532
|
-
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
533
|
-
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
534
|
-
const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
...(rawMentions.length
|
|
538
|
-
? {
|
|
539
|
-
mentions: rawMentions.map((mention) => {
|
|
540
|
-
const value =
|
|
541
|
-
mention && typeof mention === "object"
|
|
542
|
-
? (mention as Record<string, unknown>)
|
|
543
|
-
: {};
|
|
544
|
-
return {
|
|
545
|
-
username: String(value.username ?? ""),
|
|
546
|
-
id: typeof value.id === "string" ? String(value.id) : undefined,
|
|
547
|
-
start: Number(value.start ?? 0),
|
|
548
|
-
end: Number(value.end ?? 0),
|
|
549
|
-
};
|
|
550
|
-
}),
|
|
551
|
-
}
|
|
552
|
-
: {}),
|
|
553
|
-
...(rawUrls.length
|
|
554
|
-
? {
|
|
555
|
-
urls: rawUrls.map((url) => {
|
|
556
|
-
const value =
|
|
557
|
-
url && typeof url === "object"
|
|
558
|
-
? (url as Record<string, unknown>)
|
|
559
|
-
: {};
|
|
560
|
-
return {
|
|
561
|
-
url: String(value.url ?? ""),
|
|
562
|
-
expandedUrl: String(value.expanded_url ?? value.url ?? ""),
|
|
563
|
-
displayUrl: String(
|
|
564
|
-
value.display_url ?? value.expanded_url ?? value.url ?? "",
|
|
565
|
-
),
|
|
566
|
-
start: Number(value.start ?? 0),
|
|
567
|
-
end: Number(value.end ?? 0),
|
|
568
|
-
};
|
|
569
|
-
}),
|
|
570
|
-
}
|
|
571
|
-
: {}),
|
|
572
|
-
...(rawHashtags.length
|
|
573
|
-
? {
|
|
574
|
-
hashtags: rawHashtags.map((hashtag) => {
|
|
575
|
-
const value =
|
|
576
|
-
hashtag && typeof hashtag === "object"
|
|
577
|
-
? (hashtag as Record<string, unknown>)
|
|
578
|
-
: {};
|
|
579
|
-
return {
|
|
580
|
-
tag: String(value.tag ?? ""),
|
|
581
|
-
start: Number(value.start ?? 0),
|
|
582
|
-
end: Number(value.end ?? 0),
|
|
583
|
-
};
|
|
584
|
-
}),
|
|
585
|
-
}
|
|
586
|
-
: {}),
|
|
587
|
-
};
|
|
527
|
+
return tweetEntitiesFromXurl(tweet.entities);
|
|
588
528
|
}
|
|
589
529
|
|
|
590
530
|
function toMediaType(type: string): TweetMediaItem["type"] {
|
package/src/lib/bird.ts
CHANGED
|
@@ -29,6 +29,12 @@ interface BirdTweetAuthor {
|
|
|
29
29
|
name?: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
interface BirdTweetArticle {
|
|
33
|
+
title?: string;
|
|
34
|
+
previewText?: string;
|
|
35
|
+
coverImageUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
interface BirdTweetItem {
|
|
33
39
|
id: string;
|
|
34
40
|
text: string;
|
|
@@ -45,6 +51,7 @@ interface BirdTweetItem {
|
|
|
45
51
|
author?: BirdTweetAuthor;
|
|
46
52
|
authorId?: string;
|
|
47
53
|
media?: BirdTweetMedia[];
|
|
54
|
+
article?: BirdTweetArticle | null;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export interface BirdDmUser {
|
|
@@ -385,6 +392,29 @@ function toMediaEntities(media: BirdTweetMedia[] | undefined) {
|
|
|
385
392
|
};
|
|
386
393
|
}
|
|
387
394
|
|
|
395
|
+
function toTweetEntities(item: BirdTweetItem) {
|
|
396
|
+
const mediaEntities = toMediaEntities(item.media);
|
|
397
|
+
const title = item.article?.title?.trim();
|
|
398
|
+
if (!title) return mediaEntities;
|
|
399
|
+
const handle = item.author?.username?.replace(/^@/, "");
|
|
400
|
+
const url = handle
|
|
401
|
+
? `https://x.com/${handle}/status/${item.id}`
|
|
402
|
+
: `https://x.com/i/status/${item.id}`;
|
|
403
|
+
return {
|
|
404
|
+
...mediaEntities,
|
|
405
|
+
article: {
|
|
406
|
+
title,
|
|
407
|
+
url,
|
|
408
|
+
...(item.article?.previewText?.trim()
|
|
409
|
+
? { previewText: item.article.previewText.trim() }
|
|
410
|
+
: {}),
|
|
411
|
+
...(item.article?.coverImageUrl?.trim()
|
|
412
|
+
? { coverImageUrl: item.article.coverImageUrl.trim() }
|
|
413
|
+
: {}),
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
388
418
|
function toReferencedTweets(item: BirdTweetItem) {
|
|
389
419
|
const references: XurlReferencedTweet[] = [];
|
|
390
420
|
if (typeof item.inReplyToStatusId === "string" && item.inReplyToStatusId) {
|
|
@@ -434,7 +464,7 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
|
|
|
434
464
|
text: item.text,
|
|
435
465
|
created_at: toIsoTimestamp(item.createdAt),
|
|
436
466
|
conversation_id: item.conversationId ?? item.id,
|
|
437
|
-
entities:
|
|
467
|
+
entities: toTweetEntities(item),
|
|
438
468
|
referenced_tweets: toReferencedTweets(item),
|
|
439
469
|
public_metrics: {
|
|
440
470
|
reply_count: Number(item.replyCount ?? 0),
|
|
@@ -1104,6 +1134,7 @@ export const __test__ = {
|
|
|
1104
1134
|
getBirdTweetItems,
|
|
1105
1135
|
getBirdTweetItem,
|
|
1106
1136
|
toMediaEntities,
|
|
1137
|
+
toTweetEntities,
|
|
1107
1138
|
toReferencedTweets,
|
|
1108
1139
|
normalizeBirdTweets,
|
|
1109
1140
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
2
|
+
|
|
3
|
+
interface ClientCacheEntry<T> {
|
|
4
|
+
value: T;
|
|
5
|
+
updatedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface LoadClientCacheOptions {
|
|
9
|
+
force?: boolean;
|
|
10
|
+
maxAgeMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const values = new Map<string, ClientCacheEntry<unknown>>();
|
|
14
|
+
const pending = new Map<string, Promise<unknown>>();
|
|
15
|
+
const revisions = new Map<string, number>();
|
|
16
|
+
let generation = 0;
|
|
17
|
+
|
|
18
|
+
function cacheToken(key: string) {
|
|
19
|
+
return `${String(generation)}:${String(revisions.get(key) ?? 0)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function invalidateKey(key: string) {
|
|
23
|
+
revisions.set(key, (revisions.get(key) ?? 0) + 1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pruneCache() {
|
|
27
|
+
while (values.size > DEFAULT_MAX_ENTRIES) {
|
|
28
|
+
const oldestKey = values.keys().next().value as string | undefined;
|
|
29
|
+
if (!oldestKey) return;
|
|
30
|
+
values.delete(oldestKey);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readClientCache<T>(
|
|
35
|
+
key: string,
|
|
36
|
+
maxAgeMs = Number.POSITIVE_INFINITY,
|
|
37
|
+
) {
|
|
38
|
+
const entry = values.get(key) as ClientCacheEntry<T> | undefined;
|
|
39
|
+
if (!entry) return undefined;
|
|
40
|
+
if (Date.now() - entry.updatedAt > maxAgeMs) {
|
|
41
|
+
values.delete(key);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return entry.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeClientCache<T>(key: string, value: T) {
|
|
48
|
+
values.delete(key);
|
|
49
|
+
values.set(key, { value, updatedAt: Date.now() });
|
|
50
|
+
pruneCache();
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadClientCache<T>(
|
|
55
|
+
key: string,
|
|
56
|
+
load: () => Promise<T>,
|
|
57
|
+
{
|
|
58
|
+
force = false,
|
|
59
|
+
maxAgeMs = Number.POSITIVE_INFINITY,
|
|
60
|
+
}: LoadClientCacheOptions = {},
|
|
61
|
+
) {
|
|
62
|
+
if (force) {
|
|
63
|
+
invalidateKey(key);
|
|
64
|
+
values.delete(key);
|
|
65
|
+
pending.delete(key);
|
|
66
|
+
} else {
|
|
67
|
+
const cached = readClientCache<T>(key, maxAgeMs);
|
|
68
|
+
if (cached !== undefined) return Promise.resolve(cached);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const active = pending.get(key) as Promise<T> | undefined;
|
|
72
|
+
if (active) return active;
|
|
73
|
+
|
|
74
|
+
const token = cacheToken(key);
|
|
75
|
+
let request: Promise<T>;
|
|
76
|
+
request = load()
|
|
77
|
+
.then((value) => {
|
|
78
|
+
if (cacheToken(key) === token) writeClientCache(key, value);
|
|
79
|
+
return value;
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
if (pending.get(key) === request) pending.delete(key);
|
|
83
|
+
});
|
|
84
|
+
pending.set(key, request);
|
|
85
|
+
return request;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function deleteClientCache(key: string) {
|
|
89
|
+
invalidateKey(key);
|
|
90
|
+
values.delete(key);
|
|
91
|
+
pending.delete(key);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function deleteClientCacheByPrefix(prefix: string) {
|
|
95
|
+
const keys = new Set([...values.keys(), ...pending.keys()]);
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
if (!key.startsWith(prefix)) continue;
|
|
98
|
+
invalidateKey(key);
|
|
99
|
+
values.delete(key);
|
|
100
|
+
pending.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function clearClientCache() {
|
|
105
|
+
generation += 1;
|
|
106
|
+
values.clear();
|
|
107
|
+
pending.clear();
|
|
108
|
+
revisions.clear();
|
|
109
|
+
}
|
package/src/lib/db.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import NativeSqliteDatabase, {
|
|
1
|
+
import NativeSqliteDatabase, {
|
|
2
|
+
type Database,
|
|
3
|
+
SQLITE_BUSY_TIMEOUT_MS,
|
|
4
|
+
} from "./sqlite";
|
|
2
5
|
import { Kysely, SqliteDialect } from "kysely";
|
|
3
6
|
import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
|
|
4
7
|
import { seedDemoData } from "./seed";
|
|
@@ -326,7 +329,7 @@ export interface InitDatabaseOptions {
|
|
|
326
329
|
|
|
327
330
|
const BASE_SCHEMA_SQL = `
|
|
328
331
|
pragma journal_mode = wal;
|
|
329
|
-
pragma busy_timeout =
|
|
332
|
+
pragma busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};
|
|
330
333
|
pragma foreign_keys = on;
|
|
331
334
|
|
|
332
335
|
create table if not exists accounts (
|
|
@@ -948,6 +951,34 @@ function ensureFollowGraphTables(db: Database) {
|
|
|
948
951
|
}
|
|
949
952
|
|
|
950
953
|
function backfillTweetCollections(db: Database) {
|
|
954
|
+
const missingKinds = (
|
|
955
|
+
[
|
|
956
|
+
["likes", "liked"],
|
|
957
|
+
["bookmarks", "bookmarked"],
|
|
958
|
+
] as const
|
|
959
|
+
).filter(([, column]) =>
|
|
960
|
+
db
|
|
961
|
+
.prepare(
|
|
962
|
+
`
|
|
963
|
+
select 1
|
|
964
|
+
from tweets as tweet
|
|
965
|
+
where tweet.${column} = 1
|
|
966
|
+
and not exists (
|
|
967
|
+
select 1
|
|
968
|
+
from tweet_collections as collection
|
|
969
|
+
where collection.account_id = tweet.account_id
|
|
970
|
+
and collection.tweet_id = tweet.id
|
|
971
|
+
and collection.kind = ?
|
|
972
|
+
)
|
|
973
|
+
limit 1
|
|
974
|
+
`,
|
|
975
|
+
)
|
|
976
|
+
.get(column === "liked" ? "likes" : "bookmarks"),
|
|
977
|
+
);
|
|
978
|
+
if (missingKinds.length === 0) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
951
982
|
const now = new Date().toISOString();
|
|
952
983
|
const insert = db.prepare(`
|
|
953
984
|
insert or ignore into tweet_collections (
|
|
@@ -963,12 +994,34 @@ function backfillTweetCollections(db: Database) {
|
|
|
963
994
|
`);
|
|
964
995
|
|
|
965
996
|
db.transaction(() => {
|
|
966
|
-
|
|
967
|
-
|
|
997
|
+
for (const [kind] of missingKinds) {
|
|
998
|
+
insert.run(kind, now, kind);
|
|
999
|
+
}
|
|
968
1000
|
})();
|
|
969
1001
|
}
|
|
970
1002
|
|
|
971
1003
|
function backfillTweetAccountEdges(db: Database) {
|
|
1004
|
+
const missing = db
|
|
1005
|
+
.prepare(
|
|
1006
|
+
`
|
|
1007
|
+
select 1
|
|
1008
|
+
from tweets as tweet
|
|
1009
|
+
where tweet.kind in ('home', 'mention')
|
|
1010
|
+
and not exists (
|
|
1011
|
+
select 1
|
|
1012
|
+
from tweet_account_edges as edge
|
|
1013
|
+
where edge.account_id = tweet.account_id
|
|
1014
|
+
and edge.tweet_id = tweet.id
|
|
1015
|
+
and edge.kind = tweet.kind
|
|
1016
|
+
)
|
|
1017
|
+
limit 1
|
|
1018
|
+
`,
|
|
1019
|
+
)
|
|
1020
|
+
.get();
|
|
1021
|
+
if (!missing) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
972
1025
|
const now = new Date().toISOString();
|
|
973
1026
|
db.prepare(`
|
|
974
1027
|
insert or ignore into tweet_account_edges (
|
|
@@ -4,6 +4,7 @@ import { listThreadViaBirdEffect } from "./bird";
|
|
|
4
4
|
import { getNativeDb } from "./db";
|
|
5
5
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
7
8
|
import type {
|
|
8
9
|
XurlMentionData,
|
|
9
10
|
XurlMentionsResponse,
|
|
@@ -415,7 +416,7 @@ function mergeMentionThreadIntoLocalStore({
|
|
|
415
416
|
replyToId ?? null,
|
|
416
417
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
417
418
|
countTweetMedia(tweet),
|
|
418
|
-
JSON.stringify(tweet.entities
|
|
419
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
419
420
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
420
421
|
);
|
|
421
422
|
if (writeThreadContextEdges) {
|
package/src/lib/mentions-live.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
|
11
11
|
import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
|
|
12
12
|
import { listTimelineItems } from "./queries";
|
|
13
13
|
import { deleteSyncCache, readSyncCache, writeSyncCache } from "./sync-cache";
|
|
14
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
14
15
|
import type {
|
|
15
16
|
ReplyFilter,
|
|
16
17
|
TweetEntities,
|
|
@@ -521,52 +522,7 @@ function findNewestArchiveMentionId(db: Database, accountId: string) {
|
|
|
521
522
|
}
|
|
522
523
|
|
|
523
524
|
function toLocalEntities(tweet: XurlMentionData): TweetEntities {
|
|
524
|
-
|
|
525
|
-
if (!raw || typeof raw !== "object") {
|
|
526
|
-
return {};
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const entities = raw as Record<string, unknown>;
|
|
530
|
-
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
531
|
-
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
532
|
-
|
|
533
|
-
return {
|
|
534
|
-
...(rawMentions.length
|
|
535
|
-
? {
|
|
536
|
-
mentions: rawMentions.map((mention) => {
|
|
537
|
-
const value =
|
|
538
|
-
mention && typeof mention === "object"
|
|
539
|
-
? (mention as Record<string, unknown>)
|
|
540
|
-
: {};
|
|
541
|
-
return {
|
|
542
|
-
username: String(value.username ?? ""),
|
|
543
|
-
id: typeof value.id === "string" ? String(value.id) : undefined,
|
|
544
|
-
start: Number(value.start ?? 0),
|
|
545
|
-
end: Number(value.end ?? 0),
|
|
546
|
-
};
|
|
547
|
-
}),
|
|
548
|
-
}
|
|
549
|
-
: {}),
|
|
550
|
-
...(rawUrls.length
|
|
551
|
-
? {
|
|
552
|
-
urls: rawUrls.map((url) => {
|
|
553
|
-
const value =
|
|
554
|
-
url && typeof url === "object"
|
|
555
|
-
? (url as Record<string, unknown>)
|
|
556
|
-
: {};
|
|
557
|
-
return {
|
|
558
|
-
url: String(value.url ?? ""),
|
|
559
|
-
expandedUrl: String(value.expanded_url ?? value.url ?? ""),
|
|
560
|
-
displayUrl: String(
|
|
561
|
-
value.display_url ?? value.expanded_url ?? value.url ?? "",
|
|
562
|
-
),
|
|
563
|
-
start: Number(value.start ?? 0),
|
|
564
|
-
end: Number(value.end ?? 0),
|
|
565
|
-
};
|
|
566
|
-
}),
|
|
567
|
-
}
|
|
568
|
-
: {}),
|
|
569
|
-
};
|
|
525
|
+
return tweetEntitiesFromXurl(tweet.entities);
|
|
570
526
|
}
|
|
571
527
|
|
|
572
528
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
@@ -407,7 +407,7 @@ function mergeXurlTweetsIntoLocalStore(
|
|
|
407
407
|
replyToId,
|
|
408
408
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
409
409
|
countTweetMedia(tweet),
|
|
410
|
-
JSON.stringify(tweet.entities
|
|
410
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
411
411
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
412
412
|
quotedTweetId,
|
|
413
413
|
);
|
package/src/lib/queries.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import type { Database } from "./sqlite";
|
|
4
|
-
import {
|
|
4
|
+
import { findArchivesCachedEffect } from "./archive-finder";
|
|
5
5
|
import { getDb, getNativeDb } from "./db";
|
|
6
6
|
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
7
7
|
import { fetchProfileAffiliations } from "./profile-affiliations";
|
|
@@ -701,10 +701,9 @@ function getAccountProfileMeta(
|
|
|
701
701
|
| undefined;
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
-
export function getQueryEnvelopeEffect(
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
> {
|
|
704
|
+
export function getQueryEnvelopeEffect({
|
|
705
|
+
includeArchives = true,
|
|
706
|
+
}: { includeArchives?: boolean } = {}): Effect.Effect<QueryEnvelope, unknown> {
|
|
708
707
|
return Effect.gen(function* () {
|
|
709
708
|
const db = yield* trySync(() => getDb());
|
|
710
709
|
const nativeDb = yield* trySync(() => getNativeDb());
|
|
@@ -736,7 +735,9 @@ export function getQueryEnvelopeEffect(): Effect.Effect<
|
|
|
736
735
|
.orderBy("name", "asc")
|
|
737
736
|
.execute(),
|
|
738
737
|
),
|
|
739
|
-
archives:
|
|
738
|
+
archives: includeArchives
|
|
739
|
+
? findArchivesCachedEffect()
|
|
740
|
+
: Effect.succeed([]),
|
|
740
741
|
transport: getTransportStatusEffect(),
|
|
741
742
|
});
|
|
742
743
|
|