birdclaw 0.8.2 → 0.8.4
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 +33 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +27 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -2336
- package/src/components/AccountSwitcher.tsx +0 -186
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownViewer.tsx +0 -777
- package/src/components/ProfileAnalysisStream.tsx +0 -428
- package/src/components/ProfilePreview.tsx +0 -129
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/useTimelineRouteData.ts +0 -323
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/api-client.ts +0 -419
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import.ts +0 -2764
- package/src/lib/authored-live.ts +0 -1072
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2741
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -167
- package/src/lib/blocks.ts +0 -389
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/client-cache.ts +0 -109
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/db.ts +0 -1135
- package/src/lib/dms-live.ts +0 -933
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1097
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/inbox.ts +0 -241
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -917
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1238
- package/src/lib/moderation-target.ts +0 -216
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -167
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai.ts +0 -117
- package/src/lib/period-digest.ts +0 -1552
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1272
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -2381
- package/src/lib/research.ts +0 -652
- package/src/lib/search-discussion.ts +0 -1016
- package/src/lib/seed.ts +0 -613
- package/src/lib/sqlite.ts +0 -147
- package/src/lib/sync-cache.ts +0 -65
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -581
- package/src/lib/timeline-live.ts +0 -452
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-search-live.ts +0 -566
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -516
- package/src/lib/whois.ts +0 -1079
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -79
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -115
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -28
- package/src/routes/api/sync.tsx +0 -105
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -377
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -257
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -432
- package/src/routes/inbox.tsx +0 -226
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -994
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -1035
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -309
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
package/src/routes/links.tsx
DELETED
|
@@ -1,994 +0,0 @@
|
|
|
1
|
-
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
ChevronDown,
|
|
4
|
-
ChevronUp,
|
|
5
|
-
ExternalLink,
|
|
6
|
-
MessageCircle,
|
|
7
|
-
Play,
|
|
8
|
-
Repeat2,
|
|
9
|
-
Search,
|
|
10
|
-
Users,
|
|
11
|
-
} from "lucide-react";
|
|
12
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
-
import { AvatarChip } from "#/components/AvatarChip";
|
|
14
|
-
import {
|
|
15
|
-
FeedEmpty,
|
|
16
|
-
FeedError,
|
|
17
|
-
FeedLoading,
|
|
18
|
-
LinkSkeletonRows,
|
|
19
|
-
} from "#/components/FeedState";
|
|
20
|
-
import { ProfilePreview } from "#/components/ProfilePreview";
|
|
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";
|
|
28
|
-
import type {
|
|
29
|
-
LinkInsightItem,
|
|
30
|
-
LinkInsightKind,
|
|
31
|
-
LinkInsightMention,
|
|
32
|
-
LinkInsightRange,
|
|
33
|
-
LinkInsightResponse,
|
|
34
|
-
LinkInsightSort,
|
|
35
|
-
LinkInsightSource,
|
|
36
|
-
ProfileRecord,
|
|
37
|
-
TweetMediaItem,
|
|
38
|
-
} from "#/lib/types";
|
|
39
|
-
import {
|
|
40
|
-
cx,
|
|
41
|
-
pageHeaderClass,
|
|
42
|
-
pageHeaderRowClass,
|
|
43
|
-
pageSubtitleClass,
|
|
44
|
-
pageTitleClass,
|
|
45
|
-
searchFieldIconClass,
|
|
46
|
-
searchFieldInputClass,
|
|
47
|
-
searchFieldShellClass,
|
|
48
|
-
secondaryButtonClass,
|
|
49
|
-
segmentActiveClass,
|
|
50
|
-
segmentClass,
|
|
51
|
-
segmentedClass,
|
|
52
|
-
} from "#/lib/ui";
|
|
53
|
-
|
|
54
|
-
export const Route = createFileRoute("/links")({
|
|
55
|
-
component: LinksRoute,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const INITIAL_VISIBLE_COMMENTS = 3;
|
|
59
|
-
const MORE_COMMENTS_BATCH = 6;
|
|
60
|
-
const LINK_INSIGHTS_LIMIT = 30;
|
|
61
|
-
const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
|
|
62
|
-
const PROFILE_HYDRATION_LIMIT = 30;
|
|
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>();
|
|
68
|
-
|
|
69
|
-
const ranges: Array<{ value: LinkInsightRange; label: string }> = [
|
|
70
|
-
{ value: "today", label: "Today" },
|
|
71
|
-
{ value: "week", label: "Week" },
|
|
72
|
-
{ value: "month", label: "Month" },
|
|
73
|
-
{ value: "year", label: "Year" },
|
|
74
|
-
{ value: "all", label: "All" },
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
function itemTitle(item: LinkInsightItem) {
|
|
78
|
-
return item.title?.trim() || item.displayUrl;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function itemSubtitle(item: LinkInsightItem) {
|
|
82
|
-
const description = item.description?.trim();
|
|
83
|
-
if (description) {
|
|
84
|
-
return description;
|
|
85
|
-
}
|
|
86
|
-
return item.displayUrl.split("?")[0] || item.displayUrl;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function insightCacheKey(
|
|
90
|
-
kind: LinkInsightKind,
|
|
91
|
-
range: LinkInsightRange,
|
|
92
|
-
sort: LinkInsightSort,
|
|
93
|
-
source: LinkInsightSource,
|
|
94
|
-
refreshTick: number,
|
|
95
|
-
) {
|
|
96
|
-
return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
|
|
97
|
-
}
|
|
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
|
-
|
|
112
|
-
function linkInsightsUrl(
|
|
113
|
-
kind: LinkInsightKind,
|
|
114
|
-
range: LinkInsightRange,
|
|
115
|
-
sort: LinkInsightSort,
|
|
116
|
-
source: LinkInsightSource,
|
|
117
|
-
refreshTick: number,
|
|
118
|
-
) {
|
|
119
|
-
const url = new URL("/api/link-insights", window.location.origin);
|
|
120
|
-
url.searchParams.set("kind", kind);
|
|
121
|
-
url.searchParams.set("range", range);
|
|
122
|
-
url.searchParams.set("sort", sort);
|
|
123
|
-
url.searchParams.set("source", source);
|
|
124
|
-
url.searchParams.set("limit", String(LINK_INSIGHTS_LIMIT));
|
|
125
|
-
url.searchParams.set("commentsLimit", String(LINK_INSIGHTS_COMMENTS_LIMIT));
|
|
126
|
-
url.searchParams.set("refresh", String(refreshTick));
|
|
127
|
-
return url;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function mentionHref(mention: LinkInsightMention, item: LinkInsightItem) {
|
|
131
|
-
return mention.sourceUrl || mention.contentTweetUrl || item.url;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function mentionCopy(mention: LinkInsightMention) {
|
|
135
|
-
return (
|
|
136
|
-
mention.commentText ||
|
|
137
|
-
mention.sharedContentText ||
|
|
138
|
-
mention.rawText ||
|
|
139
|
-
"Shared without comment"
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function isSameProfile(
|
|
144
|
-
left: ProfileRecord | null | undefined,
|
|
145
|
-
right: ProfileRecord | null | undefined,
|
|
146
|
-
) {
|
|
147
|
-
return Boolean(left && right && left.id === right.id);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function mediaImage(media: TweetMediaItem[]) {
|
|
151
|
-
return media.find((item) => item.thumbnailUrl || item.url) ?? null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function youtubeVideoId(rawUrl: string) {
|
|
155
|
-
try {
|
|
156
|
-
const url = new URL(rawUrl);
|
|
157
|
-
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
158
|
-
if (host === "youtu.be") {
|
|
159
|
-
return url.pathname.split("/").filter(Boolean)[0] ?? null;
|
|
160
|
-
}
|
|
161
|
-
if (!host.endsWith("youtube.com")) {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
if (url.pathname === "/watch") {
|
|
165
|
-
return url.searchParams.get("v");
|
|
166
|
-
}
|
|
167
|
-
const parts = url.pathname.split("/").filter(Boolean);
|
|
168
|
-
if (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "live") {
|
|
169
|
-
return parts[1] ?? null;
|
|
170
|
-
}
|
|
171
|
-
return null;
|
|
172
|
-
} catch {
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function youtubeThumbnailUrl(rawUrl: string) {
|
|
178
|
-
const id = youtubeVideoId(rawUrl);
|
|
179
|
-
if (!id || !/^[\w-]{6,}$/.test(id)) {
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function commentCount(item: LinkInsightItem) {
|
|
186
|
-
return (
|
|
187
|
-
item.commentCount ??
|
|
188
|
-
item.mentions.filter((mention) => mention.hasComment).length
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function pureShareCount(item: LinkInsightItem) {
|
|
193
|
-
return (
|
|
194
|
-
item.pureShareCount ??
|
|
195
|
-
item.mentions.filter(
|
|
196
|
-
(mention) => mention.isPureShare || !mention.hasComment,
|
|
197
|
-
).length
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function isArchivePlaceholderProfile(profile: ProfileRecord) {
|
|
202
|
-
return (
|
|
203
|
-
/^id\d+$/i.test(profile.handle) &&
|
|
204
|
-
profile.displayName === profile.handle &&
|
|
205
|
-
profile.id === `profile_user_${profile.handle.slice(2)}`
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function profileNeedsHydration(profile: ProfileRecord | null | undefined) {
|
|
210
|
-
if (!profile?.handle || isArchivePlaceholderProfile(profile)) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
return !profile.avatarUrl || profile.followersCount === 0;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function collectProfilesForHydration(data: LinkInsightResponse | null) {
|
|
217
|
-
const handles = new Set<string>();
|
|
218
|
-
for (const item of data?.items ?? []) {
|
|
219
|
-
for (const profile of [
|
|
220
|
-
item.topSharer,
|
|
221
|
-
...item.sharers,
|
|
222
|
-
...item.mentions.flatMap((mention) => [
|
|
223
|
-
mention.sharedBy,
|
|
224
|
-
mention.contentAuthor,
|
|
225
|
-
mention.participant,
|
|
226
|
-
]),
|
|
227
|
-
]) {
|
|
228
|
-
if (!profile || !profileNeedsHydration(profile)) {
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
handles.add(profile.handle.replace(/^@/, ""));
|
|
232
|
-
if (handles.size >= PROFILE_HYDRATION_LIMIT) {
|
|
233
|
-
return [...handles];
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return [...handles];
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function ProfilePill({
|
|
241
|
-
profile,
|
|
242
|
-
className,
|
|
243
|
-
}: {
|
|
244
|
-
profile: ProfileRecord | null | undefined;
|
|
245
|
-
className?: string;
|
|
246
|
-
}) {
|
|
247
|
-
if (!profile) {
|
|
248
|
-
return (
|
|
249
|
-
<span className={cx("text-[var(--ink-soft)]", className)}>unknown</span>
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
return (
|
|
253
|
-
<ProfilePreview profile={profile}>
|
|
254
|
-
<span
|
|
255
|
-
className={cx("font-bold text-[var(--ink)] hover:underline", className)}
|
|
256
|
-
>
|
|
257
|
-
@{profile.handle}
|
|
258
|
-
</span>
|
|
259
|
-
</ProfilePreview>
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function SharerStrip({ item }: { item: LinkInsightItem }) {
|
|
264
|
-
const itemSharers = item.sharers ?? [];
|
|
265
|
-
const sharers =
|
|
266
|
-
itemSharers.length > 0
|
|
267
|
-
? itemSharers
|
|
268
|
-
: item.topSharer
|
|
269
|
-
? [item.topSharer]
|
|
270
|
-
: [];
|
|
271
|
-
const visible = sharers.slice(0, 5);
|
|
272
|
-
const hidden = Math.max(0, item.uniqueSharers - visible.length);
|
|
273
|
-
|
|
274
|
-
if (visible.length === 0) {
|
|
275
|
-
return <span>top unknown</span>;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return (
|
|
279
|
-
<div className="group/sharers relative z-10 inline-flex items-center gap-1 hover:z-50 focus-within:z-50">
|
|
280
|
-
<div className="flex -space-x-2">
|
|
281
|
-
{visible.slice(0, 4).map((profile) => (
|
|
282
|
-
<a
|
|
283
|
-
aria-label={`Open @${profile.handle}`}
|
|
284
|
-
className="inline-flex rounded-full bg-[var(--bg)] ring-2 ring-[var(--bg)]"
|
|
285
|
-
href={`https://x.com/${profile.handle}`}
|
|
286
|
-
key={profile.id}
|
|
287
|
-
rel="noreferrer"
|
|
288
|
-
target="_blank"
|
|
289
|
-
>
|
|
290
|
-
<AvatarChip
|
|
291
|
-
avatarUrl={profile.avatarUrl}
|
|
292
|
-
hue={profile.avatarHue}
|
|
293
|
-
name={profile.displayName}
|
|
294
|
-
profileId={profile.id}
|
|
295
|
-
size="small"
|
|
296
|
-
/>
|
|
297
|
-
</a>
|
|
298
|
-
))}
|
|
299
|
-
</div>
|
|
300
|
-
<span className="inline-flex items-center gap-1 text-[13px] text-[var(--ink-soft)]">
|
|
301
|
-
<span>top</span>
|
|
302
|
-
<a
|
|
303
|
-
className="font-medium text-[var(--ink-soft)] hover:underline"
|
|
304
|
-
href={`https://x.com/${visible[0]?.handle ?? ""}`}
|
|
305
|
-
rel="noreferrer"
|
|
306
|
-
target="_blank"
|
|
307
|
-
>
|
|
308
|
-
@{visible[0]?.handle}
|
|
309
|
-
</a>
|
|
310
|
-
</span>
|
|
311
|
-
{hidden > 0 ? (
|
|
312
|
-
<span className="text-[13px] text-[var(--ink-soft)]">
|
|
313
|
-
+{formatCompactNumber(hidden)}
|
|
314
|
-
</span>
|
|
315
|
-
) : null}
|
|
316
|
-
<div className="pointer-events-none absolute left-0 top-[calc(100%+8px)] z-[80] grid w-[280px] translate-y-1 gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover/sharers:pointer-events-auto group-hover/sharers:translate-y-0 group-hover/sharers:opacity-100 group-focus-within/sharers:pointer-events-auto group-focus-within/sharers:translate-y-0 group-focus-within/sharers:opacity-100">
|
|
317
|
-
<div className="flex items-center gap-2 text-[13px] font-bold text-[var(--ink)]">
|
|
318
|
-
<Users className="size-4" />
|
|
319
|
-
<span>{formatCompactNumber(item.uniqueSharers)} sharers</span>
|
|
320
|
-
</div>
|
|
321
|
-
<div className="grid gap-2">
|
|
322
|
-
{visible.map((profile) => (
|
|
323
|
-
<a
|
|
324
|
-
className="flex min-w-0 items-center gap-2 rounded-xl p-1 transition-colors hover:bg-[var(--bg-hover)]"
|
|
325
|
-
href={`https://x.com/${profile.handle}`}
|
|
326
|
-
key={profile.id}
|
|
327
|
-
rel="noreferrer"
|
|
328
|
-
target="_blank"
|
|
329
|
-
>
|
|
330
|
-
<AvatarChip
|
|
331
|
-
avatarUrl={profile.avatarUrl}
|
|
332
|
-
hue={profile.avatarHue}
|
|
333
|
-
name={profile.displayName}
|
|
334
|
-
profileId={profile.id}
|
|
335
|
-
size="small"
|
|
336
|
-
/>
|
|
337
|
-
<span className="min-w-0">
|
|
338
|
-
<span className="block truncate text-[14px] font-bold text-[var(--ink)]">
|
|
339
|
-
{profile.displayName}
|
|
340
|
-
</span>
|
|
341
|
-
<span className="block truncate text-[13px] text-[var(--ink-soft)]">
|
|
342
|
-
@{profile.handle} /{" "}
|
|
343
|
-
{formatCompactNumber(profile.followersCount)} followers
|
|
344
|
-
</span>
|
|
345
|
-
</span>
|
|
346
|
-
</a>
|
|
347
|
-
))}
|
|
348
|
-
</div>
|
|
349
|
-
</div>
|
|
350
|
-
</div>
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function SourceActions({
|
|
355
|
-
item,
|
|
356
|
-
mention,
|
|
357
|
-
}: {
|
|
358
|
-
item: LinkInsightItem;
|
|
359
|
-
mention: LinkInsightMention;
|
|
360
|
-
}) {
|
|
361
|
-
const links = [
|
|
362
|
-
mention.sourceUrl
|
|
363
|
-
? {
|
|
364
|
-
href: mention.sourceUrl,
|
|
365
|
-
label: mention.sourceKind === "dm" ? "Open DM" : "Open tweet",
|
|
366
|
-
}
|
|
367
|
-
: null,
|
|
368
|
-
mention.contentTweetUrl
|
|
369
|
-
? { href: mention.contentTweetUrl, label: "Open source tweet" }
|
|
370
|
-
: null,
|
|
371
|
-
{ href: item.url, label: "Open link" },
|
|
372
|
-
].filter(Boolean) as Array<{ href: string; label: string }>;
|
|
373
|
-
|
|
374
|
-
return (
|
|
375
|
-
<div className="flex flex-wrap items-center gap-3 text-[13px]">
|
|
376
|
-
{links.map((link) => (
|
|
377
|
-
<a
|
|
378
|
-
key={`${link.label}:${link.href}`}
|
|
379
|
-
className="inline-flex items-center gap-1 font-semibold text-[var(--accent)] hover:underline"
|
|
380
|
-
href={link.href}
|
|
381
|
-
rel="noreferrer"
|
|
382
|
-
target="_blank"
|
|
383
|
-
>
|
|
384
|
-
<span>{link.label}</span>
|
|
385
|
-
<ExternalLink className="size-3.5" />
|
|
386
|
-
</a>
|
|
387
|
-
))}
|
|
388
|
-
</div>
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function MentionCard({
|
|
393
|
-
item,
|
|
394
|
-
mention,
|
|
395
|
-
}: {
|
|
396
|
-
item: LinkInsightItem;
|
|
397
|
-
mention: LinkInsightMention;
|
|
398
|
-
}) {
|
|
399
|
-
const image = mediaImage(mention.media);
|
|
400
|
-
const href = mentionHref(mention, item);
|
|
401
|
-
const contentAuthor =
|
|
402
|
-
mention.contentAuthor &&
|
|
403
|
-
!isSameProfile(mention.sharedBy, mention.contentAuthor)
|
|
404
|
-
? mention.contentAuthor
|
|
405
|
-
: null;
|
|
406
|
-
|
|
407
|
-
return (
|
|
408
|
-
<article className="grid gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg)] px-3 py-2.5">
|
|
409
|
-
<div className="flex min-w-0 items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
|
410
|
-
<ProfilePill profile={mention.sharedBy} />
|
|
411
|
-
<span>{mention.sourceLabel ?? mention.sourceKind}</span>
|
|
412
|
-
<span>/</span>
|
|
413
|
-
<a
|
|
414
|
-
className="hover:underline"
|
|
415
|
-
href={href}
|
|
416
|
-
rel="noreferrer"
|
|
417
|
-
target="_blank"
|
|
418
|
-
>
|
|
419
|
-
<SmartTimestamp value={mention.createdAt} />
|
|
420
|
-
</a>
|
|
421
|
-
</div>
|
|
422
|
-
<p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
|
|
423
|
-
{mentionCopy(mention)}
|
|
424
|
-
</p>
|
|
425
|
-
{contentAuthor ? (
|
|
426
|
-
<div className="text-[13px] text-[var(--ink-soft)]">
|
|
427
|
-
source by <ProfilePill profile={contentAuthor} />
|
|
428
|
-
</div>
|
|
429
|
-
) : null}
|
|
430
|
-
{image ? (
|
|
431
|
-
<a
|
|
432
|
-
className="block max-w-[220px] overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-active)]"
|
|
433
|
-
href={href}
|
|
434
|
-
rel="noreferrer"
|
|
435
|
-
target="_blank"
|
|
436
|
-
>
|
|
437
|
-
<img
|
|
438
|
-
alt={image.altText || "Mention media"}
|
|
439
|
-
className="aspect-video h-auto w-full object-cover"
|
|
440
|
-
src={image.thumbnailUrl || image.url}
|
|
441
|
-
/>
|
|
442
|
-
</a>
|
|
443
|
-
) : null}
|
|
444
|
-
<SourceActions item={item} mention={mention} />
|
|
445
|
-
</article>
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function PureShareCluster({
|
|
450
|
-
item,
|
|
451
|
-
mentions,
|
|
452
|
-
}: {
|
|
453
|
-
item: LinkInsightItem;
|
|
454
|
-
mentions: LinkInsightMention[];
|
|
455
|
-
}) {
|
|
456
|
-
const profiles = mentions
|
|
457
|
-
.map((mention) => mention.sharedBy)
|
|
458
|
-
.filter(Boolean) as ProfileRecord[];
|
|
459
|
-
const visible = profiles.slice(0, 6);
|
|
460
|
-
const firstSource = mentions.find((mention) => mention.sourceUrl)?.sourceUrl;
|
|
461
|
-
|
|
462
|
-
return (
|
|
463
|
-
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] px-3 py-2.5">
|
|
464
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
465
|
-
<div className="grid size-8 shrink-0 place-items-center rounded-full bg-[var(--bg-active)] text-[var(--ink-soft)]">
|
|
466
|
-
<Repeat2 className="size-4" />
|
|
467
|
-
</div>
|
|
468
|
-
<div className="min-w-0">
|
|
469
|
-
<div className="text-[14px] font-bold text-[var(--ink)]">
|
|
470
|
-
{formatCompactNumber(pureShareCount(item))} shares without comment
|
|
471
|
-
</div>
|
|
472
|
-
<div className="flex min-w-0 flex-wrap items-center gap-1 text-[13px] text-[var(--ink-soft)]">
|
|
473
|
-
{visible.map((profile) => (
|
|
474
|
-
<ProfilePill key={profile.id} profile={profile} />
|
|
475
|
-
))}
|
|
476
|
-
{pureShareCount(item) > visible.length ? (
|
|
477
|
-
<span>
|
|
478
|
-
+{formatCompactNumber(pureShareCount(item) - visible.length)}{" "}
|
|
479
|
-
more
|
|
480
|
-
</span>
|
|
481
|
-
) : null}
|
|
482
|
-
</div>
|
|
483
|
-
</div>
|
|
484
|
-
</div>
|
|
485
|
-
{firstSource ? (
|
|
486
|
-
<a
|
|
487
|
-
className="inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent)] hover:underline"
|
|
488
|
-
href={firstSource}
|
|
489
|
-
rel="noreferrer"
|
|
490
|
-
target="_blank"
|
|
491
|
-
>
|
|
492
|
-
<span>Open first source</span>
|
|
493
|
-
<ExternalLink className="size-3.5" />
|
|
494
|
-
</a>
|
|
495
|
-
) : null}
|
|
496
|
-
</div>
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function CommentsSection({ item }: { item: LinkInsightItem }) {
|
|
501
|
-
const [expanded, setExpanded] = useState(false);
|
|
502
|
-
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
|
503
|
-
const comments = item.mentions.filter((mention) => mention.hasComment);
|
|
504
|
-
const pureShares = item.mentions.filter(
|
|
505
|
-
(mention) => mention.isPureShare || !mention.hasComment,
|
|
506
|
-
);
|
|
507
|
-
const itemCommentCount = commentCount(item);
|
|
508
|
-
const itemPureShareCount = pureShareCount(item);
|
|
509
|
-
const totalDiscussion = itemCommentCount + itemPureShareCount;
|
|
510
|
-
const visibleComments = comments.slice(0, visibleCount);
|
|
511
|
-
const remainingLocalComments = Math.max(0, comments.length - visibleCount);
|
|
512
|
-
const remainingComments = Math.max(
|
|
513
|
-
0,
|
|
514
|
-
itemCommentCount - visibleComments.length,
|
|
515
|
-
);
|
|
516
|
-
|
|
517
|
-
if (totalDiscussion === 0) {
|
|
518
|
-
return null;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
return (
|
|
522
|
-
<div className="mt-2 grid gap-2 border-t border-[var(--line)] pt-2">
|
|
523
|
-
<button
|
|
524
|
-
className="inline-flex w-fit items-center gap-1 rounded-full border-0 bg-transparent px-2 py-1 text-[13px] font-bold text-[var(--accent)] transition-colors hover:bg-[var(--accent-soft)]"
|
|
525
|
-
onClick={() => setExpanded((value) => !value)}
|
|
526
|
-
type="button"
|
|
527
|
-
>
|
|
528
|
-
<MessageCircle className="size-4" />
|
|
529
|
-
<span>
|
|
530
|
-
{expanded ? "Hide" : "Show"} {formatCompactNumber(itemCommentCount)}{" "}
|
|
531
|
-
comments
|
|
532
|
-
{itemPureShareCount > 0
|
|
533
|
-
? ` / ${formatCompactNumber(itemPureShareCount)} shares`
|
|
534
|
-
: ""}
|
|
535
|
-
</span>
|
|
536
|
-
{expanded ? (
|
|
537
|
-
<ChevronUp className="size-4" />
|
|
538
|
-
) : (
|
|
539
|
-
<ChevronDown className="size-4" />
|
|
540
|
-
)}
|
|
541
|
-
</button>
|
|
542
|
-
{expanded ? (
|
|
543
|
-
<div className="grid gap-2">
|
|
544
|
-
{visibleComments.map((mention) => (
|
|
545
|
-
<MentionCard key={mention.id} item={item} mention={mention} />
|
|
546
|
-
))}
|
|
547
|
-
{remainingLocalComments > 0 ? (
|
|
548
|
-
<button
|
|
549
|
-
className={cx(secondaryButtonClass, "w-fit")}
|
|
550
|
-
onClick={() =>
|
|
551
|
-
setVisibleCount((value) => value + MORE_COMMENTS_BATCH)
|
|
552
|
-
}
|
|
553
|
-
type="button"
|
|
554
|
-
>
|
|
555
|
-
Show{" "}
|
|
556
|
-
{formatCompactNumber(
|
|
557
|
-
Math.min(MORE_COMMENTS_BATCH, remainingLocalComments),
|
|
558
|
-
)}{" "}
|
|
559
|
-
more
|
|
560
|
-
</button>
|
|
561
|
-
) : null}
|
|
562
|
-
{remainingComments > 0 && remainingLocalComments === 0 ? (
|
|
563
|
-
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] px-3 py-2 text-[13px] text-[var(--ink-soft)]">
|
|
564
|
-
{formatCompactNumber(remainingComments)} more mentions in archive.
|
|
565
|
-
Narrow the range or raise the API limit to inspect them.
|
|
566
|
-
</div>
|
|
567
|
-
) : null}
|
|
568
|
-
{itemPureShareCount > 0 ? (
|
|
569
|
-
<PureShareCluster item={item} mentions={pureShares} />
|
|
570
|
-
) : null}
|
|
571
|
-
</div>
|
|
572
|
-
) : null}
|
|
573
|
-
</div>
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function VideoPreview({ item }: { item: LinkInsightItem }) {
|
|
578
|
-
if (item.kind !== "videos") {
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
const image = mediaImage(item.mentions.flatMap((mention) => mention.media));
|
|
582
|
-
const thumbnailUrl =
|
|
583
|
-
image?.thumbnailUrl || image?.url || youtubeThumbnailUrl(item.url);
|
|
584
|
-
|
|
585
|
-
return (
|
|
586
|
-
<a
|
|
587
|
-
className="mt-2 flex max-w-md items-center gap-3 overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] transition-colors hover:bg-[var(--bg-active)]"
|
|
588
|
-
href={item.url}
|
|
589
|
-
rel="noreferrer"
|
|
590
|
-
target="_blank"
|
|
591
|
-
>
|
|
592
|
-
<div className="relative grid aspect-video w-36 shrink-0 place-items-center overflow-hidden bg-[var(--bg-active)] text-[var(--ink-soft)]">
|
|
593
|
-
{thumbnailUrl ? (
|
|
594
|
-
<img
|
|
595
|
-
alt={image?.altText || itemTitle(item)}
|
|
596
|
-
className="h-full w-full object-cover transition-transform duration-150 hover:scale-105"
|
|
597
|
-
src={thumbnailUrl}
|
|
598
|
-
/>
|
|
599
|
-
) : (
|
|
600
|
-
<Play className="size-6" />
|
|
601
|
-
)}
|
|
602
|
-
<div className="absolute inset-0 grid place-items-center bg-black/20">
|
|
603
|
-
<span className="grid size-9 place-items-center rounded-full bg-black/70 text-white shadow-[0_4px_18px_rgba(0,0,0,0.35)]">
|
|
604
|
-
<Play className="ml-0.5 size-4 fill-current" />
|
|
605
|
-
</span>
|
|
606
|
-
</div>
|
|
607
|
-
</div>
|
|
608
|
-
<div className="min-w-0 py-2 pr-3">
|
|
609
|
-
<div className="truncate text-[13px] font-bold text-[var(--ink)]">
|
|
610
|
-
{itemTitle(item)}
|
|
611
|
-
</div>
|
|
612
|
-
<div className="truncate text-[13px] text-[var(--ink-soft)]">
|
|
613
|
-
{item.displayUrl}
|
|
614
|
-
</div>
|
|
615
|
-
</div>
|
|
616
|
-
</a>
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function LinkInsightRow({
|
|
621
|
-
index,
|
|
622
|
-
item,
|
|
623
|
-
}: {
|
|
624
|
-
index: number;
|
|
625
|
-
item: LinkInsightItem;
|
|
626
|
-
}) {
|
|
627
|
-
return (
|
|
628
|
-
<article
|
|
629
|
-
className="flex flex-col gap-2 border-b border-[var(--line)] px-4 py-3 transition-colors duration-150 hover:bg-[var(--bg-hover)]"
|
|
630
|
-
data-perf="link-insight-row"
|
|
631
|
-
>
|
|
632
|
-
<div className="flex items-start gap-3">
|
|
633
|
-
<div className="grid size-9 shrink-0 place-items-center rounded-full bg-[var(--bg-active)] text-[14px] font-bold text-[var(--ink-soft)]">
|
|
634
|
-
{index + 1}
|
|
635
|
-
</div>
|
|
636
|
-
<div className="min-w-0 flex-1">
|
|
637
|
-
<a
|
|
638
|
-
className="block break-words text-[15px] font-bold text-[var(--ink)] [overflow-wrap:anywhere] hover:underline"
|
|
639
|
-
href={item.url}
|
|
640
|
-
rel="noreferrer"
|
|
641
|
-
target="_blank"
|
|
642
|
-
>
|
|
643
|
-
{itemTitle(item)}
|
|
644
|
-
</a>
|
|
645
|
-
<p className="mt-0.5 truncate text-[13px] text-[var(--ink-soft)]">
|
|
646
|
-
{itemSubtitle(item)}
|
|
647
|
-
</p>
|
|
648
|
-
<div className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
|
649
|
-
<span>{item.host}</span>
|
|
650
|
-
<span>/</span>
|
|
651
|
-
<span>{formatCompactNumber(item.shareCount)} shares</span>
|
|
652
|
-
<span>/</span>
|
|
653
|
-
<span>{formatCompactNumber(item.uniqueSharers)} sharers</span>
|
|
654
|
-
<span>/</span>
|
|
655
|
-
<span>{formatCompactNumber(commentCount(item))} comments</span>
|
|
656
|
-
<span>/</span>
|
|
657
|
-
<SharerStrip item={item} />
|
|
658
|
-
<span>/</span>
|
|
659
|
-
<SmartTimestamp value={item.lastSeenAt} />
|
|
660
|
-
</div>
|
|
661
|
-
<VideoPreview item={item} />
|
|
662
|
-
</div>
|
|
663
|
-
</div>
|
|
664
|
-
<CommentsSection item={item} />
|
|
665
|
-
</article>
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function LinksRoute() {
|
|
670
|
-
const [kind, setKind] = useState<LinkInsightKind>("links");
|
|
671
|
-
const [range, setRange] = useState<LinkInsightRange>("week");
|
|
672
|
-
const [source, setSource] = useState<LinkInsightSource>("all");
|
|
673
|
-
const [sort, setSort] = useState<LinkInsightSort>("rank");
|
|
674
|
-
const [search, setSearch] = useState("");
|
|
675
|
-
const [refreshTick, setRefreshTick] = useState(0);
|
|
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
|
-
);
|
|
686
|
-
const inFlightRef = useRef(new Set<string>());
|
|
687
|
-
const mountedRef = useRef(true);
|
|
688
|
-
const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
|
|
689
|
-
const [, bumpCacheVersion] = useState(0);
|
|
690
|
-
const currentCacheKey = insightCacheKey(
|
|
691
|
-
kind,
|
|
692
|
-
range,
|
|
693
|
-
sort,
|
|
694
|
-
source,
|
|
695
|
-
refreshTick,
|
|
696
|
-
);
|
|
697
|
-
const data = cacheRef.current.get(currentCacheKey) ?? null;
|
|
698
|
-
const error = errorByKey[currentCacheKey] ?? null;
|
|
699
|
-
const loading = !data && !error;
|
|
700
|
-
|
|
701
|
-
useEffect(() => {
|
|
702
|
-
return () => {
|
|
703
|
-
mountedRef.current = false;
|
|
704
|
-
};
|
|
705
|
-
}, []);
|
|
706
|
-
|
|
707
|
-
const fetchInsights = useCallback(
|
|
708
|
-
(fetchKind: LinkInsightKind) => {
|
|
709
|
-
const key = insightCacheKey(fetchKind, range, sort, source, refreshTick);
|
|
710
|
-
if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
|
|
711
|
-
return;
|
|
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
|
-
}
|
|
725
|
-
inFlightRef.current.add(key);
|
|
726
|
-
setErrorByKey((current) => {
|
|
727
|
-
const rest = { ...current };
|
|
728
|
-
delete rest[key];
|
|
729
|
-
return rest;
|
|
730
|
-
});
|
|
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
|
-
)
|
|
742
|
-
.then((response: LinkInsightResponse) => {
|
|
743
|
-
cacheRef.current.set(key, response);
|
|
744
|
-
if (mountedRef.current) {
|
|
745
|
-
bumpCacheVersion((value) => value + 1);
|
|
746
|
-
}
|
|
747
|
-
})
|
|
748
|
-
.catch((error: unknown) => {
|
|
749
|
-
if (error instanceof DOMException && error.name === "AbortError") {
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
if (!mountedRef.current) {
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
console.warn("Link insights failed", error);
|
|
756
|
-
setErrorByKey((current) => ({
|
|
757
|
-
...current,
|
|
758
|
-
[key]:
|
|
759
|
-
error instanceof Error
|
|
760
|
-
? error.message
|
|
761
|
-
: "Link insights unavailable",
|
|
762
|
-
}));
|
|
763
|
-
})
|
|
764
|
-
.finally(() => {
|
|
765
|
-
inFlightRef.current.delete(key);
|
|
766
|
-
});
|
|
767
|
-
},
|
|
768
|
-
[range, refreshTick, sort, source],
|
|
769
|
-
);
|
|
770
|
-
|
|
771
|
-
useEffect(() => {
|
|
772
|
-
fetchInsights(kind);
|
|
773
|
-
}, [fetchInsights, kind]);
|
|
774
|
-
|
|
775
|
-
useEffect(() => {
|
|
776
|
-
if (!data) {
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
const prefetchKind = kind === "links" ? "videos" : "links";
|
|
780
|
-
const timer = window.setTimeout(() => fetchInsights(prefetchKind), 250);
|
|
781
|
-
return () => window.clearTimeout(timer);
|
|
782
|
-
}, [data, fetchInsights, kind]);
|
|
783
|
-
|
|
784
|
-
useEffect(() => {
|
|
785
|
-
const handles = collectProfilesForHydration(data).filter(
|
|
786
|
-
(handle) =>
|
|
787
|
-
!readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
|
|
788
|
-
!hydratingLinkProfileHandles.has(handle.toLowerCase()),
|
|
789
|
-
);
|
|
790
|
-
if (handles.length === 0) {
|
|
791
|
-
return;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const controller = new AbortController();
|
|
795
|
-
const url = new URL("/api/profile-hydrate", window.location.origin);
|
|
796
|
-
url.searchParams.set("handles", handles.join(","));
|
|
797
|
-
for (const handle of handles) {
|
|
798
|
-
hydratingLinkProfileHandles.add(handle.toLowerCase());
|
|
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
|
-
};
|
|
809
|
-
|
|
810
|
-
let idleId: number | null = null;
|
|
811
|
-
const runHydration = () => {
|
|
812
|
-
fetch(url, { signal: controller.signal })
|
|
813
|
-
.then((response) => response.json())
|
|
814
|
-
.then((response: { hydratedProfiles?: number }) => {
|
|
815
|
-
finishHydration(true);
|
|
816
|
-
if ((response.hydratedProfiles ?? 0) > 0) {
|
|
817
|
-
setRefreshTick((value) => value + 1);
|
|
818
|
-
}
|
|
819
|
-
})
|
|
820
|
-
.catch((error: unknown) => {
|
|
821
|
-
finishHydration(false);
|
|
822
|
-
if (error instanceof DOMException && error.name === "AbortError") {
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
console.warn("Profile hydration failed", error);
|
|
826
|
-
});
|
|
827
|
-
};
|
|
828
|
-
const timer = window.setTimeout(() => {
|
|
829
|
-
if ("requestIdleCallback" in window) {
|
|
830
|
-
idleId = window.requestIdleCallback(runHydration, { timeout: 2500 });
|
|
831
|
-
} else {
|
|
832
|
-
runHydration();
|
|
833
|
-
}
|
|
834
|
-
}, PROFILE_HYDRATION_DELAY_MS);
|
|
835
|
-
|
|
836
|
-
return () => {
|
|
837
|
-
controller.abort();
|
|
838
|
-
finishHydration(false);
|
|
839
|
-
window.clearTimeout(timer);
|
|
840
|
-
if (idleId !== null && "cancelIdleCallback" in window) {
|
|
841
|
-
window.cancelIdleCallback(idleId);
|
|
842
|
-
}
|
|
843
|
-
};
|
|
844
|
-
}, [data]);
|
|
845
|
-
|
|
846
|
-
const items = useMemo(() => {
|
|
847
|
-
const query = search.trim().toLowerCase();
|
|
848
|
-
const filtered = (data?.items ?? []).filter((item) => {
|
|
849
|
-
if (!query) {
|
|
850
|
-
return true;
|
|
851
|
-
}
|
|
852
|
-
return [
|
|
853
|
-
item.title,
|
|
854
|
-
item.description,
|
|
855
|
-
item.displayUrl,
|
|
856
|
-
item.host,
|
|
857
|
-
item.topSharer?.handle,
|
|
858
|
-
...item.mentions.map((mention) => mention.commentText),
|
|
859
|
-
]
|
|
860
|
-
.filter(Boolean)
|
|
861
|
-
.some((value) => String(value).toLowerCase().includes(query));
|
|
862
|
-
});
|
|
863
|
-
return filtered;
|
|
864
|
-
}, [data?.items, search]);
|
|
865
|
-
|
|
866
|
-
const subtitle = useMemo(() => {
|
|
867
|
-
if (!data) {
|
|
868
|
-
return "Loading link memory...";
|
|
869
|
-
}
|
|
870
|
-
const label = kind === "videos" ? "video URLs" : "URLs";
|
|
871
|
-
return `${formatCompactNumber(data.stats.occurrences)} ${label} across ${formatCompactNumber(data.stats.groups)} groups`;
|
|
872
|
-
}, [data, kind]);
|
|
873
|
-
|
|
874
|
-
return (
|
|
875
|
-
<>
|
|
876
|
-
<header className={pageHeaderClass}>
|
|
877
|
-
<div className={pageHeaderRowClass}>
|
|
878
|
-
<div className="flex min-w-0 flex-col">
|
|
879
|
-
<h1 className={pageTitleClass}>Links</h1>
|
|
880
|
-
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
881
|
-
</div>
|
|
882
|
-
</div>
|
|
883
|
-
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
|
|
884
|
-
<label className={cx(searchFieldShellClass, "min-w-[180px] flex-1")}>
|
|
885
|
-
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
886
|
-
<input
|
|
887
|
-
className={searchFieldInputClass}
|
|
888
|
-
onChange={(event) => setSearch(event.target.value)}
|
|
889
|
-
placeholder="Search links"
|
|
890
|
-
value={search}
|
|
891
|
-
/>
|
|
892
|
-
</label>
|
|
893
|
-
<div className={segmentedClass}>
|
|
894
|
-
{(["links", "videos"] as const).map((value) => (
|
|
895
|
-
<button
|
|
896
|
-
key={value}
|
|
897
|
-
className={cx(
|
|
898
|
-
segmentClass,
|
|
899
|
-
kind === value && segmentActiveClass,
|
|
900
|
-
)}
|
|
901
|
-
onClick={() => setKind(value)}
|
|
902
|
-
type="button"
|
|
903
|
-
>
|
|
904
|
-
{value}
|
|
905
|
-
</button>
|
|
906
|
-
))}
|
|
907
|
-
</div>
|
|
908
|
-
<div className={segmentedClass}>
|
|
909
|
-
{ranges.map((entry) => (
|
|
910
|
-
<button
|
|
911
|
-
key={entry.value}
|
|
912
|
-
className={cx(
|
|
913
|
-
segmentClass,
|
|
914
|
-
range === entry.value && segmentActiveClass,
|
|
915
|
-
)}
|
|
916
|
-
onClick={() => setRange(entry.value)}
|
|
917
|
-
type="button"
|
|
918
|
-
>
|
|
919
|
-
{entry.label}
|
|
920
|
-
</button>
|
|
921
|
-
))}
|
|
922
|
-
</div>
|
|
923
|
-
<div className={segmentedClass}>
|
|
924
|
-
{(["all", "tweet", "dm"] as const).map((value) => (
|
|
925
|
-
<button
|
|
926
|
-
key={value}
|
|
927
|
-
className={cx(
|
|
928
|
-
segmentClass,
|
|
929
|
-
source === value && segmentActiveClass,
|
|
930
|
-
)}
|
|
931
|
-
onClick={() => setSource(value)}
|
|
932
|
-
type="button"
|
|
933
|
-
>
|
|
934
|
-
{value}
|
|
935
|
-
</button>
|
|
936
|
-
))}
|
|
937
|
-
</div>
|
|
938
|
-
<div className={segmentedClass}>
|
|
939
|
-
{(["rank", "recent", "comments"] as const).map((value) => (
|
|
940
|
-
<button
|
|
941
|
-
key={value}
|
|
942
|
-
className={cx(
|
|
943
|
-
segmentClass,
|
|
944
|
-
sort === value && segmentActiveClass,
|
|
945
|
-
)}
|
|
946
|
-
onClick={() => setSort(value)}
|
|
947
|
-
type="button"
|
|
948
|
-
>
|
|
949
|
-
{value}
|
|
950
|
-
</button>
|
|
951
|
-
))}
|
|
952
|
-
</div>
|
|
953
|
-
</div>
|
|
954
|
-
</header>
|
|
955
|
-
|
|
956
|
-
<section className="flex flex-col">
|
|
957
|
-
{loading ? (
|
|
958
|
-
<FeedLoading
|
|
959
|
-
detail="Ranking URLs and collecting local discussion"
|
|
960
|
-
label="Loading links"
|
|
961
|
-
>
|
|
962
|
-
<LinkSkeletonRows />
|
|
963
|
-
</FeedLoading>
|
|
964
|
-
) : error ? (
|
|
965
|
-
<FeedError
|
|
966
|
-
action={
|
|
967
|
-
<button
|
|
968
|
-
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
969
|
-
onClick={() => setRefreshTick((value) => value + 1)}
|
|
970
|
-
type="button"
|
|
971
|
-
>
|
|
972
|
-
Retry
|
|
973
|
-
</button>
|
|
974
|
-
}
|
|
975
|
-
message={error}
|
|
976
|
-
title="Could not load links"
|
|
977
|
-
/>
|
|
978
|
-
) : items.length === 0 ? (
|
|
979
|
-
<FeedEmpty
|
|
980
|
-
detail="Try a different range, source, or search term."
|
|
981
|
-
label="No links in this window"
|
|
982
|
-
/>
|
|
983
|
-
) : null}
|
|
984
|
-
{items.map((item, index) => (
|
|
985
|
-
<LinkInsightRow
|
|
986
|
-
index={index}
|
|
987
|
-
item={item}
|
|
988
|
-
key={`${kind}:${range}:${source}:${item.id}`}
|
|
989
|
-
/>
|
|
990
|
-
))}
|
|
991
|
-
</section>
|
|
992
|
-
</>
|
|
993
|
-
);
|
|
994
|
-
}
|