birdclaw 0.8.1 → 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.
- package/CHANGELOG.md +25 -0
- package/package.json +2 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -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/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +7 -715
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +41 -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/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +142 -170
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -100
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +77 -182
- package/src/lib/backup.ts +335 -424
- package/src/lib/bird.ts +32 -1
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +141 -334
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -176
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -225
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +37 -111
- package/src/lib/queries.ts +6 -2380
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +53 -14
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -256
- package/src/lib/timeline-live.ts +86 -235
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -166
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +11 -21
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -2
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +168 -167
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -329
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/vite.config.ts +0 -2
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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);
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Fragment,
|
|
3
|
-
type ReactNode,
|
|
4
|
-
useLayoutEffect,
|
|
5
|
-
useRef,
|
|
6
|
-
useState,
|
|
7
|
-
} from "react";
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
8
2
|
import { formatCompactNumber } from "#/lib/present";
|
|
9
3
|
import {
|
|
10
4
|
collectTweetSegmentsForText,
|
|
@@ -25,25 +19,8 @@ import {
|
|
|
25
19
|
} from "#/lib/ui";
|
|
26
20
|
import { safeHttpUrl } from "#/lib/url-safety";
|
|
27
21
|
import { AvatarChip } from "./AvatarChip";
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
|
|
32
|
-
let top = 0;
|
|
33
|
-
let bottom = window.innerHeight;
|
|
34
|
-
for (
|
|
35
|
-
let current = element.parentElement;
|
|
36
|
-
current;
|
|
37
|
-
current = current.parentElement
|
|
38
|
-
) {
|
|
39
|
-
const style = window.getComputedStyle(current);
|
|
40
|
-
if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
|
|
41
|
-
const rect = current.getBoundingClientRect();
|
|
42
|
-
top = Math.max(top, rect.top);
|
|
43
|
-
bottom = Math.min(bottom, rect.bottom);
|
|
44
|
-
}
|
|
45
|
-
return { top, bottom };
|
|
46
|
-
}
|
|
22
|
+
import { useAvatarPreload } from "./AvatarPreload";
|
|
23
|
+
import { useFloatingPreview } from "./FloatingPreview";
|
|
47
24
|
|
|
48
25
|
function ProfilePreviewBio({ profile }: { profile: ProfileRecord }) {
|
|
49
26
|
const segments = collectTweetSegmentsForText(
|
|
@@ -98,74 +75,57 @@ export function ProfilePreview({
|
|
|
98
75
|
children: ReactNode;
|
|
99
76
|
className?: string;
|
|
100
77
|
}) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const cardRef = useRef<HTMLSpanElement | null>(null);
|
|
104
|
-
|
|
105
|
-
function updatePlacement() {
|
|
106
|
-
const shell = shellRef.current;
|
|
107
|
-
if (!shell) return;
|
|
108
|
-
const shellRect = shell.getBoundingClientRect();
|
|
109
|
-
const card = cardRef.current;
|
|
110
|
-
const cardRect = card?.getBoundingClientRect();
|
|
111
|
-
const cardHeight = Math.max(
|
|
112
|
-
card?.offsetHeight ?? 0,
|
|
113
|
-
cardRect?.height ?? 0,
|
|
114
|
-
180,
|
|
115
|
-
);
|
|
116
|
-
const bounds = nearestVerticalClipBounds(shell);
|
|
117
|
-
const belowSpace = bounds.bottom - shellRect.bottom;
|
|
118
|
-
const aboveSpace = shellRect.top - bounds.top;
|
|
119
|
-
setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
useLayoutEffect(() => {
|
|
123
|
-
updatePlacement();
|
|
124
|
-
const frame = window.requestAnimationFrame(updatePlacement);
|
|
125
|
-
return () => window.cancelAnimationFrame(frame);
|
|
126
|
-
}, []);
|
|
78
|
+
const preview = useFloatingPreview();
|
|
79
|
+
useAvatarPreload(preview.referenceRef, profile.id, profile.avatarUrl);
|
|
127
80
|
|
|
128
81
|
return (
|
|
129
82
|
<span
|
|
130
|
-
ref={
|
|
131
|
-
className={cx(profilePreviewClass,
|
|
132
|
-
|
|
133
|
-
onPointerEnter={updatePlacement}
|
|
83
|
+
ref={preview.referenceRef}
|
|
84
|
+
className={cx(profilePreviewClass, className)}
|
|
85
|
+
{...preview.referenceProps}
|
|
134
86
|
>
|
|
135
87
|
<a
|
|
88
|
+
aria-controls={preview.open ? preview.floatingId : undefined}
|
|
89
|
+
aria-expanded={preview.open}
|
|
136
90
|
className={profilePreviewTriggerClass}
|
|
137
91
|
href={`/profiles/${encodeURIComponent(profile.handle)}`}
|
|
138
92
|
>
|
|
139
93
|
{children}
|
|
140
94
|
</a>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
95
|
+
{preview.open ? (
|
|
96
|
+
<span
|
|
97
|
+
aria-label={`${profile.displayName} profile preview`}
|
|
98
|
+
id={preview.floatingId}
|
|
99
|
+
ref={preview.floatingRef}
|
|
100
|
+
className={profilePreviewCardClass}
|
|
101
|
+
role="group"
|
|
102
|
+
style={preview.floatingStyle}
|
|
103
|
+
{...preview.floatingProps}
|
|
104
|
+
>
|
|
105
|
+
<span className="grid gap-2" data-floating-preview-content>
|
|
106
|
+
<span className={profilePreviewHeaderClass}>
|
|
107
|
+
<AvatarChip
|
|
108
|
+
avatarUrl={profile.avatarUrl}
|
|
109
|
+
hue={profile.avatarHue}
|
|
110
|
+
name={profile.displayName}
|
|
111
|
+
profileId={profile.id}
|
|
112
|
+
/>
|
|
113
|
+
<span className="flex min-w-0 flex-col">
|
|
114
|
+
<span className={profilePreviewNameClass}>
|
|
115
|
+
{profile.displayName}
|
|
116
|
+
</span>
|
|
117
|
+
<span className={profilePreviewHandleClass}>
|
|
118
|
+
@{profile.handle}
|
|
119
|
+
</span>
|
|
120
|
+
</span>
|
|
121
|
+
</span>
|
|
122
|
+
{profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
|
|
123
|
+
<span className={profilePreviewMetaClass}>
|
|
124
|
+
{formatCompactNumber(profile.followersCount)} followers
|
|
160
125
|
</span>
|
|
161
|
-
<span className={profilePreviewHandleClass}>@{profile.handle}</span>
|
|
162
126
|
</span>
|
|
163
127
|
</span>
|
|
164
|
-
|
|
165
|
-
<span className={profilePreviewMetaClass}>
|
|
166
|
-
{formatCompactNumber(profile.followersCount)} followers
|
|
167
|
-
</span>
|
|
168
|
-
</span>
|
|
128
|
+
) : null}
|
|
169
129
|
</span>
|
|
170
130
|
);
|
|
171
131
|
}
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
UserSearch,
|
|
10
10
|
} from "lucide-react";
|
|
11
11
|
import { formatCompactNumber } from "#/lib/present";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isTweetArticleUrlEntity,
|
|
14
|
+
normalizeTweetUrlEntityRangeForText,
|
|
15
|
+
} from "#/lib/tweet-render";
|
|
13
16
|
import type {
|
|
14
17
|
TimelineItem,
|
|
15
18
|
TweetEntities,
|
|
@@ -42,6 +45,7 @@ import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
|
|
|
42
45
|
import { LinkPreviewCard } from "./LinkPreviewCard";
|
|
43
46
|
import { ProfilePreview } from "./ProfilePreview";
|
|
44
47
|
import { SmartTimestamp } from "./SmartTimestamp";
|
|
48
|
+
import { TweetArticleCard } from "./TweetArticleCard";
|
|
45
49
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
46
50
|
import { TweetRichText } from "./TweetRichText";
|
|
47
51
|
|
|
@@ -213,6 +217,9 @@ function getVisibleUrlCards(
|
|
|
213
217
|
) {
|
|
214
218
|
return (entities.urls ?? []).filter((entry) => {
|
|
215
219
|
if (isUnresolvedShortUrlEntity(entry)) return false;
|
|
220
|
+
if (entities.article && isTweetArticleUrlEntity(entry, entities.article)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
216
223
|
if (!quotedTweetId) return true;
|
|
217
224
|
return !entry.expandedUrl.includes(quotedTweetId);
|
|
218
225
|
});
|
|
@@ -277,7 +284,10 @@ export function TimelineCard({
|
|
|
277
284
|
|
|
278
285
|
return (
|
|
279
286
|
<article
|
|
280
|
-
className={cx(
|
|
287
|
+
className={cx(
|
|
288
|
+
feedRowClass,
|
|
289
|
+
"cursor-pointer [content-visibility:auto] [contain-intrinsic-size:auto_280px]",
|
|
290
|
+
)}
|
|
281
291
|
data-perf="timeline-card"
|
|
282
292
|
onFocus={conversation.prefetch}
|
|
283
293
|
onMouseEnter={conversation.prefetch}
|
|
@@ -363,6 +373,9 @@ export function TimelineCard({
|
|
|
363
373
|
text={displayTweet.text}
|
|
364
374
|
/>
|
|
365
375
|
<TweetMediaGrid items={displayTweet.media} />
|
|
376
|
+
{displayTweet.entities.article ? (
|
|
377
|
+
<TweetArticleCard article={displayTweet.entities.article} />
|
|
378
|
+
) : null}
|
|
366
379
|
{visibleUrlCards.map((entry, index) => (
|
|
367
380
|
<LinkPreviewCard
|
|
368
381
|
key={`${entry.expandedUrl}-${String(index)}`}
|
|
@@ -380,6 +393,9 @@ export function TimelineCard({
|
|
|
380
393
|
text={item.text}
|
|
381
394
|
/>
|
|
382
395
|
<TweetMediaGrid items={item.media} />
|
|
396
|
+
{item.entities.article ? (
|
|
397
|
+
<TweetArticleCard article={item.entities.article} />
|
|
398
|
+
) : null}
|
|
383
399
|
{item.replyToTweet ? (
|
|
384
400
|
<div className={embeddedCardClass}>
|
|
385
401
|
<EmbeddedTweetCard
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BookOpen, ExternalLink } from "lucide-react";
|
|
2
|
+
import type { TweetArticle } from "#/lib/types";
|
|
3
|
+
import {
|
|
4
|
+
linkPreviewCardClass,
|
|
5
|
+
linkPreviewDescClass,
|
|
6
|
+
linkPreviewHostClass,
|
|
7
|
+
linkPreviewTitleClass,
|
|
8
|
+
} from "#/lib/ui";
|
|
9
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
10
|
+
|
|
11
|
+
function safeArticleImageUrl(value: string | undefined) {
|
|
12
|
+
const url = safeHttpUrl(value);
|
|
13
|
+
if (!url) return null;
|
|
14
|
+
try {
|
|
15
|
+
return new URL(url).hostname === "pbs.twimg.com" ? url : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function TweetArticleCard({ article }: { article: TweetArticle }) {
|
|
22
|
+
const href = safeHttpUrl(article.url);
|
|
23
|
+
if (!href) return null;
|
|
24
|
+
const imageUrl = safeArticleImageUrl(article.coverImageUrl);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<a
|
|
28
|
+
aria-label={`Read article: ${article.title}`}
|
|
29
|
+
className={linkPreviewCardClass}
|
|
30
|
+
data-perf="tweet-article-card"
|
|
31
|
+
href={href}
|
|
32
|
+
rel="noreferrer"
|
|
33
|
+
target="_blank"
|
|
34
|
+
>
|
|
35
|
+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
|
|
36
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
37
|
+
<BookOpen
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className="size-3.5 shrink-0 text-[var(--ink-soft)]"
|
|
40
|
+
strokeWidth={1.8}
|
|
41
|
+
/>
|
|
42
|
+
<span className={linkPreviewHostClass}>Article on X</span>
|
|
43
|
+
<ExternalLink
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
|
|
46
|
+
strokeWidth={1.8}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<span className={linkPreviewTitleClass}>{article.title}</span>
|
|
50
|
+
{article.previewText ? (
|
|
51
|
+
<span className={linkPreviewDescClass}>{article.previewText}</span>
|
|
52
|
+
) : null}
|
|
53
|
+
</div>
|
|
54
|
+
{imageUrl ? (
|
|
55
|
+
<div className="flex aspect-[1.45] w-40 shrink-0 overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
|
|
56
|
+
<img
|
|
57
|
+
alt=""
|
|
58
|
+
className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
|
|
59
|
+
loading="lazy"
|
|
60
|
+
src={imageUrl}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
64
|
+
</a>
|
|
65
|
+
);
|
|
66
|
+
}
|