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/sqlite.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type Database = NativeSqliteDatabase;
|
|
|
5
5
|
type DatabaseOptions = {
|
|
6
6
|
readonly?: boolean;
|
|
7
7
|
fileMustExist?: boolean;
|
|
8
|
+
timeout?: number;
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
type PragmaOptions = {
|
|
@@ -16,6 +17,8 @@ type RunResult = {
|
|
|
16
17
|
lastInsertRowid: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
19
22
|
function bindArgs(parameters: unknown[]) {
|
|
20
23
|
if (parameters.length === 1 && Array.isArray(parameters[0])) {
|
|
21
24
|
return parameters[0];
|
|
@@ -87,7 +90,7 @@ export class NativeSqliteDatabase {
|
|
|
87
90
|
constructor(path: string, options: DatabaseOptions = {}) {
|
|
88
91
|
this.db = new DatabaseSync(path, {
|
|
89
92
|
readOnly: options.readonly,
|
|
90
|
-
timeout:
|
|
93
|
+
timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -123,7 +126,7 @@ export class NativeSqliteDatabase {
|
|
|
123
126
|
return (...args: TArgs) => {
|
|
124
127
|
const nested = this.db.isTransaction;
|
|
125
128
|
const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
|
|
126
|
-
this.exec(nested ? `savepoint ${savepoint}` : "begin");
|
|
129
|
+
this.exec(nested ? `savepoint ${savepoint}` : "begin immediate");
|
|
127
130
|
try {
|
|
128
131
|
const result = fn(...args);
|
|
129
132
|
this.exec(nested ? `release ${savepoint}` : "commit");
|
|
@@ -8,6 +8,7 @@ import { getNativeDb } from "./db";
|
|
|
8
8
|
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
9
9
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
10
10
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
11
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
11
12
|
import type {
|
|
12
13
|
XurlMentionData,
|
|
13
14
|
XurlMentionsResponse,
|
|
@@ -310,7 +311,7 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
310
311
|
countTweetMedia(tweet),
|
|
311
312
|
bookmarked,
|
|
312
313
|
liked,
|
|
313
|
-
JSON.stringify(tweet.entities
|
|
314
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
314
315
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
315
316
|
quotedTweetId,
|
|
316
317
|
);
|
package/src/lib/timeline-live.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
|
|
|
5
5
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
8
9
|
import type {
|
|
9
10
|
XurlMediaItem,
|
|
10
11
|
XurlMentionUser,
|
|
@@ -244,7 +245,7 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
244
245
|
replyToId,
|
|
245
246
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
246
247
|
countTweetMedia(tweet),
|
|
247
|
-
JSON.stringify(tweet.entities
|
|
248
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
248
249
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
249
250
|
quotedTweetId,
|
|
250
251
|
);
|
package/src/lib/tweet-render.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
TweetArticle,
|
|
2
3
|
TweetEntities,
|
|
3
4
|
TweetHashtagEntity,
|
|
4
5
|
TweetMentionEntity,
|
|
@@ -57,6 +58,34 @@ export function displayUrlForLink(url: string) {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
function comparableUrl(value: string) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = new URL(value);
|
|
64
|
+
return `${parsed.protocol}//${parsed.hostname.replace(/^www\./, "")}${parsed.pathname}`;
|
|
65
|
+
} catch {
|
|
66
|
+
return value.split("?")[0] ?? value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isTweetArticleUrlEntity(
|
|
71
|
+
entry: TweetUrlEntity,
|
|
72
|
+
article: TweetArticle,
|
|
73
|
+
) {
|
|
74
|
+
if (comparableUrl(entry.expandedUrl) === comparableUrl(article.url)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = new URL(entry.expandedUrl);
|
|
79
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
80
|
+
return (
|
|
81
|
+
(host === "x.com" || host === "twitter.com") &&
|
|
82
|
+
parsed.pathname.startsWith("/i/article/")
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
function asRecord(value: unknown) {
|
|
61
90
|
return value && typeof value === "object"
|
|
62
91
|
? (value as Record<string, unknown>)
|
|
@@ -68,6 +97,9 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
|
68
97
|
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
69
98
|
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
70
99
|
const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
|
|
100
|
+
const rawArticle = asRecord(entities.article);
|
|
101
|
+
const articleTitle = String(rawArticle.title ?? "").trim();
|
|
102
|
+
const articleUrl = String(rawArticle.url ?? "").trim();
|
|
71
103
|
|
|
72
104
|
return {
|
|
73
105
|
...(rawMentions.length
|
|
@@ -102,6 +134,23 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
|
102
134
|
),
|
|
103
135
|
start: Number(value.start ?? 0),
|
|
104
136
|
end: Number(value.end ?? 0),
|
|
137
|
+
...(typeof value.title === "string"
|
|
138
|
+
? { title: value.title }
|
|
139
|
+
: {}),
|
|
140
|
+
...(typeof value.description === "string" ||
|
|
141
|
+
value.description === null
|
|
142
|
+
? { description: value.description }
|
|
143
|
+
: {}),
|
|
144
|
+
...(typeof value.imageUrl === "string"
|
|
145
|
+
? { imageUrl: value.imageUrl }
|
|
146
|
+
: typeof value.image_url === "string"
|
|
147
|
+
? { imageUrl: value.image_url }
|
|
148
|
+
: {}),
|
|
149
|
+
...(typeof value.siteName === "string"
|
|
150
|
+
? { siteName: value.siteName }
|
|
151
|
+
: typeof value.site_name === "string"
|
|
152
|
+
? { siteName: value.site_name }
|
|
153
|
+
: {}),
|
|
105
154
|
};
|
|
106
155
|
}),
|
|
107
156
|
}
|
|
@@ -118,6 +167,35 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
|
118
167
|
}),
|
|
119
168
|
}
|
|
120
169
|
: {}),
|
|
170
|
+
...(articleTitle && articleUrl
|
|
171
|
+
? {
|
|
172
|
+
article: {
|
|
173
|
+
title: articleTitle,
|
|
174
|
+
url: articleUrl,
|
|
175
|
+
...(typeof (rawArticle.previewText ?? rawArticle.preview_text) ===
|
|
176
|
+
"string" &&
|
|
177
|
+
String(rawArticle.previewText ?? rawArticle.preview_text).trim()
|
|
178
|
+
? {
|
|
179
|
+
previewText: String(
|
|
180
|
+
rawArticle.previewText ?? rawArticle.preview_text,
|
|
181
|
+
).trim(),
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
184
|
+
...(typeof (
|
|
185
|
+
rawArticle.coverImageUrl ?? rawArticle.cover_image_url
|
|
186
|
+
) === "string" &&
|
|
187
|
+
String(
|
|
188
|
+
rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
|
|
189
|
+
).trim()
|
|
190
|
+
? {
|
|
191
|
+
coverImageUrl: String(
|
|
192
|
+
rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
|
|
193
|
+
).trim(),
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
121
199
|
};
|
|
122
200
|
}
|
|
123
201
|
|
|
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
|
|
|
5
5
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
8
9
|
import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
|
|
9
10
|
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
10
11
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
@@ -190,7 +191,7 @@ function mergeTweetSearchIntoLocalStore(
|
|
|
190
191
|
replyToId,
|
|
191
192
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
192
193
|
countTweetMedia(tweet),
|
|
193
|
-
JSON.stringify(tweet.entities
|
|
194
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
194
195
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
195
196
|
quotedTweetId,
|
|
196
197
|
);
|
package/src/lib/types.ts
CHANGED
|
@@ -101,10 +101,18 @@ export interface TweetHashtagEntity {
|
|
|
101
101
|
end: number;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
export interface TweetArticle {
|
|
105
|
+
title: string;
|
|
106
|
+
previewText?: string;
|
|
107
|
+
url: string;
|
|
108
|
+
coverImageUrl?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
export interface TweetEntities {
|
|
105
112
|
mentions?: TweetMentionEntity[];
|
|
106
113
|
urls?: TweetUrlEntity[];
|
|
107
114
|
hashtags?: TweetHashtagEntity[];
|
|
115
|
+
article?: TweetArticle;
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
export interface TweetMediaItem {
|
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
|
-
"
|
|
314
|
+
"fixed z-40 w-[280px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 shadow-[0_8px_28px_var(--shadow-strong)]";
|
|
315
315
|
|
|
316
316
|
export const profilePreviewHeaderClass = "flex items-center gap-3";
|
|
317
317
|
|
package/src/router.tsx
CHANGED
package/src/routes/__root.tsx
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { TanStackDevtools } from "@tanstack/react-devtools";
|
|
2
1
|
import {
|
|
3
2
|
createRootRoute,
|
|
4
3
|
HeadContent,
|
|
5
4
|
Scripts,
|
|
6
5
|
useRouterState,
|
|
7
6
|
} from "@tanstack/react-router";
|
|
8
|
-
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
|
9
7
|
import type { ReactNode } from "react";
|
|
10
8
|
import { AppNav } from "#/components/AppNav";
|
|
11
9
|
import { ThemeProvider, themeScript } from "#/lib/theme";
|
|
@@ -74,17 +72,6 @@ function RootDocument({ children }: { children: ReactNode }) {
|
|
|
74
72
|
</main>
|
|
75
73
|
</div>
|
|
76
74
|
</ThemeProvider>
|
|
77
|
-
<TanStackDevtools
|
|
78
|
-
config={{
|
|
79
|
-
position: "bottom-right",
|
|
80
|
-
}}
|
|
81
|
-
plugins={[
|
|
82
|
-
{
|
|
83
|
-
name: "Tanstack Router",
|
|
84
|
-
render: <TanStackRouterDevtoolsPanel />,
|
|
85
|
-
},
|
|
86
|
-
]}
|
|
87
|
-
/>
|
|
88
75
|
<Scripts />
|
|
89
76
|
</body>
|
|
90
77
|
</html>
|
|
@@ -18,7 +18,9 @@ export const Route = createFileRoute("/api/status")({
|
|
|
18
18
|
if (denied) return denied;
|
|
19
19
|
|
|
20
20
|
yield* maybeAutoUpdateBackupEffect();
|
|
21
|
-
return jsonResponse(
|
|
21
|
+
return jsonResponse(
|
|
22
|
+
yield* getQueryEnvelopeEffect({ includeArchives: false }),
|
|
23
|
+
);
|
|
22
24
|
}),
|
|
23
25
|
),
|
|
24
26
|
},
|
package/src/routes/dms.tsx
CHANGED
|
@@ -6,9 +6,11 @@ import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
|
|
|
6
6
|
import { SyncNowButton } from "#/components/SyncNowButton";
|
|
7
7
|
import { useSelectedAccountId } from "#/components/account-selection";
|
|
8
8
|
import {
|
|
9
|
+
fetchCachedQueryResponse,
|
|
9
10
|
fetchQueryEnvelope,
|
|
10
|
-
|
|
11
|
+
invalidateCachedQueryResponses,
|
|
11
12
|
postAction,
|
|
13
|
+
readCachedQueryResponse,
|
|
12
14
|
} from "#/lib/api-client";
|
|
13
15
|
import type {
|
|
14
16
|
DmConversationItem,
|
|
@@ -16,6 +18,7 @@ import type {
|
|
|
16
18
|
QueryEnvelope,
|
|
17
19
|
ReplyFilter,
|
|
18
20
|
} from "#/lib/types";
|
|
21
|
+
import { useDebouncedValue } from "#/components/useDebouncedValue";
|
|
19
22
|
import {
|
|
20
23
|
cx,
|
|
21
24
|
pageHeaderClass,
|
|
@@ -83,9 +86,10 @@ function DmsRoute() {
|
|
|
83
86
|
const [error, setError] = useState<string | null>(null);
|
|
84
87
|
const [replyError, setReplyError] = useState<string | null>(null);
|
|
85
88
|
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
89
|
+
const debouncedSearch = useDebouncedValue(search, 180);
|
|
86
90
|
|
|
87
|
-
async function loadStatus() {
|
|
88
|
-
setMeta(await fetchQueryEnvelope());
|
|
91
|
+
async function loadStatus(force = false) {
|
|
92
|
+
setMeta(await fetchQueryEnvelope(undefined, { force }));
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
useEffect(() => {
|
|
@@ -113,29 +117,41 @@ function DmsRoute() {
|
|
|
113
117
|
if (selectedConversationId) {
|
|
114
118
|
url.searchParams.set("conversationId", selectedConversationId);
|
|
115
119
|
}
|
|
116
|
-
if (
|
|
117
|
-
url.searchParams.set("search",
|
|
120
|
+
if (debouncedSearch.trim()) {
|
|
121
|
+
url.searchParams.set("search", debouncedSearch.trim());
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
const applyResponse = (
|
|
125
|
+
data: Awaited<ReturnType<typeof fetchCachedQueryResponse>>,
|
|
126
|
+
) => {
|
|
127
|
+
const conversations = data.items as DmConversationItem[];
|
|
128
|
+
const nextSelected =
|
|
129
|
+
data.selectedConversation?.conversation.id ?? conversations[0]?.id;
|
|
130
|
+
setLoadedConversationId(data.selectedConversation?.conversation.id);
|
|
131
|
+
setItems(conversations);
|
|
132
|
+
setSelectedConversationId((current) => {
|
|
133
|
+
if (!current) return nextSelected;
|
|
134
|
+
return conversations.some((conversation) => conversation.id === current)
|
|
135
|
+
? current
|
|
136
|
+
: nextSelected;
|
|
137
|
+
});
|
|
138
|
+
setMessages(data.selectedConversation?.messages ?? []);
|
|
139
|
+
};
|
|
120
140
|
setError(null);
|
|
141
|
+
const cached = readCachedQueryResponse(url);
|
|
142
|
+
if (cached) {
|
|
143
|
+
applyResponse(cached);
|
|
144
|
+
setLoading(false);
|
|
145
|
+
return () => {
|
|
146
|
+
active = false;
|
|
147
|
+
controller.abort();
|
|
148
|
+
};
|
|
149
|
+
}
|
|
121
150
|
setLoading(true);
|
|
122
|
-
|
|
151
|
+
fetchCachedQueryResponse(url, { signal: controller.signal })
|
|
123
152
|
.then((data) => {
|
|
124
153
|
if (!active) return;
|
|
125
|
-
|
|
126
|
-
const nextSelected =
|
|
127
|
-
data.selectedConversation?.conversation.id ?? conversations[0]?.id;
|
|
128
|
-
setLoadedConversationId(data.selectedConversation?.conversation.id);
|
|
129
|
-
setItems(conversations);
|
|
130
|
-
setSelectedConversationId((current) => {
|
|
131
|
-
if (!current) return nextSelected;
|
|
132
|
-
return conversations.some(
|
|
133
|
-
(conversation) => conversation.id === current,
|
|
134
|
-
)
|
|
135
|
-
? current
|
|
136
|
-
: nextSelected;
|
|
137
|
-
});
|
|
138
|
-
setMessages(data.selectedConversation?.messages ?? []);
|
|
154
|
+
applyResponse(data);
|
|
139
155
|
})
|
|
140
156
|
.catch((fetchError: unknown) => {
|
|
141
157
|
if (
|
|
@@ -170,7 +186,7 @@ function DmsRoute() {
|
|
|
170
186
|
inboxFilter,
|
|
171
187
|
refreshTick,
|
|
172
188
|
replyFilter,
|
|
173
|
-
|
|
189
|
+
debouncedSearch,
|
|
174
190
|
selectedConversationId,
|
|
175
191
|
selectedAccountId,
|
|
176
192
|
sort,
|
|
@@ -247,6 +263,7 @@ function DmsRoute() {
|
|
|
247
263
|
text,
|
|
248
264
|
});
|
|
249
265
|
|
|
266
|
+
invalidateCachedQueryResponses();
|
|
250
267
|
setSelectedConversationId(conversationId);
|
|
251
268
|
setRefreshTick((value) => value + 1);
|
|
252
269
|
} catch (error) {
|
|
@@ -258,8 +275,9 @@ function DmsRoute() {
|
|
|
258
275
|
}
|
|
259
276
|
|
|
260
277
|
function refreshLocalView() {
|
|
278
|
+
invalidateCachedQueryResponses();
|
|
261
279
|
setRefreshTick((value) => value + 1);
|
|
262
|
-
void loadStatus();
|
|
280
|
+
void loadStatus(true);
|
|
263
281
|
}
|
|
264
282
|
|
|
265
283
|
return (
|
package/src/routes/links.tsx
CHANGED
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
} from "#/components/FeedState";
|
|
20
20
|
import { ProfilePreview } from "#/components/ProfilePreview";
|
|
21
21
|
import { SmartTimestamp } from "#/components/SmartTimestamp";
|
|
22
|
+
import {
|
|
23
|
+
loadClientCache,
|
|
24
|
+
readClientCache,
|
|
25
|
+
writeClientCache,
|
|
26
|
+
} from "#/lib/client-cache";
|
|
22
27
|
import { formatCompactNumber } from "#/lib/present";
|
|
23
28
|
import type {
|
|
24
29
|
LinkInsightItem,
|
|
@@ -56,6 +61,10 @@ const LINK_INSIGHTS_LIMIT = 30;
|
|
|
56
61
|
const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
|
|
57
62
|
const PROFILE_HYDRATION_LIMIT = 30;
|
|
58
63
|
const PROFILE_HYDRATION_DELAY_MS = 1200;
|
|
64
|
+
const LINK_INSIGHTS_CACHE_PREFIX = "link-insights:";
|
|
65
|
+
const LINK_INSIGHTS_CACHE_MAX_AGE_MS = 5 * 60_000;
|
|
66
|
+
const LINK_PROFILE_HYDRATED_CACHE_PREFIX = "link-profile-hydrated:";
|
|
67
|
+
const hydratingLinkProfileHandles = new Set<string>();
|
|
59
68
|
|
|
60
69
|
const ranges: Array<{ value: LinkInsightRange; label: string }> = [
|
|
61
70
|
{ value: "today", label: "Today" },
|
|
@@ -87,6 +96,19 @@ function insightCacheKey(
|
|
|
87
96
|
return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
|
|
88
97
|
}
|
|
89
98
|
|
|
99
|
+
function sharedInsightCacheKey(
|
|
100
|
+
kind: LinkInsightKind,
|
|
101
|
+
range: LinkInsightRange,
|
|
102
|
+
sort: LinkInsightSort,
|
|
103
|
+
source: LinkInsightSource,
|
|
104
|
+
) {
|
|
105
|
+
return `${LINK_INSIGHTS_CACHE_PREFIX}${kind}:${range}:${sort}:${source}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hydratedProfileCacheKey(handle: string) {
|
|
109
|
+
return `${LINK_PROFILE_HYDRATED_CACHE_PREFIX}${handle.toLowerCase()}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
function linkInsightsUrl(
|
|
91
113
|
kind: LinkInsightKind,
|
|
92
114
|
range: LinkInsightRange,
|
|
@@ -651,8 +673,16 @@ function LinksRoute() {
|
|
|
651
673
|
const [sort, setSort] = useState<LinkInsightSort>("rank");
|
|
652
674
|
const [search, setSearch] = useState("");
|
|
653
675
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
654
|
-
const
|
|
655
|
-
const
|
|
676
|
+
const initialCacheKey = insightCacheKey(kind, range, sort, source, 0);
|
|
677
|
+
const initialSharedData = readClientCache<LinkInsightResponse>(
|
|
678
|
+
sharedInsightCacheKey(kind, range, sort, source),
|
|
679
|
+
LINK_INSIGHTS_CACHE_MAX_AGE_MS,
|
|
680
|
+
);
|
|
681
|
+
const cacheRef = useRef(
|
|
682
|
+
new Map<string, LinkInsightResponse>(
|
|
683
|
+
initialSharedData ? [[initialCacheKey, initialSharedData]] : [],
|
|
684
|
+
),
|
|
685
|
+
);
|
|
656
686
|
const inFlightRef = useRef(new Set<string>());
|
|
657
687
|
const mountedRef = useRef(true);
|
|
658
688
|
const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
|
|
@@ -680,14 +710,35 @@ function LinksRoute() {
|
|
|
680
710
|
if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
|
|
681
711
|
return;
|
|
682
712
|
}
|
|
713
|
+
const sharedKey = sharedInsightCacheKey(fetchKind, range, sort, source);
|
|
714
|
+
if (refreshTick === 0) {
|
|
715
|
+
const cached = readClientCache<LinkInsightResponse>(
|
|
716
|
+
sharedKey,
|
|
717
|
+
LINK_INSIGHTS_CACHE_MAX_AGE_MS,
|
|
718
|
+
);
|
|
719
|
+
if (cached) {
|
|
720
|
+
cacheRef.current.set(key, cached);
|
|
721
|
+
bumpCacheVersion((value) => value + 1);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
683
725
|
inFlightRef.current.add(key);
|
|
684
726
|
setErrorByKey((current) => {
|
|
685
727
|
const rest = { ...current };
|
|
686
728
|
delete rest[key];
|
|
687
729
|
return rest;
|
|
688
730
|
});
|
|
689
|
-
|
|
690
|
-
|
|
731
|
+
loadClientCache(
|
|
732
|
+
sharedKey,
|
|
733
|
+
() =>
|
|
734
|
+
fetch(
|
|
735
|
+
linkInsightsUrl(fetchKind, range, sort, source, refreshTick),
|
|
736
|
+
).then((response) => response.json() as Promise<LinkInsightResponse>),
|
|
737
|
+
{
|
|
738
|
+
force: refreshTick > 0,
|
|
739
|
+
maxAgeMs: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
|
|
740
|
+
},
|
|
741
|
+
)
|
|
691
742
|
.then((response: LinkInsightResponse) => {
|
|
692
743
|
cacheRef.current.set(key, response);
|
|
693
744
|
if (mountedRef.current) {
|
|
@@ -732,7 +783,9 @@ function LinksRoute() {
|
|
|
732
783
|
|
|
733
784
|
useEffect(() => {
|
|
734
785
|
const handles = collectProfilesForHydration(data).filter(
|
|
735
|
-
(handle) =>
|
|
786
|
+
(handle) =>
|
|
787
|
+
!readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
|
|
788
|
+
!hydratingLinkProfileHandles.has(handle.toLowerCase()),
|
|
736
789
|
);
|
|
737
790
|
if (handles.length === 0) {
|
|
738
791
|
return;
|
|
@@ -742,19 +795,30 @@ function LinksRoute() {
|
|
|
742
795
|
const url = new URL("/api/profile-hydrate", window.location.origin);
|
|
743
796
|
url.searchParams.set("handles", handles.join(","));
|
|
744
797
|
for (const handle of handles) {
|
|
745
|
-
|
|
798
|
+
hydratingLinkProfileHandles.add(handle.toLowerCase());
|
|
746
799
|
}
|
|
800
|
+
const finishHydration = (succeeded: boolean) => {
|
|
801
|
+
for (const handle of handles) {
|
|
802
|
+
const normalized = handle.toLowerCase();
|
|
803
|
+
hydratingLinkProfileHandles.delete(normalized);
|
|
804
|
+
if (succeeded) {
|
|
805
|
+
writeClientCache(hydratedProfileCacheKey(normalized), true);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
747
809
|
|
|
748
810
|
let idleId: number | null = null;
|
|
749
811
|
const runHydration = () => {
|
|
750
812
|
fetch(url, { signal: controller.signal })
|
|
751
813
|
.then((response) => response.json())
|
|
752
814
|
.then((response: { hydratedProfiles?: number }) => {
|
|
815
|
+
finishHydration(true);
|
|
753
816
|
if ((response.hydratedProfiles ?? 0) > 0) {
|
|
754
817
|
setRefreshTick((value) => value + 1);
|
|
755
818
|
}
|
|
756
819
|
})
|
|
757
820
|
.catch((error: unknown) => {
|
|
821
|
+
finishHydration(false);
|
|
758
822
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
759
823
|
return;
|
|
760
824
|
}
|
|
@@ -771,6 +835,7 @@ function LinksRoute() {
|
|
|
771
835
|
|
|
772
836
|
return () => {
|
|
773
837
|
controller.abort();
|
|
838
|
+
finishHydration(false);
|
|
774
839
|
window.clearTimeout(timer);
|
|
775
840
|
if (idleId !== null && "cancelIdleCallback" in window) {
|
|
776
841
|
window.cancelIdleCallback(idleId);
|
package/vite.config.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import tailwindcss from "@tailwindcss/vite";
|
|
2
|
-
import { devtools } from "@tanstack/devtools-vite";
|
|
3
2
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|
4
3
|
import viteReact from "@vitejs/plugin-react";
|
|
5
4
|
import { defineConfig } from "vite";
|
|
@@ -11,7 +10,6 @@ const extraAllowedHosts =
|
|
|
11
10
|
|
|
12
11
|
const config = defineConfig({
|
|
13
12
|
plugins: [
|
|
14
|
-
devtools(),
|
|
15
13
|
tailwindcss(),
|
|
16
14
|
tanstackStart({
|
|
17
15
|
router: {
|