birdclaw 0.8.2 → 0.8.3

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -0,0 +1,191 @@
1
+ import type {
2
+ ProfileAnalysisContext,
3
+ ProfileAnalysisRunResult,
4
+ } from "#/lib/profile-analysis";
5
+ import type { ProfileRecord } from "#/lib/types";
6
+
7
+ export interface ProfileAnalysisRequestOptions {
8
+ refresh: boolean;
9
+ maxTweets: number;
10
+ maxPages: number;
11
+ maxConversations: number;
12
+ maxConversationPages: number;
13
+ }
14
+
15
+ export const DEFAULT_PROFILE_ANALYSIS_LIMITS = {
16
+ maxTweets: 10000,
17
+ maxPages: 100,
18
+ maxConversations: 80,
19
+ maxConversationPages: 3,
20
+ } as const;
21
+
22
+ const PROFILE_HYDRATION_LIMIT = 50;
23
+ const PROFILE_MENTION_RE = /(^|[^\w@./])@([A-Za-z0-9_]{1,15})\b/g;
24
+
25
+ export function normalizeProfileHandle(value: string) {
26
+ return value.trim().replace(/^@/, "").toLowerCase();
27
+ }
28
+
29
+ export function handlesFromText(value: string) {
30
+ return Array.from(value.matchAll(PROFILE_MENTION_RE)).map(
31
+ (match) => match[2],
32
+ );
33
+ }
34
+
35
+ export function knownProfileHandles(context: ProfileAnalysisContext) {
36
+ const handles = new Set<string>();
37
+ handles.add(normalizeProfileHandle(context.profile.handle));
38
+ for (const profile of context.profiles ?? []) {
39
+ handles.add(normalizeProfileHandle(profile.handle));
40
+ }
41
+ for (const tweet of context.conversations) {
42
+ handles.add(normalizeProfileHandle(tweet.author));
43
+ }
44
+ return handles;
45
+ }
46
+
47
+ export function collectProfileAnalysisHydrationHandles({
48
+ context,
49
+ analysis,
50
+ markdown,
51
+ }: {
52
+ context: ProfileAnalysisContext;
53
+ analysis?: ProfileAnalysisRunResult["analysis"];
54
+ markdown?: string;
55
+ }) {
56
+ const handles = new Set<string>();
57
+ const known = knownProfileHandles(context);
58
+ const add = (value: string | undefined) => {
59
+ if (!value) return;
60
+ const handle = normalizeProfileHandle(value);
61
+ if (!/^[a-z0-9_]{1,15}$/.test(handle) || known.has(handle)) return;
62
+ handles.add(handle);
63
+ };
64
+
65
+ for (const handle of analysis?.sourceHandles ?? []) add(handle);
66
+ for (const theme of analysis?.themes ?? []) {
67
+ for (const handle of theme.handles) add(handle);
68
+ }
69
+ if (markdown) {
70
+ for (const handle of handlesFromText(markdown)) add(handle);
71
+ }
72
+ for (const handle of handlesFromText(context.profile.bio)) add(handle);
73
+ for (const tweet of context.tweets) {
74
+ for (const handle of handlesFromText(tweet.text)) add(handle);
75
+ }
76
+ for (const tweet of context.conversations) {
77
+ for (const handle of handlesFromText(tweet.text)) add(handle);
78
+ for (const handle of handlesFromText(tweet.bio)) add(handle);
79
+ }
80
+
81
+ return [...handles].slice(0, PROFILE_HYDRATION_LIMIT);
82
+ }
83
+
84
+ export function applyHydratedProfilesToProfileAnalysisContext(
85
+ context: ProfileAnalysisContext,
86
+ profiles: ProfileRecord[],
87
+ ) {
88
+ const existing = new Map<string, ProfileRecord>();
89
+ for (const profile of context.profiles ?? []) {
90
+ existing.set(normalizeProfileHandle(profile.handle), profile);
91
+ }
92
+ for (const profile of profiles) {
93
+ existing.set(normalizeProfileHandle(profile.handle), profile);
94
+ }
95
+ return {
96
+ ...context,
97
+ profiles: [...existing.values()],
98
+ };
99
+ }
100
+
101
+ export async function hydrateProfileAnalysisContext({
102
+ context,
103
+ analysis,
104
+ markdown,
105
+ requestedHandles,
106
+ }: {
107
+ context: ProfileAnalysisContext;
108
+ analysis?: ProfileAnalysisRunResult["analysis"];
109
+ markdown?: string;
110
+ requestedHandles?: Set<string>;
111
+ }) {
112
+ const handles = collectProfileAnalysisHydrationHandles({
113
+ context,
114
+ analysis,
115
+ markdown,
116
+ }).filter((handle) => !requestedHandles?.has(handle));
117
+ if (handles.length === 0) return context;
118
+ for (const handle of handles) {
119
+ requestedHandles?.add(handle);
120
+ }
121
+ const url = new URL("/api/profile-hydrate", window.location.origin);
122
+ url.searchParams.set("handles", handles.join(","));
123
+ const response = await fetch(url);
124
+ if (!response.ok) return context;
125
+ const payload = (await response.json()) as {
126
+ results?: Array<{ status?: string; profile?: ProfileRecord }>;
127
+ };
128
+ const profiles = (payload.results ?? [])
129
+ .filter((item) => item.status === "hit" && item.profile)
130
+ .map((item) => item.profile as ProfileRecord);
131
+ return profiles.length > 0
132
+ ? applyHydratedProfilesToProfileAnalysisContext(context, profiles)
133
+ : context;
134
+ }
135
+
136
+ export function profileAnalysisUrl(
137
+ handle: string,
138
+ options: ProfileAnalysisRequestOptions,
139
+ ) {
140
+ const params = new URLSearchParams();
141
+ params.set("handle", handle);
142
+ params.set("maxTweets", String(options.maxTweets));
143
+ params.set("maxPages", String(options.maxPages));
144
+ params.set("maxConversations", String(options.maxConversations));
145
+ params.set("maxConversationPages", String(options.maxConversationPages));
146
+ if (options.refresh) {
147
+ params.set("refresh", "true");
148
+ }
149
+ return `/api/profile-analysis?${params.toString()}`;
150
+ }
151
+
152
+ export async function profileAnalysisRequestError(response: Response) {
153
+ const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
154
+ let detail = "";
155
+ try {
156
+ const contentType = response.headers.get("content-type") ?? "";
157
+ if (contentType.includes("application/json")) {
158
+ const payload = (await response.json()) as {
159
+ error?: unknown;
160
+ message?: unknown;
161
+ };
162
+ if (typeof payload.message === "string") detail = payload.message;
163
+ else if (typeof payload.error === "string") detail = payload.error;
164
+ } else {
165
+ detail = (await response.text()).trim();
166
+ }
167
+ } catch {
168
+ detail = "";
169
+ }
170
+ return new Error(
171
+ detail
172
+ ? `Profile analysis failed (${status}): ${detail}`
173
+ : `Profile analysis failed (${status})`,
174
+ );
175
+ }
176
+
177
+ export function formatProfileAnalysisCounts(
178
+ context: ProfileAnalysisContext | null,
179
+ ) {
180
+ if (!context) return "xurl profile backfill with cached AI analysis.";
181
+ return [
182
+ context.fetchCached ? "cached backfill" : "fresh xurl backfill",
183
+ `${String(context.counts.tweets)} tweets`,
184
+ `${String(context.counts.conversationTweets)} conversation tweets`,
185
+ `${String(context.counts.conversationsScanned)} conversations`,
186
+ ].join(" · ");
187
+ }
188
+
189
+ export function cleanProfileHandle(value: string) {
190
+ return value.trim().replace(/^@/, "");
191
+ }
@@ -9,13 +9,22 @@ import type {
9
9
  import type { ProfileRecord } from "#/lib/types";
10
10
  import { errorCopyClass } from "#/lib/ui";
11
11
 
12
- export interface ProfileAnalysisRequestOptions {
13
- refresh: boolean;
14
- maxTweets: number;
15
- maxPages: number;
16
- maxConversations: number;
17
- maxConversationPages: number;
18
- }
12
+ import {
13
+ DEFAULT_PROFILE_ANALYSIS_LIMITS,
14
+ applyHydratedProfilesToProfileAnalysisContext,
15
+ cleanProfileHandle,
16
+ hydrateProfileAnalysisContext,
17
+ normalizeProfileHandle,
18
+ profileAnalysisRequestError,
19
+ profileAnalysisUrl,
20
+ } from "#/components/ProfileAnalysisClient";
21
+ export {
22
+ DEFAULT_PROFILE_ANALYSIS_LIMITS,
23
+ cleanProfileHandle,
24
+ formatProfileAnalysisCounts,
25
+ profileAnalysisRequestError,
26
+ profileAnalysisUrl,
27
+ } from "#/components/ProfileAnalysisClient";
19
28
 
20
29
  export interface ProfileAnalysisState {
21
30
  context: ProfileAnalysisContext | null;
@@ -27,184 +36,6 @@ export interface ProfileAnalysisState {
27
36
  status: string;
28
37
  }
29
38
 
30
- export const DEFAULT_PROFILE_ANALYSIS_LIMITS = {
31
- maxTweets: 10000,
32
- maxPages: 100,
33
- maxConversations: 80,
34
- maxConversationPages: 3,
35
- } as const;
36
-
37
- const PROFILE_HYDRATION_LIMIT = 50;
38
- const PROFILE_MENTION_RE = /(^|[^\w@./])@([A-Za-z0-9_]{1,15})\b/g;
39
-
40
- function normalizeProfileHandle(value: string) {
41
- return value.trim().replace(/^@/, "").toLowerCase();
42
- }
43
-
44
- function handlesFromText(value: string) {
45
- return Array.from(value.matchAll(PROFILE_MENTION_RE)).map(
46
- (match) => match[2],
47
- );
48
- }
49
-
50
- function knownProfileHandles(context: ProfileAnalysisContext) {
51
- const handles = new Set<string>();
52
- handles.add(normalizeProfileHandle(context.profile.handle));
53
- for (const profile of context.profiles ?? []) {
54
- handles.add(normalizeProfileHandle(profile.handle));
55
- }
56
- for (const tweet of context.conversations) {
57
- handles.add(normalizeProfileHandle(tweet.author));
58
- }
59
- return handles;
60
- }
61
-
62
- function collectProfileAnalysisHydrationHandles({
63
- context,
64
- analysis,
65
- markdown,
66
- }: {
67
- context: ProfileAnalysisContext;
68
- analysis?: ProfileAnalysisRunResult["analysis"];
69
- markdown?: string;
70
- }) {
71
- const handles = new Set<string>();
72
- const known = knownProfileHandles(context);
73
- const add = (value: string | undefined) => {
74
- if (!value) return;
75
- const handle = normalizeProfileHandle(value);
76
- if (!/^[a-z0-9_]{1,15}$/.test(handle) || known.has(handle)) return;
77
- handles.add(handle);
78
- };
79
-
80
- for (const handle of analysis?.sourceHandles ?? []) add(handle);
81
- for (const theme of analysis?.themes ?? []) {
82
- for (const handle of theme.handles) add(handle);
83
- }
84
- if (markdown) {
85
- for (const handle of handlesFromText(markdown)) add(handle);
86
- }
87
- for (const handle of handlesFromText(context.profile.bio)) add(handle);
88
- for (const tweet of context.tweets) {
89
- for (const handle of handlesFromText(tweet.text)) add(handle);
90
- }
91
- for (const tweet of context.conversations) {
92
- for (const handle of handlesFromText(tweet.text)) add(handle);
93
- for (const handle of handlesFromText(tweet.bio)) add(handle);
94
- }
95
-
96
- return [...handles].slice(0, PROFILE_HYDRATION_LIMIT);
97
- }
98
-
99
- function applyHydratedProfilesToProfileAnalysisContext(
100
- context: ProfileAnalysisContext,
101
- profiles: ProfileRecord[],
102
- ) {
103
- const existing = new Map<string, ProfileRecord>();
104
- for (const profile of context.profiles ?? []) {
105
- existing.set(normalizeProfileHandle(profile.handle), profile);
106
- }
107
- for (const profile of profiles) {
108
- existing.set(normalizeProfileHandle(profile.handle), profile);
109
- }
110
- return {
111
- ...context,
112
- profiles: [...existing.values()],
113
- };
114
- }
115
-
116
- async function hydrateProfileAnalysisContext({
117
- context,
118
- analysis,
119
- markdown,
120
- requestedHandles,
121
- }: {
122
- context: ProfileAnalysisContext;
123
- analysis?: ProfileAnalysisRunResult["analysis"];
124
- markdown?: string;
125
- requestedHandles?: Set<string>;
126
- }) {
127
- const handles = collectProfileAnalysisHydrationHandles({
128
- context,
129
- analysis,
130
- markdown,
131
- }).filter((handle) => !requestedHandles?.has(handle));
132
- if (handles.length === 0) return context;
133
- for (const handle of handles) {
134
- requestedHandles?.add(handle);
135
- }
136
- const url = new URL("/api/profile-hydrate", window.location.origin);
137
- url.searchParams.set("handles", handles.join(","));
138
- const response = await fetch(url);
139
- if (!response.ok) return context;
140
- const payload = (await response.json()) as {
141
- results?: Array<{ status?: string; profile?: ProfileRecord }>;
142
- };
143
- const profiles = (payload.results ?? [])
144
- .filter((item) => item.status === "hit" && item.profile)
145
- .map((item) => item.profile as ProfileRecord);
146
- return profiles.length > 0
147
- ? applyHydratedProfilesToProfileAnalysisContext(context, profiles)
148
- : context;
149
- }
150
-
151
- export function profileAnalysisUrl(
152
- handle: string,
153
- options: ProfileAnalysisRequestOptions,
154
- ) {
155
- const params = new URLSearchParams();
156
- params.set("handle", handle);
157
- params.set("maxTweets", String(options.maxTweets));
158
- params.set("maxPages", String(options.maxPages));
159
- params.set("maxConversations", String(options.maxConversations));
160
- params.set("maxConversationPages", String(options.maxConversationPages));
161
- if (options.refresh) {
162
- params.set("refresh", "true");
163
- }
164
- return `/api/profile-analysis?${params.toString()}`;
165
- }
166
-
167
- export async function profileAnalysisRequestError(response: Response) {
168
- const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
169
- let detail = "";
170
- try {
171
- const contentType = response.headers.get("content-type") ?? "";
172
- if (contentType.includes("application/json")) {
173
- const payload = (await response.json()) as {
174
- error?: unknown;
175
- message?: unknown;
176
- };
177
- if (typeof payload.message === "string") detail = payload.message;
178
- else if (typeof payload.error === "string") detail = payload.error;
179
- } else {
180
- detail = (await response.text()).trim();
181
- }
182
- } catch {
183
- detail = "";
184
- }
185
- return new Error(
186
- detail
187
- ? `Profile analysis failed (${status}): ${detail}`
188
- : `Profile analysis failed (${status})`,
189
- );
190
- }
191
-
192
- export function formatProfileAnalysisCounts(
193
- context: ProfileAnalysisContext | null,
194
- ) {
195
- if (!context) return "xurl profile backfill with cached AI analysis.";
196
- return [
197
- context.fetchCached ? "cached backfill" : "fresh xurl backfill",
198
- `${String(context.counts.tweets)} tweets`,
199
- `${String(context.counts.conversationTweets)} conversation tweets`,
200
- `${String(context.counts.conversationsScanned)} conversations`,
201
- ].join(" · ");
202
- }
203
-
204
- export function cleanProfileHandle(value: string) {
205
- return value.trim().replace(/^@/, "");
206
- }
207
-
208
39
  export function useProfileAnalysisStream(handle: string): ProfileAnalysisState {
209
40
  const [markdown, setMarkdown] = useState("");
210
41
  const [context, setContext] = useState<ProfileAnalysisContext | null>(null);
@@ -19,6 +19,7 @@ import {
19
19
  } from "#/lib/ui";
20
20
  import { safeHttpUrl } from "#/lib/url-safety";
21
21
  import { AvatarChip } from "./AvatarChip";
22
+ import { useAvatarPreload } from "./AvatarPreload";
22
23
  import { useFloatingPreview } from "./FloatingPreview";
23
24
 
24
25
  function ProfilePreviewBio({ profile }: { profile: ProfileRecord }) {
@@ -75,6 +76,7 @@ export function ProfilePreview({
75
76
  className?: string;
76
77
  }) {
77
78
  const preview = useFloatingPreview();
79
+ useAvatarPreload(preview.referenceRef, profile.id, profile.avatarUrl);
78
80
 
79
81
  return (
80
82
  <span
@@ -0,0 +1,162 @@
1
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { formatCompactNumber } from "#/lib/present";
4
+ import { queryKeys } from "#/lib/query-client";
5
+ import type {
6
+ LinkInsightKind,
7
+ LinkInsightRange,
8
+ LinkInsightSort,
9
+ LinkInsightSource,
10
+ } from "#/lib/types";
11
+ import {
12
+ LINK_INSIGHTS_CACHE_MAX_AGE_MS,
13
+ PROFILE_HYDRATION_DELAY_MS,
14
+ collectProfilesForHydration,
15
+ fetchLinkInsights,
16
+ hydratingLinkProfileHandles,
17
+ linkInsightQueryKey,
18
+ } from "./links-model";
19
+
20
+ export function useLinksController() {
21
+ const queryClient = useQueryClient();
22
+ const [kind, setKind] = useState<LinkInsightKind>("links");
23
+ const [range, setRange] = useState<LinkInsightRange>("week");
24
+ const [source, setSource] = useState<LinkInsightSource>("all");
25
+ const [sort, setSort] = useState<LinkInsightSort>("rank");
26
+ const [search, setSearch] = useState("");
27
+ const insightsQuery = useQuery({
28
+ queryKey: linkInsightQueryKey(kind, range, sort, source),
29
+ queryFn: ({ signal }) =>
30
+ fetchLinkInsights(kind, range, sort, source, signal),
31
+ staleTime: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
32
+ });
33
+ const data = insightsQuery.data ?? null;
34
+
35
+ useEffect(() => {
36
+ if (!data) return;
37
+ const prefetchKind = kind === "links" ? "videos" : "links";
38
+ const timer = window.setTimeout(() => {
39
+ void queryClient.prefetchQuery({
40
+ queryKey: linkInsightQueryKey(prefetchKind, range, sort, source),
41
+ queryFn: ({ signal }) =>
42
+ fetchLinkInsights(prefetchKind, range, sort, source, signal),
43
+ staleTime: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
44
+ });
45
+ }, 250);
46
+ return () => window.clearTimeout(timer);
47
+ }, [data, kind, queryClient, range, sort, source]);
48
+
49
+ useEffect(() => {
50
+ const handles = collectProfilesForHydration(data).filter((handle) => {
51
+ const normalized = handle.toLowerCase();
52
+ return (
53
+ queryClient.getQueryData([
54
+ ...queryKeys.profileHydration,
55
+ normalized,
56
+ ]) !== true && !hydratingLinkProfileHandles.has(normalized)
57
+ );
58
+ });
59
+ if (handles.length === 0) return;
60
+
61
+ const controller = new AbortController();
62
+ const url = new URL("/api/profile-hydrate", window.location.origin);
63
+ url.searchParams.set("handles", handles.join(","));
64
+ for (const handle of handles) {
65
+ hydratingLinkProfileHandles.add(handle.toLowerCase());
66
+ }
67
+ const finishHydration = (succeeded: boolean) => {
68
+ for (const handle of handles) {
69
+ const normalized = handle.toLowerCase();
70
+ hydratingLinkProfileHandles.delete(normalized);
71
+ if (succeeded) {
72
+ queryClient.setQueryData(
73
+ [...queryKeys.profileHydration, normalized],
74
+ true,
75
+ );
76
+ }
77
+ }
78
+ };
79
+
80
+ let idleId: number | null = null;
81
+ const runHydration = () => {
82
+ fetch(url, { signal: controller.signal })
83
+ .then((response) => response.json())
84
+ .then((response: { hydratedProfiles?: number }) => {
85
+ finishHydration(true);
86
+ if ((response.hydratedProfiles ?? 0) > 0) {
87
+ void queryClient.invalidateQueries({
88
+ queryKey: queryKeys.linkInsights,
89
+ });
90
+ }
91
+ })
92
+ .catch((error: unknown) => {
93
+ finishHydration(false);
94
+ if (error instanceof DOMException && error.name === "AbortError") {
95
+ return;
96
+ }
97
+ console.warn("Profile hydration failed", error);
98
+ });
99
+ };
100
+ const timer = window.setTimeout(() => {
101
+ if ("requestIdleCallback" in window) {
102
+ idleId = window.requestIdleCallback(runHydration, { timeout: 2500 });
103
+ } else {
104
+ runHydration();
105
+ }
106
+ }, PROFILE_HYDRATION_DELAY_MS);
107
+
108
+ return () => {
109
+ controller.abort();
110
+ finishHydration(false);
111
+ window.clearTimeout(timer);
112
+ if (idleId !== null && "cancelIdleCallback" in window) {
113
+ window.cancelIdleCallback(idleId);
114
+ }
115
+ };
116
+ }, [data, queryClient]);
117
+
118
+ const items = useMemo(() => {
119
+ const query = search.trim().toLowerCase();
120
+ return (data?.items ?? []).filter((item) => {
121
+ if (!query) return true;
122
+ return [
123
+ item.title,
124
+ item.description,
125
+ item.displayUrl,
126
+ item.host,
127
+ item.topSharer?.handle,
128
+ ...item.mentions.map((mention) => mention.commentText),
129
+ ]
130
+ .filter(Boolean)
131
+ .some((value) => String(value).toLowerCase().includes(query));
132
+ });
133
+ }, [data?.items, search]);
134
+
135
+ const subtitle = useMemo(() => {
136
+ if (!data) return "Loading link memory...";
137
+ const label = kind === "videos" ? "video URLs" : "URLs";
138
+ return `${formatCompactNumber(data.stats.occurrences)} ${label} across ${formatCompactNumber(data.stats.groups)} groups`;
139
+ }, [data, kind]);
140
+
141
+ return {
142
+ kind,
143
+ setKind,
144
+ range,
145
+ setRange,
146
+ source,
147
+ setSource,
148
+ sort,
149
+ setSort,
150
+ search,
151
+ setSearch,
152
+ items,
153
+ subtitle,
154
+ loading: insightsQuery.isPending,
155
+ error: insightsQuery.error
156
+ ? insightsQuery.error instanceof Error
157
+ ? insightsQuery.error.message
158
+ : "Link insights unavailable"
159
+ : null,
160
+ retry: insightsQuery.refetch,
161
+ };
162
+ }