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
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
-
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
4
|
import {
|
|
6
5
|
parseBoundedInteger,
|
|
7
6
|
runRouteEffect,
|
|
8
7
|
sensitiveRequestErrorResponse,
|
|
9
8
|
} from "#/lib/http-effect";
|
|
9
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
10
10
|
import {
|
|
11
11
|
streamProfileAnalysisEffect,
|
|
12
12
|
type ProfileAnalysisOptions,
|
|
13
13
|
type ProfileAnalysisStreamEvent,
|
|
14
14
|
} from "#/lib/profile-analysis";
|
|
15
15
|
|
|
16
|
-
const encoder = new TextEncoder();
|
|
17
|
-
|
|
18
16
|
function parseBoolean(value: string | null) {
|
|
19
17
|
return value === "true" || value === "1" || value === "yes";
|
|
20
18
|
}
|
|
@@ -57,10 +55,6 @@ function parseOptions(url: URL): ProfileAnalysisOptions {
|
|
|
57
55
|
};
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
function encodeEvent(event: ProfileAnalysisStreamEvent) {
|
|
61
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
58
|
export const Route = createFileRoute("/api/profile-analysis")({
|
|
65
59
|
server: {
|
|
66
60
|
handlers: {
|
|
@@ -72,79 +66,30 @@ export const Route = createFileRoute("/api/profile-analysis")({
|
|
|
72
66
|
|
|
73
67
|
const url = new URL(request.url);
|
|
74
68
|
const options = parseOptions(url);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
return createEffectNdjsonResponse<ProfileAnalysisStreamEvent>({
|
|
70
|
+
request,
|
|
71
|
+
initialEvents: [
|
|
72
|
+
{
|
|
73
|
+
type: "status",
|
|
74
|
+
label: "Starting profile analysis",
|
|
81
75
|
},
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const closeController = () => {
|
|
90
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
91
|
-
if (!closed) {
|
|
92
|
-
closed = true;
|
|
93
|
-
controller.close();
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
const onAbort = () => close();
|
|
97
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
98
|
-
once: true,
|
|
99
|
-
});
|
|
100
|
-
abortAnalysis = close;
|
|
101
|
-
const enqueue = (event: ProfileAnalysisStreamEvent) => {
|
|
102
|
-
if (closed) return;
|
|
103
|
-
try {
|
|
104
|
-
controller.enqueue(encodeEvent(event));
|
|
105
|
-
} catch {
|
|
106
|
-
close();
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
enqueue({
|
|
110
|
-
type: "status",
|
|
111
|
-
label: "Starting profile analysis",
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
runEffectBackground(
|
|
115
|
-
Effect.gen(function* () {
|
|
116
|
-
yield* maybeAutoUpdateBackupEffect();
|
|
117
|
-
return yield* streamProfileAnalysisEffect(
|
|
118
|
-
{
|
|
119
|
-
...options,
|
|
120
|
-
signal: abortController.signal,
|
|
121
|
-
},
|
|
122
|
-
{ onEvent: enqueue },
|
|
123
|
-
);
|
|
124
|
-
}),
|
|
125
|
-
{
|
|
126
|
-
onSuccess: closeController,
|
|
127
|
-
onFailure: (error) => {
|
|
128
|
-
enqueue({
|
|
129
|
-
type: "error",
|
|
130
|
-
error:
|
|
131
|
-
error instanceof Error
|
|
132
|
-
? error.message
|
|
133
|
-
: "Profile analysis failed",
|
|
134
|
-
});
|
|
135
|
-
closeController();
|
|
136
|
-
},
|
|
137
|
-
},
|
|
76
|
+
],
|
|
77
|
+
run: ({ signal, emit }) =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
yield* maybeAutoUpdateBackupEffect();
|
|
80
|
+
return yield* streamProfileAnalysisEffect(
|
|
81
|
+
{ ...options, signal },
|
|
82
|
+
{ onEvent: emit },
|
|
138
83
|
);
|
|
139
|
-
},
|
|
84
|
+
}),
|
|
85
|
+
errorEvent: (error) => ({
|
|
86
|
+
type: "error",
|
|
87
|
+
error:
|
|
88
|
+
error instanceof Error
|
|
89
|
+
? error.message
|
|
90
|
+
: "Profile analysis failed",
|
|
140
91
|
}),
|
|
141
|
-
|
|
142
|
-
headers: {
|
|
143
|
-
"cache-control": "no-store",
|
|
144
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
);
|
|
92
|
+
});
|
|
148
93
|
}),
|
|
149
94
|
),
|
|
150
95
|
},
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
-
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
4
|
import {
|
|
6
5
|
parseBoundedInteger,
|
|
7
6
|
runRouteEffect,
|
|
8
7
|
sensitiveRequestErrorResponse,
|
|
9
8
|
} from "#/lib/http-effect";
|
|
9
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
10
10
|
import {
|
|
11
11
|
streamSearchDiscussionEffect,
|
|
12
12
|
type SearchDiscussionOptions,
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
} from "#/lib/search-discussion";
|
|
16
16
|
import type { TweetSearchMode } from "#/lib/tweet-search-live";
|
|
17
17
|
|
|
18
|
-
const encoder = new TextEncoder();
|
|
19
18
|
const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
|
|
20
19
|
const MAX_DISCUSSION_SEARCH_PAGES = 200;
|
|
21
20
|
|
|
@@ -73,10 +72,6 @@ function parseOptions(url: URL): SearchDiscussionOptions {
|
|
|
73
72
|
};
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
function encodeEvent(event: SearchDiscussionStreamEvent) {
|
|
77
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
75
|
export const Route = createFileRoute("/api/search-discussion")({
|
|
81
76
|
server: {
|
|
82
77
|
handlers: {
|
|
@@ -88,80 +83,29 @@ export const Route = createFileRoute("/api/search-discussion")({
|
|
|
88
83
|
|
|
89
84
|
const url = new URL(request.url);
|
|
90
85
|
const options = parseOptions(url);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const abortController = new AbortController();
|
|
100
|
-
let closed = false;
|
|
101
|
-
const close = () => {
|
|
102
|
-
closed = true;
|
|
103
|
-
abortController.abort();
|
|
104
|
-
};
|
|
105
|
-
const closeController = () => {
|
|
106
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
107
|
-
if (!closed) {
|
|
108
|
-
closed = true;
|
|
109
|
-
controller.close();
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
const onAbort = () => close();
|
|
113
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
114
|
-
once: true,
|
|
115
|
-
});
|
|
116
|
-
abortDiscussion = close;
|
|
117
|
-
const enqueue = (event: SearchDiscussionStreamEvent) => {
|
|
118
|
-
if (closed) return;
|
|
119
|
-
try {
|
|
120
|
-
controller.enqueue(encodeEvent(event));
|
|
121
|
-
} catch {
|
|
122
|
-
close();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
runEffectBackground(
|
|
127
|
-
maybeAutoUpdateBackupEffect().pipe(
|
|
128
|
-
Effect.flatMap(() => {
|
|
129
|
-
if (closed || abortController.signal.aborted) {
|
|
130
|
-
return Effect.succeed(undefined);
|
|
131
|
-
}
|
|
132
|
-
return streamSearchDiscussionEffect(
|
|
86
|
+
return createEffectNdjsonResponse<SearchDiscussionStreamEvent>({
|
|
87
|
+
request,
|
|
88
|
+
run: ({ signal, emit }) =>
|
|
89
|
+
maybeAutoUpdateBackupEffect().pipe(
|
|
90
|
+
Effect.flatMap(() =>
|
|
91
|
+
signal.aborted
|
|
92
|
+
? Effect.succeed(undefined)
|
|
93
|
+
: streamSearchDiscussionEffect(
|
|
133
94
|
{
|
|
134
95
|
...options,
|
|
135
|
-
signal
|
|
96
|
+
signal,
|
|
136
97
|
prefetchAvatars: true,
|
|
137
98
|
},
|
|
138
|
-
{ onEvent:
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
type: "error",
|
|
147
|
-
error:
|
|
148
|
-
error instanceof Error
|
|
149
|
-
? error.message
|
|
150
|
-
: "Discussion failed",
|
|
151
|
-
});
|
|
152
|
-
closeController();
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
},
|
|
99
|
+
{ onEvent: emit },
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
errorEvent: (error) => ({
|
|
104
|
+
type: "error",
|
|
105
|
+
error:
|
|
106
|
+
error instanceof Error ? error.message : "Discussion failed",
|
|
157
107
|
}),
|
|
158
|
-
|
|
159
|
-
headers: {
|
|
160
|
-
"cache-control": "no-store",
|
|
161
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
);
|
|
108
|
+
});
|
|
165
109
|
}),
|
|
166
110
|
),
|
|
167
111
|
},
|
|
@@ -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
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
LinkSkeletonRows,
|
|
19
19
|
} from "#/components/FeedState";
|
|
20
20
|
import { ProfilePreview } from "#/components/ProfilePreview";
|
|
21
|
-
import {
|
|
21
|
+
import { SmartTimestamp } from "#/components/SmartTimestamp";
|
|
22
|
+
import {
|
|
23
|
+
loadClientCache,
|
|
24
|
+
readClientCache,
|
|
25
|
+
writeClientCache,
|
|
26
|
+
} from "#/lib/client-cache";
|
|
27
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
22
28
|
import type {
|
|
23
29
|
LinkInsightItem,
|
|
24
30
|
LinkInsightKind,
|
|
@@ -55,6 +61,10 @@ const LINK_INSIGHTS_LIMIT = 30;
|
|
|
55
61
|
const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
|
|
56
62
|
const PROFILE_HYDRATION_LIMIT = 30;
|
|
57
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>();
|
|
58
68
|
|
|
59
69
|
const ranges: Array<{ value: LinkInsightRange; label: string }> = [
|
|
60
70
|
{ value: "today", label: "Today" },
|
|
@@ -86,6 +96,19 @@ function insightCacheKey(
|
|
|
86
96
|
return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
|
|
87
97
|
}
|
|
88
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
|
+
|
|
89
112
|
function linkInsightsUrl(
|
|
90
113
|
kind: LinkInsightKind,
|
|
91
114
|
range: LinkInsightRange,
|
|
@@ -393,7 +416,7 @@ function MentionCard({
|
|
|
393
416
|
rel="noreferrer"
|
|
394
417
|
target="_blank"
|
|
395
418
|
>
|
|
396
|
-
{
|
|
419
|
+
<SmartTimestamp value={mention.createdAt} />
|
|
397
420
|
</a>
|
|
398
421
|
</div>
|
|
399
422
|
<p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
|
|
@@ -633,7 +656,7 @@ function LinkInsightRow({
|
|
|
633
656
|
<span>/</span>
|
|
634
657
|
<SharerStrip item={item} />
|
|
635
658
|
<span>/</span>
|
|
636
|
-
<
|
|
659
|
+
<SmartTimestamp value={item.lastSeenAt} />
|
|
637
660
|
</div>
|
|
638
661
|
<VideoPreview item={item} />
|
|
639
662
|
</div>
|
|
@@ -650,8 +673,16 @@ function LinksRoute() {
|
|
|
650
673
|
const [sort, setSort] = useState<LinkInsightSort>("rank");
|
|
651
674
|
const [search, setSearch] = useState("");
|
|
652
675
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
653
|
-
const
|
|
654
|
-
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
|
+
);
|
|
655
686
|
const inFlightRef = useRef(new Set<string>());
|
|
656
687
|
const mountedRef = useRef(true);
|
|
657
688
|
const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
|
|
@@ -679,14 +710,35 @@ function LinksRoute() {
|
|
|
679
710
|
if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
|
|
680
711
|
return;
|
|
681
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
|
+
}
|
|
682
725
|
inFlightRef.current.add(key);
|
|
683
726
|
setErrorByKey((current) => {
|
|
684
727
|
const rest = { ...current };
|
|
685
728
|
delete rest[key];
|
|
686
729
|
return rest;
|
|
687
730
|
});
|
|
688
|
-
|
|
689
|
-
|
|
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
|
+
)
|
|
690
742
|
.then((response: LinkInsightResponse) => {
|
|
691
743
|
cacheRef.current.set(key, response);
|
|
692
744
|
if (mountedRef.current) {
|
|
@@ -731,7 +783,9 @@ function LinksRoute() {
|
|
|
731
783
|
|
|
732
784
|
useEffect(() => {
|
|
733
785
|
const handles = collectProfilesForHydration(data).filter(
|
|
734
|
-
(handle) =>
|
|
786
|
+
(handle) =>
|
|
787
|
+
!readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
|
|
788
|
+
!hydratingLinkProfileHandles.has(handle.toLowerCase()),
|
|
735
789
|
);
|
|
736
790
|
if (handles.length === 0) {
|
|
737
791
|
return;
|
|
@@ -741,19 +795,30 @@ function LinksRoute() {
|
|
|
741
795
|
const url = new URL("/api/profile-hydrate", window.location.origin);
|
|
742
796
|
url.searchParams.set("handles", handles.join(","));
|
|
743
797
|
for (const handle of handles) {
|
|
744
|
-
|
|
798
|
+
hydratingLinkProfileHandles.add(handle.toLowerCase());
|
|
745
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
|
+
};
|
|
746
809
|
|
|
747
810
|
let idleId: number | null = null;
|
|
748
811
|
const runHydration = () => {
|
|
749
812
|
fetch(url, { signal: controller.signal })
|
|
750
813
|
.then((response) => response.json())
|
|
751
814
|
.then((response: { hydratedProfiles?: number }) => {
|
|
815
|
+
finishHydration(true);
|
|
752
816
|
if ((response.hydratedProfiles ?? 0) > 0) {
|
|
753
817
|
setRefreshTick((value) => value + 1);
|
|
754
818
|
}
|
|
755
819
|
})
|
|
756
820
|
.catch((error: unknown) => {
|
|
821
|
+
finishHydration(false);
|
|
757
822
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
758
823
|
return;
|
|
759
824
|
}
|
|
@@ -770,6 +835,7 @@ function LinksRoute() {
|
|
|
770
835
|
|
|
771
836
|
return () => {
|
|
772
837
|
controller.abort();
|
|
838
|
+
finishHydration(false);
|
|
773
839
|
window.clearTimeout(timer);
|
|
774
840
|
if (idleId !== null && "cancelIdleCallback" in window) {
|
|
775
841
|
window.cancelIdleCallback(idleId);
|