birdclaw 0.8.0 → 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 +22 -0
- package/package.json +5 -8
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +9 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +9 -4
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +59 -100
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +24 -6
- 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/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +184 -24
- package/src/lib/present.ts +99 -1
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +68 -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/period-digest.tsx +21 -69
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +75 -9
- package/src/routes/today.tsx +67 -11
- package/vite.config.ts +0 -2
|
@@ -6,13 +6,30 @@ import type {
|
|
|
6
6
|
TimelineItem,
|
|
7
7
|
} from "#/lib/types";
|
|
8
8
|
import {
|
|
9
|
+
fetchCachedQueryResponse,
|
|
9
10
|
fetchQueryEnvelope,
|
|
10
|
-
|
|
11
|
+
invalidateCachedQueryResponse,
|
|
12
|
+
invalidateCachedQueryResponses,
|
|
11
13
|
postAction,
|
|
14
|
+
readCachedQueryEnvelope,
|
|
12
15
|
} from "#/lib/api-client";
|
|
16
|
+
import {
|
|
17
|
+
deleteClientCache,
|
|
18
|
+
deleteClientCacheByPrefix,
|
|
19
|
+
readClientCache,
|
|
20
|
+
writeClientCache,
|
|
21
|
+
} from "#/lib/client-cache";
|
|
13
22
|
import { useSelectedAccountId } from "./account-selection";
|
|
23
|
+
import { useDebouncedValue } from "./useDebouncedValue";
|
|
14
24
|
|
|
15
25
|
const PAGE_SIZE = 50;
|
|
26
|
+
const TIMELINE_VIEW_CACHE_PREFIX = "timeline-view:";
|
|
27
|
+
const TIMELINE_VIEW_CACHE_MAX_AGE_MS = 5 * 60_000;
|
|
28
|
+
|
|
29
|
+
interface TimelineViewSnapshot {
|
|
30
|
+
items: TimelineItem[];
|
|
31
|
+
hasMore: boolean;
|
|
32
|
+
}
|
|
16
33
|
|
|
17
34
|
interface UseTimelineRouteDataOptions {
|
|
18
35
|
resource: Exclude<ResourceKind, "dms">;
|
|
@@ -23,6 +40,53 @@ interface UseTimelineRouteDataOptions {
|
|
|
23
40
|
bookmarkedOnly?: boolean;
|
|
24
41
|
}
|
|
25
42
|
|
|
43
|
+
function buildTimelineQueryUrl({
|
|
44
|
+
resource,
|
|
45
|
+
search,
|
|
46
|
+
replyFilter,
|
|
47
|
+
likedOnly,
|
|
48
|
+
bookmarkedOnly,
|
|
49
|
+
selectedAccountId,
|
|
50
|
+
refreshTick,
|
|
51
|
+
until,
|
|
52
|
+
untilId,
|
|
53
|
+
}: {
|
|
54
|
+
resource: Exclude<ResourceKind, "dms">;
|
|
55
|
+
search: string;
|
|
56
|
+
replyFilter?: ReplyFilter;
|
|
57
|
+
likedOnly: boolean;
|
|
58
|
+
bookmarkedOnly: boolean;
|
|
59
|
+
selectedAccountId?: string;
|
|
60
|
+
refreshTick: number;
|
|
61
|
+
until?: string;
|
|
62
|
+
untilId?: string;
|
|
63
|
+
}) {
|
|
64
|
+
const params = new URLSearchParams({
|
|
65
|
+
resource,
|
|
66
|
+
limit: String(PAGE_SIZE),
|
|
67
|
+
});
|
|
68
|
+
if (selectedAccountId) params.set("account", selectedAccountId);
|
|
69
|
+
params.set("refresh", String(refreshTick));
|
|
70
|
+
if (replyFilter) params.set("replyFilter", replyFilter);
|
|
71
|
+
if (likedOnly) params.set("liked", "true");
|
|
72
|
+
if (bookmarkedOnly) params.set("bookmarked", "true");
|
|
73
|
+
if (search.trim()) params.set("search", search.trim());
|
|
74
|
+
if (until) params.set("until", until);
|
|
75
|
+
if (untilId) params.set("untilId", untilId);
|
|
76
|
+
params.sort();
|
|
77
|
+
const base =
|
|
78
|
+
typeof window === "undefined"
|
|
79
|
+
? "http://birdclaw.local"
|
|
80
|
+
: window.location.origin;
|
|
81
|
+
return new URL(`/api/query?${params.toString()}`, base).toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function timelineViewCacheKey(requestUrl: string) {
|
|
85
|
+
const url = new URL(requestUrl, "http://birdclaw.local");
|
|
86
|
+
url.searchParams.delete("refresh");
|
|
87
|
+
return `${TIMELINE_VIEW_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
26
90
|
export function useTimelineRouteData({
|
|
27
91
|
resource,
|
|
28
92
|
search,
|
|
@@ -31,75 +95,98 @@ export function useTimelineRouteData({
|
|
|
31
95
|
likedOnly = false,
|
|
32
96
|
bookmarkedOnly = false,
|
|
33
97
|
}: UseTimelineRouteDataOptions) {
|
|
34
|
-
const [meta, setMeta] = useState<QueryEnvelope | null>(
|
|
35
|
-
|
|
36
|
-
|
|
98
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(
|
|
99
|
+
() => readCachedQueryEnvelope() ?? null,
|
|
100
|
+
);
|
|
101
|
+
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
102
|
+
const debouncedSearch = useDebouncedValue(search, 180);
|
|
103
|
+
const initialRequestUrl = buildTimelineQueryUrl({
|
|
104
|
+
resource,
|
|
105
|
+
search: debouncedSearch,
|
|
106
|
+
replyFilter,
|
|
107
|
+
likedOnly,
|
|
108
|
+
bookmarkedOnly,
|
|
109
|
+
selectedAccountId,
|
|
110
|
+
refreshTick: 0,
|
|
111
|
+
});
|
|
112
|
+
const initialSnapshot = useRef(
|
|
113
|
+
readClientCache<TimelineViewSnapshot>(
|
|
114
|
+
timelineViewCacheKey(initialRequestUrl),
|
|
115
|
+
TIMELINE_VIEW_CACHE_MAX_AGE_MS,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
const [items, setItems] = useState<TimelineItem[]>(
|
|
119
|
+
() => initialSnapshot.current?.items ?? [],
|
|
120
|
+
);
|
|
121
|
+
const [loading, setLoading] = useState(() => !initialSnapshot.current);
|
|
37
122
|
const [error, setError] = useState<string | null>(null);
|
|
38
123
|
const [replyError, setReplyError] = useState<string | null>(null);
|
|
39
124
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
40
|
-
const [hasMore, setHasMore] = useState(
|
|
125
|
+
const [hasMore, setHasMore] = useState(
|
|
126
|
+
() => initialSnapshot.current?.hasMore ?? false,
|
|
127
|
+
);
|
|
41
128
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
42
|
-
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
43
129
|
// Bumped whenever the active filters change. A `loadMore` request that
|
|
44
130
|
// resolves against a stale generation is discarded so its older page is
|
|
45
131
|
// never appended to a freshly loaded feed.
|
|
46
132
|
const generationRef = useRef(0);
|
|
47
133
|
const loadMoreControllerRef = useRef<AbortController | null>(null);
|
|
134
|
+
const activeRequestUrlRef = useRef(initialRequestUrl);
|
|
48
135
|
|
|
49
|
-
async function loadStatus() {
|
|
50
|
-
setMeta(await fetchQueryEnvelope());
|
|
136
|
+
async function loadStatus(force = false) {
|
|
137
|
+
setMeta(await fetchQueryEnvelope(undefined, { force }));
|
|
51
138
|
}
|
|
52
139
|
|
|
53
140
|
useEffect(() => {
|
|
54
141
|
void loadStatus();
|
|
55
142
|
}, []);
|
|
56
143
|
|
|
57
|
-
// Build the /api/query URL for the current filters. Passing the last item's
|
|
58
|
-
// (createdAt, id) requests the next (older) page via the server's keyset
|
|
59
|
-
// cursor, which is deterministic across duplicate timestamps.
|
|
60
|
-
function buildQueryUrl(until?: string, untilId?: string) {
|
|
61
|
-
const url = new URL("/api/query", window.location.origin);
|
|
62
|
-
url.searchParams.set("resource", resource);
|
|
63
|
-
url.searchParams.set("refresh", String(refreshTick));
|
|
64
|
-
url.searchParams.set("limit", String(PAGE_SIZE));
|
|
65
|
-
if (selectedAccountId) {
|
|
66
|
-
url.searchParams.set("account", selectedAccountId);
|
|
67
|
-
}
|
|
68
|
-
if (replyFilter) {
|
|
69
|
-
url.searchParams.set("replyFilter", replyFilter);
|
|
70
|
-
}
|
|
71
|
-
if (likedOnly) {
|
|
72
|
-
url.searchParams.set("liked", "true");
|
|
73
|
-
}
|
|
74
|
-
if (bookmarkedOnly) {
|
|
75
|
-
url.searchParams.set("bookmarked", "true");
|
|
76
|
-
}
|
|
77
|
-
if (search.trim()) {
|
|
78
|
-
url.searchParams.set("search", search.trim());
|
|
79
|
-
}
|
|
80
|
-
if (until) {
|
|
81
|
-
url.searchParams.set("until", until);
|
|
82
|
-
}
|
|
83
|
-
if (untilId) {
|
|
84
|
-
url.searchParams.set("untilId", untilId);
|
|
85
|
-
}
|
|
86
|
-
return url;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
144
|
useEffect(() => {
|
|
90
145
|
generationRef.current += 1;
|
|
91
146
|
loadMoreControllerRef.current?.abort();
|
|
92
147
|
const controller = new AbortController();
|
|
93
148
|
let active = true;
|
|
149
|
+
const requestUrl = buildTimelineQueryUrl({
|
|
150
|
+
resource,
|
|
151
|
+
search: debouncedSearch,
|
|
152
|
+
replyFilter,
|
|
153
|
+
likedOnly,
|
|
154
|
+
bookmarkedOnly,
|
|
155
|
+
selectedAccountId,
|
|
156
|
+
refreshTick,
|
|
157
|
+
});
|
|
158
|
+
const viewCacheKey = timelineViewCacheKey(requestUrl);
|
|
159
|
+
activeRequestUrlRef.current = requestUrl;
|
|
94
160
|
setError(null);
|
|
95
|
-
setLoading(true);
|
|
96
161
|
setLoadingMore(false);
|
|
97
|
-
|
|
162
|
+
const cached = readClientCache<TimelineViewSnapshot>(
|
|
163
|
+
viewCacheKey,
|
|
164
|
+
TIMELINE_VIEW_CACHE_MAX_AGE_MS,
|
|
165
|
+
);
|
|
166
|
+
if (cached) {
|
|
167
|
+
setItems(cached.items);
|
|
168
|
+
setHasMore(cached.hasMore);
|
|
169
|
+
setLoading(false);
|
|
170
|
+
return () => {
|
|
171
|
+
active = false;
|
|
172
|
+
controller.abort();
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setItems([]);
|
|
177
|
+
setHasMore(false);
|
|
178
|
+
setLoading(true);
|
|
179
|
+
fetchCachedQueryResponse(requestUrl, { signal: controller.signal })
|
|
98
180
|
.then((data) => {
|
|
99
181
|
if (!active) return;
|
|
100
182
|
const next = data.items as TimelineItem[];
|
|
101
183
|
setItems(next);
|
|
102
|
-
|
|
184
|
+
const nextHasMore = next.length >= PAGE_SIZE;
|
|
185
|
+
setHasMore(nextHasMore);
|
|
186
|
+
writeClientCache(viewCacheKey, {
|
|
187
|
+
items: next,
|
|
188
|
+
hasMore: nextHasMore,
|
|
189
|
+
});
|
|
103
190
|
})
|
|
104
191
|
.catch((fetchError: unknown) => {
|
|
105
192
|
if (
|
|
@@ -127,12 +214,12 @@ export function useTimelineRouteData({
|
|
|
127
214
|
};
|
|
128
215
|
}, [
|
|
129
216
|
bookmarkedOnly,
|
|
217
|
+
debouncedSearch,
|
|
130
218
|
errorFallback,
|
|
131
219
|
likedOnly,
|
|
132
220
|
refreshTick,
|
|
133
221
|
replyFilter,
|
|
134
222
|
resource,
|
|
135
|
-
search,
|
|
136
223
|
selectedAccountId,
|
|
137
224
|
]);
|
|
138
225
|
|
|
@@ -147,7 +234,13 @@ export function useTimelineRouteData({
|
|
|
147
234
|
loadMoreControllerRef.current = controller;
|
|
148
235
|
setLoadingMore(true);
|
|
149
236
|
try {
|
|
150
|
-
const
|
|
237
|
+
const nextPageUrl = new URL(
|
|
238
|
+
activeRequestUrlRef.current,
|
|
239
|
+
window.location.origin,
|
|
240
|
+
);
|
|
241
|
+
nextPageUrl.searchParams.set("until", until);
|
|
242
|
+
nextPageUrl.searchParams.set("untilId", untilId);
|
|
243
|
+
const data = await fetchCachedQueryResponse(nextPageUrl, {
|
|
151
244
|
signal: controller.signal,
|
|
152
245
|
});
|
|
153
246
|
// Discard if the filters changed (new generation) while in flight.
|
|
@@ -155,7 +248,12 @@ export function useTimelineRouteData({
|
|
|
155
248
|
const page = data.items as TimelineItem[];
|
|
156
249
|
setItems((prev) => {
|
|
157
250
|
const seen = new Set(prev.map((item) => item.id));
|
|
158
|
-
|
|
251
|
+
const next = [...prev, ...page.filter((item) => !seen.has(item.id))];
|
|
252
|
+
writeClientCache(timelineViewCacheKey(activeRequestUrlRef.current), {
|
|
253
|
+
items: next,
|
|
254
|
+
hasMore: page.length >= PAGE_SIZE,
|
|
255
|
+
});
|
|
256
|
+
return next;
|
|
159
257
|
});
|
|
160
258
|
setHasMore(page.length >= PAGE_SIZE);
|
|
161
259
|
} catch (loadError) {
|
|
@@ -175,12 +273,16 @@ export function useTimelineRouteData({
|
|
|
175
273
|
}
|
|
176
274
|
|
|
177
275
|
function retry() {
|
|
276
|
+
invalidateCachedQueryResponse(activeRequestUrlRef.current);
|
|
277
|
+
deleteClientCache(timelineViewCacheKey(activeRequestUrlRef.current));
|
|
178
278
|
setRefreshTick((value) => value + 1);
|
|
179
279
|
}
|
|
180
280
|
|
|
181
281
|
function refreshLocalView() {
|
|
282
|
+
invalidateCachedQueryResponses();
|
|
283
|
+
deleteClientCacheByPrefix(TIMELINE_VIEW_CACHE_PREFIX);
|
|
182
284
|
setRefreshTick((value) => value + 1);
|
|
183
|
-
void loadStatus();
|
|
285
|
+
void loadStatus(true);
|
|
184
286
|
}
|
|
185
287
|
|
|
186
288
|
async function replyToTweet(tweetId: string) {
|
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
|
};
|