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
|
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
import {
|
|
4
4
|
collectTweetSegmentsForText,
|
|
5
5
|
enrichFallbackUrlEntities,
|
|
6
|
+
isTweetArticleUrlEntity,
|
|
6
7
|
normalizeTweetUrlEntityRangeForText,
|
|
7
8
|
} from "#/lib/tweet-render";
|
|
8
9
|
import type { TweetEntities } from "#/lib/types";
|
|
@@ -19,6 +20,14 @@ function rangeKey(range: { start: number; end: number }) {
|
|
|
19
20
|
return `${range.start}:${range.end}`;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function isShortUrl(value: string) {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(value).hostname.replace(/^www\./, "") === "t.co";
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export function TweetRichText({
|
|
23
32
|
text,
|
|
24
33
|
entities,
|
|
@@ -37,6 +46,25 @@ export function TweetRichText({
|
|
|
37
46
|
const richEntities = enrichFallbackUrlEntities(text, entities);
|
|
38
47
|
const segments = collectTweetSegmentsForText(text, richEntities);
|
|
39
48
|
const hiddenRawRangeKeys = new Set(hiddenUrlRanges.map(rangeKey));
|
|
49
|
+
const article = entities.article;
|
|
50
|
+
if (article) {
|
|
51
|
+
const urlEntries = richEntities.urls ?? [];
|
|
52
|
+
const articleUrlEntries = urlEntries.filter((entry) =>
|
|
53
|
+
isTweetArticleUrlEntity(entry, article),
|
|
54
|
+
);
|
|
55
|
+
const onlyUrlEntry = urlEntries[0];
|
|
56
|
+
if (
|
|
57
|
+
articleUrlEntries.length === 0 &&
|
|
58
|
+
urlEntries.length === 1 &&
|
|
59
|
+
onlyUrlEntry &&
|
|
60
|
+
(isShortUrl(onlyUrlEntry.url) || isShortUrl(onlyUrlEntry.expandedUrl))
|
|
61
|
+
) {
|
|
62
|
+
articleUrlEntries.push(onlyUrlEntry);
|
|
63
|
+
}
|
|
64
|
+
for (const entry of articleUrlEntries) {
|
|
65
|
+
hiddenRawRangeKeys.add(rangeKey(entry));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
40
68
|
const hiddenRangeKeys = new Set(hiddenRawRangeKeys);
|
|
41
69
|
for (const entry of richEntities.urls ?? []) {
|
|
42
70
|
if (!hiddenRawRangeKeys.has(rangeKey(entry))) continue;
|
|
@@ -46,10 +74,13 @@ export function TweetRichText({
|
|
|
46
74
|
}
|
|
47
75
|
const Wrapper = as;
|
|
48
76
|
let cursor = 0;
|
|
77
|
+
const hideArticleTitle =
|
|
78
|
+
entities.article && text.trim() === entities.article.title.trim();
|
|
79
|
+
const visibleSegments = hideArticleTitle ? [] : segments;
|
|
49
80
|
|
|
50
81
|
return (
|
|
51
82
|
<Wrapper className={className === "body-copy" ? bodyCopyClass : className}>
|
|
52
|
-
{
|
|
83
|
+
{visibleSegments.map((segment, index) => {
|
|
53
84
|
if (
|
|
54
85
|
segment.start < cursor ||
|
|
55
86
|
segment.end <= segment.start ||
|
|
@@ -122,7 +153,7 @@ export function TweetRichText({
|
|
|
122
153
|
</Fragment>
|
|
123
154
|
);
|
|
124
155
|
})}
|
|
125
|
-
{text.slice(cursor)}
|
|
156
|
+
{hideArticleTitle ? null : text.slice(cursor)}
|
|
126
157
|
</Wrapper>
|
|
127
158
|
);
|
|
128
159
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { queryKeys } from "#/lib/query-client";
|
|
2
|
+
import type {
|
|
3
|
+
LinkInsightItem,
|
|
4
|
+
LinkInsightKind,
|
|
5
|
+
LinkInsightMention,
|
|
6
|
+
LinkInsightRange,
|
|
7
|
+
LinkInsightResponse,
|
|
8
|
+
LinkInsightSort,
|
|
9
|
+
LinkInsightSource,
|
|
10
|
+
ProfileRecord,
|
|
11
|
+
TweetMediaItem,
|
|
12
|
+
} from "#/lib/types";
|
|
13
|
+
|
|
14
|
+
export const INITIAL_VISIBLE_COMMENTS = 3;
|
|
15
|
+
export const MORE_COMMENTS_BATCH = 6;
|
|
16
|
+
export const LINK_INSIGHTS_LIMIT = 30;
|
|
17
|
+
export const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
|
|
18
|
+
export const PROFILE_HYDRATION_LIMIT = 30;
|
|
19
|
+
export const PROFILE_HYDRATION_DELAY_MS = 1200;
|
|
20
|
+
export const LINK_INSIGHTS_CACHE_MAX_AGE_MS = 5 * 60_000;
|
|
21
|
+
export const hydratingLinkProfileHandles = new Set<string>();
|
|
22
|
+
|
|
23
|
+
export const ranges: Array<{ value: LinkInsightRange; label: string }> = [
|
|
24
|
+
{ value: "today", label: "Today" },
|
|
25
|
+
{ value: "week", label: "Week" },
|
|
26
|
+
{ value: "month", label: "Month" },
|
|
27
|
+
{ value: "year", label: "Year" },
|
|
28
|
+
{ value: "all", label: "All" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function itemTitle(item: LinkInsightItem) {
|
|
32
|
+
return item.title?.trim() || item.displayUrl;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function itemSubtitle(item: LinkInsightItem) {
|
|
36
|
+
const description = item.description?.trim();
|
|
37
|
+
if (description) {
|
|
38
|
+
return description;
|
|
39
|
+
}
|
|
40
|
+
return item.displayUrl.split("?")[0] || item.displayUrl;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function linkInsightQueryKey(
|
|
44
|
+
kind: LinkInsightKind,
|
|
45
|
+
range: LinkInsightRange,
|
|
46
|
+
sort: LinkInsightSort,
|
|
47
|
+
source: LinkInsightSource,
|
|
48
|
+
) {
|
|
49
|
+
return [...queryKeys.linkInsights, { kind, range, sort, source }] as const;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function linkInsightsUrl(
|
|
53
|
+
kind: LinkInsightKind,
|
|
54
|
+
range: LinkInsightRange,
|
|
55
|
+
sort: LinkInsightSort,
|
|
56
|
+
source: LinkInsightSource,
|
|
57
|
+
) {
|
|
58
|
+
const url = new URL("/api/link-insights", window.location.origin);
|
|
59
|
+
url.searchParams.set("kind", kind);
|
|
60
|
+
url.searchParams.set("range", range);
|
|
61
|
+
url.searchParams.set("sort", sort);
|
|
62
|
+
url.searchParams.set("source", source);
|
|
63
|
+
url.searchParams.set("limit", String(LINK_INSIGHTS_LIMIT));
|
|
64
|
+
url.searchParams.set("commentsLimit", String(LINK_INSIGHTS_COMMENTS_LIMIT));
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchLinkInsights(
|
|
69
|
+
kind: LinkInsightKind,
|
|
70
|
+
range: LinkInsightRange,
|
|
71
|
+
sort: LinkInsightSort,
|
|
72
|
+
source: LinkInsightSource,
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
) {
|
|
75
|
+
const response = await fetch(linkInsightsUrl(kind, range, sort, source), {
|
|
76
|
+
signal,
|
|
77
|
+
});
|
|
78
|
+
const data = (await response.json()) as LinkInsightResponse;
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error("Link insights unavailable");
|
|
81
|
+
}
|
|
82
|
+
return data;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function mentionHref(
|
|
86
|
+
mention: LinkInsightMention,
|
|
87
|
+
item: LinkInsightItem,
|
|
88
|
+
) {
|
|
89
|
+
return mention.sourceUrl || mention.contentTweetUrl || item.url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function mentionCopy(mention: LinkInsightMention) {
|
|
93
|
+
return (
|
|
94
|
+
mention.commentText ||
|
|
95
|
+
mention.sharedContentText ||
|
|
96
|
+
mention.rawText ||
|
|
97
|
+
"Shared without comment"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isSameProfile(
|
|
102
|
+
left: ProfileRecord | null | undefined,
|
|
103
|
+
right: ProfileRecord | null | undefined,
|
|
104
|
+
) {
|
|
105
|
+
return Boolean(left && right && left.id === right.id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function mediaImage(media: TweetMediaItem[]) {
|
|
109
|
+
return media.find((item) => item.thumbnailUrl || item.url) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function youtubeVideoId(rawUrl: string) {
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(rawUrl);
|
|
115
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
116
|
+
if (host === "youtu.be") {
|
|
117
|
+
return url.pathname.split("/").filter(Boolean)[0] ?? null;
|
|
118
|
+
}
|
|
119
|
+
if (!host.endsWith("youtube.com")) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
if (url.pathname === "/watch") {
|
|
123
|
+
return url.searchParams.get("v");
|
|
124
|
+
}
|
|
125
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
126
|
+
if (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "live") {
|
|
127
|
+
return parts[1] ?? null;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function youtubeThumbnailUrl(rawUrl: string) {
|
|
136
|
+
const id = youtubeVideoId(rawUrl);
|
|
137
|
+
if (!id || !/^[\w-]{6,}$/.test(id)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function commentCount(item: LinkInsightItem) {
|
|
144
|
+
return (
|
|
145
|
+
item.commentCount ??
|
|
146
|
+
item.mentions.filter((mention) => mention.hasComment).length
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function pureShareCount(item: LinkInsightItem) {
|
|
151
|
+
return (
|
|
152
|
+
item.pureShareCount ??
|
|
153
|
+
item.mentions.filter(
|
|
154
|
+
(mention) => mention.isPureShare || !mention.hasComment,
|
|
155
|
+
).length
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isArchivePlaceholderProfile(profile: ProfileRecord) {
|
|
160
|
+
return (
|
|
161
|
+
/^id\d+$/i.test(profile.handle) &&
|
|
162
|
+
profile.displayName === profile.handle &&
|
|
163
|
+
profile.id === `profile_user_${profile.handle.slice(2)}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function profileNeedsHydration(
|
|
168
|
+
profile: ProfileRecord | null | undefined,
|
|
169
|
+
) {
|
|
170
|
+
if (!profile?.handle || isArchivePlaceholderProfile(profile)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return !profile.avatarUrl || profile.followersCount === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function collectProfilesForHydration(data: LinkInsightResponse | null) {
|
|
177
|
+
const handles = new Set<string>();
|
|
178
|
+
for (const item of data?.items ?? []) {
|
|
179
|
+
for (const profile of [
|
|
180
|
+
item.topSharer,
|
|
181
|
+
...item.sharers,
|
|
182
|
+
...item.mentions.flatMap((mention) => [
|
|
183
|
+
mention.sharedBy,
|
|
184
|
+
mention.contentAuthor,
|
|
185
|
+
mention.participant,
|
|
186
|
+
]),
|
|
187
|
+
]) {
|
|
188
|
+
if (!profile || !profileNeedsHydration(profile)) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
handles.add(profile.handle.replace(/^@/, ""));
|
|
192
|
+
if (handles.size >= PROFILE_HYDRATION_LIMIT) {
|
|
193
|
+
return [...handles];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return [...handles];
|
|
198
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { useSelectedAccountId } from "./account-selection";
|
|
4
|
+
import {
|
|
5
|
+
MAP_TYPES,
|
|
6
|
+
WORLD_VIEWPORT,
|
|
7
|
+
boundsContainFeature,
|
|
8
|
+
featureMatchesSearch,
|
|
9
|
+
fetchMap,
|
|
10
|
+
type MapViewport,
|
|
11
|
+
} from "./network-map-model";
|
|
12
|
+
import { fetchQueryEnvelope } from "#/lib/api-client";
|
|
13
|
+
import type { NetworkMapKind } from "#/lib/network-map";
|
|
14
|
+
import { queryKeys } from "#/lib/query-client";
|
|
15
|
+
|
|
16
|
+
export function useNetworkMapController() {
|
|
17
|
+
const queryClient = useQueryClient();
|
|
18
|
+
const [type, setType] = useState<NetworkMapKind>("all");
|
|
19
|
+
const [viewport, setViewport] = useState<MapViewport>(WORLD_VIEWPORT);
|
|
20
|
+
const [visibleSearch, setVisibleSearch] = useState("");
|
|
21
|
+
const statusQuery = useQuery({
|
|
22
|
+
queryKey: queryKeys.status,
|
|
23
|
+
queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
|
|
24
|
+
});
|
|
25
|
+
const selectedAccountId = useSelectedAccountId(statusQuery.data?.accounts);
|
|
26
|
+
const mapQueryKey = [
|
|
27
|
+
...queryKeys.networkMap,
|
|
28
|
+
{ type, selectedAccountId: selectedAccountId ?? null },
|
|
29
|
+
] as const;
|
|
30
|
+
const mapQuery = useQuery({
|
|
31
|
+
queryKey: mapQueryKey,
|
|
32
|
+
queryFn: ({ signal }) => fetchMap(type, false, selectedAccountId, signal),
|
|
33
|
+
staleTime: 5 * 60_000,
|
|
34
|
+
});
|
|
35
|
+
const refreshMutation = useMutation({
|
|
36
|
+
mutationFn: () => fetchMap(type, true, selectedAccountId),
|
|
37
|
+
onSuccess: (nextData) => {
|
|
38
|
+
queryClient.setQueryData(mapQueryKey, nextData);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const data = mapQuery.data ?? null;
|
|
42
|
+
const loading = mapQuery.isPending || refreshMutation.isPending;
|
|
43
|
+
const queryError = refreshMutation.error ?? mapQuery.error;
|
|
44
|
+
|
|
45
|
+
const visibleFeatures = useMemo(
|
|
46
|
+
() =>
|
|
47
|
+
(data?.features ?? [])
|
|
48
|
+
.slice()
|
|
49
|
+
.filter((feature) => boundsContainFeature(viewport.bounds, feature))
|
|
50
|
+
.sort(
|
|
51
|
+
(a, b) =>
|
|
52
|
+
b.properties.followersCount - a.properties.followersCount ||
|
|
53
|
+
a.properties.handle.localeCompare(b.properties.handle),
|
|
54
|
+
),
|
|
55
|
+
[data, viewport],
|
|
56
|
+
);
|
|
57
|
+
const filteredVisibleFeatures = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
visibleFeatures
|
|
60
|
+
.filter((feature) => featureMatchesSearch(feature, visibleSearch))
|
|
61
|
+
.slice(0, 160),
|
|
62
|
+
[visibleFeatures, visibleSearch],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
type,
|
|
67
|
+
setType,
|
|
68
|
+
viewport,
|
|
69
|
+
setViewport,
|
|
70
|
+
visibleSearch,
|
|
71
|
+
setVisibleSearch,
|
|
72
|
+
data,
|
|
73
|
+
loading,
|
|
74
|
+
error: queryError
|
|
75
|
+
? queryError instanceof Error
|
|
76
|
+
? queryError.message
|
|
77
|
+
: "Map unavailable"
|
|
78
|
+
: null,
|
|
79
|
+
refresh: refreshMutation.mutate,
|
|
80
|
+
visibleFeatures,
|
|
81
|
+
filteredVisibleFeatures,
|
|
82
|
+
mapTypes: MAP_TYPES,
|
|
83
|
+
};
|
|
84
|
+
}
|