birdclaw 0.4.1 → 0.5.1
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 +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +326 -35
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { formatShortTimestamp } from "#/lib/present";
|
|
2
2
|
import type { EmbeddedTweet } from "#/lib/types";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
embeddedCardBodyClass,
|
|
5
|
+
embeddedCardCopyClass,
|
|
6
|
+
embeddedCardHandleClass,
|
|
7
|
+
embeddedCardHeaderClass,
|
|
8
|
+
embeddedCardLabelClass,
|
|
9
|
+
embeddedCardNameClass,
|
|
10
|
+
feedRowTimestampClass,
|
|
10
11
|
} from "#/lib/ui";
|
|
11
12
|
import { ProfilePreview } from "./ProfilePreview";
|
|
12
13
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
@@ -20,21 +21,26 @@ export function EmbeddedTweetCard({
|
|
|
20
21
|
label: string;
|
|
21
22
|
}) {
|
|
22
23
|
return (
|
|
23
|
-
<section className={
|
|
24
|
-
<p className={
|
|
25
|
-
<header className={
|
|
24
|
+
<section className={embeddedCardBodyClass}>
|
|
25
|
+
<p className={embeddedCardLabelClass}>{label}</p>
|
|
26
|
+
<header className={embeddedCardHeaderClass}>
|
|
26
27
|
<ProfilePreview profile={item.author}>
|
|
27
|
-
<span className=
|
|
28
|
-
<
|
|
29
|
-
|
|
28
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
29
|
+
<span className={embeddedCardNameClass}>
|
|
30
|
+
{item.author.displayName}
|
|
31
|
+
</span>
|
|
32
|
+
<span className={embeddedCardHandleClass}>
|
|
33
|
+
@{item.author.handle}
|
|
34
|
+
</span>
|
|
30
35
|
</span>
|
|
31
36
|
</ProfilePreview>
|
|
32
|
-
<span className=
|
|
37
|
+
<span className="text-[var(--ink-soft)]">·</span>
|
|
38
|
+
<span className={feedRowTimestampClass}>
|
|
33
39
|
{formatShortTimestamp(item.createdAt)}
|
|
34
40
|
</span>
|
|
35
41
|
</header>
|
|
36
42
|
<TweetRichText
|
|
37
|
-
className={
|
|
43
|
+
className={embeddedCardCopyClass}
|
|
38
44
|
entities={item.entities}
|
|
39
45
|
text={item.text}
|
|
40
46
|
/>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
3
|
+
|
|
4
|
+
function SkeletonBlock({
|
|
5
|
+
className,
|
|
6
|
+
muted = false,
|
|
7
|
+
}: {
|
|
8
|
+
className: string;
|
|
9
|
+
muted?: boolean;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<span
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
className={`block animate-pulse rounded-full ${muted ? "bg-[color:color-mix(in_srgb,var(--ink-soft)_16%,transparent)]" : "bg-[color:color-mix(in_srgb,var(--ink-soft)_24%,transparent)]"} ${className}`}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TweetSkeletonRows({ count = 4 }: { count?: number }) {
|
|
20
|
+
return (
|
|
21
|
+
<div aria-hidden="true" className="divide-y divide-[var(--line)]">
|
|
22
|
+
{Array.from({ length: count }, (_unused, index) => {
|
|
23
|
+
const hasMedia = index % 3 === 0;
|
|
24
|
+
const hasQuote = index % 4 === 2;
|
|
25
|
+
return (
|
|
26
|
+
<article
|
|
27
|
+
className="flex gap-3 px-4 py-3"
|
|
28
|
+
data-perf="tweet-skeleton-row"
|
|
29
|
+
key={`tweet-skeleton-${index}`}
|
|
30
|
+
>
|
|
31
|
+
<SkeletonBlock className="size-10 shrink-0 rounded-full" />
|
|
32
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<SkeletonBlock className="h-3.5 w-32" />
|
|
35
|
+
<SkeletonBlock className="h-3 w-20" muted />
|
|
36
|
+
<SkeletonBlock className="ml-auto h-5 w-16" muted />
|
|
37
|
+
</div>
|
|
38
|
+
<div className="space-y-2">
|
|
39
|
+
<SkeletonBlock className="h-3 w-full" />
|
|
40
|
+
<SkeletonBlock className="h-3 w-11/12" muted />
|
|
41
|
+
{index % 2 === 0 ? (
|
|
42
|
+
<SkeletonBlock className="h-3 w-7/12" muted />
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
{hasMedia ? (
|
|
46
|
+
<SkeletonBlock className="h-32 w-full rounded-2xl" muted />
|
|
47
|
+
) : null}
|
|
48
|
+
{hasQuote ? (
|
|
49
|
+
<div className="space-y-2 rounded-2xl border border-[var(--line)] px-3 py-2">
|
|
50
|
+
<SkeletonBlock className="h-3 w-28" muted />
|
|
51
|
+
<SkeletonBlock className="h-3 w-full" muted />
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
54
|
+
<div className="flex max-w-md items-center justify-between">
|
|
55
|
+
{["thread", "reply", "repost", "like"].map((key) => (
|
|
56
|
+
<SkeletonBlock className="h-3 w-11" key={key} muted />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function LinkSkeletonRows({ count = 4 }: { count?: number }) {
|
|
68
|
+
return (
|
|
69
|
+
<div aria-hidden="true" className="divide-y divide-[var(--line)]">
|
|
70
|
+
{Array.from({ length: count }, (_unused, index) => (
|
|
71
|
+
<article
|
|
72
|
+
className="flex gap-3 px-4 py-3"
|
|
73
|
+
data-perf="link-skeleton-row"
|
|
74
|
+
key={`link-skeleton-${index}`}
|
|
75
|
+
>
|
|
76
|
+
<SkeletonBlock className="size-9 shrink-0 rounded-full" muted />
|
|
77
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
78
|
+
<SkeletonBlock className="h-3.5 w-8/12" />
|
|
79
|
+
<SkeletonBlock className="h-3 w-6/12" muted />
|
|
80
|
+
<div className="flex flex-wrap gap-2">
|
|
81
|
+
<SkeletonBlock className="h-3 w-20" muted />
|
|
82
|
+
<SkeletonBlock className="h-3 w-14" muted />
|
|
83
|
+
<SkeletonBlock className="h-3 w-24" muted />
|
|
84
|
+
</div>
|
|
85
|
+
{index % 2 === 0 ? (
|
|
86
|
+
<SkeletonBlock
|
|
87
|
+
className="h-20 w-72 max-w-full rounded-2xl"
|
|
88
|
+
muted
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
</article>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function FeedLoading({
|
|
99
|
+
children,
|
|
100
|
+
detail,
|
|
101
|
+
label,
|
|
102
|
+
}: {
|
|
103
|
+
children?: ReactNode;
|
|
104
|
+
detail?: string;
|
|
105
|
+
label: string;
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="border-b border-[var(--line)]">
|
|
109
|
+
<BirdclawLoading detail={detail} label={label} />
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function FeedError({
|
|
116
|
+
action,
|
|
117
|
+
message,
|
|
118
|
+
title = "Could not load this view",
|
|
119
|
+
}: {
|
|
120
|
+
action?: ReactNode;
|
|
121
|
+
message: string;
|
|
122
|
+
title?: string;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="border-b border-[var(--line)] px-6 py-10 text-center">
|
|
126
|
+
<div className="mx-auto max-w-sm">
|
|
127
|
+
<div className="text-[14px] font-bold text-[var(--alert)]">{title}</div>
|
|
128
|
+
<p className="mt-2 text-[13px] leading-[1.45] text-[var(--ink-soft)]">
|
|
129
|
+
{message}
|
|
130
|
+
</p>
|
|
131
|
+
{action ? (
|
|
132
|
+
<div className="mt-4 flex justify-center">{action}</div>
|
|
133
|
+
) : null}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function FeedEmpty({
|
|
140
|
+
detail,
|
|
141
|
+
label,
|
|
142
|
+
}: {
|
|
143
|
+
detail?: string;
|
|
144
|
+
label: string;
|
|
145
|
+
}) {
|
|
146
|
+
return <BirdclawEmpty detail={detail} label={label} />;
|
|
147
|
+
}
|
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
Circle,
|
|
5
|
+
ExternalLink,
|
|
6
|
+
MessageCircle,
|
|
7
|
+
} from "lucide-react";
|
|
2
8
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
3
9
|
import type { InboxItem } from "#/lib/types";
|
|
4
10
|
import {
|
|
5
|
-
actionButtonClass,
|
|
6
|
-
actionRowClass,
|
|
7
|
-
bodyCopyClass,
|
|
8
|
-
cardFooterClass,
|
|
9
|
-
cardHeaderClass,
|
|
10
11
|
composerBarClass,
|
|
11
12
|
composerInputClass,
|
|
12
13
|
composerShellClass,
|
|
13
|
-
contentCardClass,
|
|
14
14
|
cx,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
feedRowBodyClass,
|
|
16
|
+
feedRowClass,
|
|
17
|
+
feedRowDotClass,
|
|
18
|
+
feedRowHandleClass,
|
|
19
|
+
feedRowHeaderClass,
|
|
20
|
+
feedRowNameClass,
|
|
21
|
+
feedRowTextClass,
|
|
22
|
+
feedRowTimestampClass,
|
|
18
23
|
inboxAnalysisClass,
|
|
19
|
-
inboxTitleClass,
|
|
20
|
-
metaStackClass,
|
|
21
|
-
metricRowClass,
|
|
22
24
|
mutedDotClass,
|
|
23
|
-
navLinkClass,
|
|
24
25
|
pillAlertClass,
|
|
25
26
|
pillClass,
|
|
26
27
|
pillSoftClass,
|
|
28
|
+
primaryButtonClass,
|
|
29
|
+
secondaryButtonClass,
|
|
27
30
|
timestampClass,
|
|
28
31
|
} from "#/lib/ui";
|
|
29
32
|
import { AvatarChip } from "./AvatarChip";
|
|
@@ -44,93 +47,104 @@ export function InboxCard({
|
|
|
44
47
|
onReplySend: () => void;
|
|
45
48
|
}) {
|
|
46
49
|
return (
|
|
47
|
-
<article className={cx(
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
</span>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
</div>
|
|
67
|
-
<div className={metaStackClass}>
|
|
68
|
-
<span className={cx(pillClass, pillSoftClass)}>
|
|
69
|
-
{item.entityKind}
|
|
50
|
+
<article className={cx(feedRowClass, "items-start")}>
|
|
51
|
+
<AvatarChip
|
|
52
|
+
avatarUrl={item.participant.avatarUrl}
|
|
53
|
+
hue={item.participant.avatarHue}
|
|
54
|
+
name={item.participant.displayName}
|
|
55
|
+
profileId={item.participant.id}
|
|
56
|
+
/>
|
|
57
|
+
<div className={feedRowBodyClass}>
|
|
58
|
+
<header className={feedRowHeaderClass}>
|
|
59
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
60
|
+
<span className={feedRowNameClass}>
|
|
61
|
+
{item.participant.displayName}
|
|
62
|
+
</span>
|
|
63
|
+
<span className={feedRowHandleClass}>
|
|
64
|
+
@{item.participant.handle}
|
|
65
|
+
</span>
|
|
70
66
|
</span>
|
|
71
|
-
<span className={
|
|
72
|
-
|
|
73
|
-
</span>
|
|
74
|
-
<span className={timestampClass}>
|
|
67
|
+
<span className={feedRowDotClass}>·</span>
|
|
68
|
+
<span className={feedRowTimestampClass}>
|
|
75
69
|
{formatShortTimestamp(item.createdAt)}
|
|
76
70
|
</span>
|
|
71
|
+
<span className="ml-auto flex items-center gap-1.5">
|
|
72
|
+
<span className={cx(pillClass, pillSoftClass)}>
|
|
73
|
+
{item.entityKind}
|
|
74
|
+
</span>
|
|
75
|
+
<span className={cx(pillClass, pillAlertClass)}>
|
|
76
|
+
score {item.score}
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
</header>
|
|
80
|
+
<h3 className="text-[15px] font-bold text-[var(--ink)]">
|
|
81
|
+
{item.title}
|
|
82
|
+
</h3>
|
|
83
|
+
<p className={feedRowTextClass}>{item.text}</p>
|
|
84
|
+
<div className={inboxAnalysisClass}>
|
|
85
|
+
<strong className="text-[var(--ink)]">{item.summary}</strong>
|
|
86
|
+
<p>{item.reasoning}</p>
|
|
77
87
|
</div>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
className={navLinkClass}
|
|
95
|
-
onClick={onReplyToggle}
|
|
96
|
-
type="button"
|
|
97
|
-
>
|
|
98
|
-
{isReplying ? "Close reply" : "Reply"}
|
|
99
|
-
</button>
|
|
100
|
-
<Link
|
|
101
|
-
className={actionButtonClass}
|
|
102
|
-
to={item.entityKind === "dm" ? "/dms" : "/mentions"}
|
|
103
|
-
>
|
|
104
|
-
Open
|
|
105
|
-
</Link>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
{isReplying ? (
|
|
109
|
-
<div className={composerShellClass}>
|
|
110
|
-
<textarea
|
|
111
|
-
className={composerInputClass}
|
|
112
|
-
onChange={(event) => onReplyChange(event.target.value)}
|
|
113
|
-
placeholder={
|
|
114
|
-
item.entityKind === "dm"
|
|
115
|
-
? `Reply to @${item.participant.handle}`
|
|
116
|
-
: `Reply to mention from @${item.participant.handle}`
|
|
117
|
-
}
|
|
118
|
-
rows={4}
|
|
119
|
-
value={replyDraft}
|
|
120
|
-
/>
|
|
121
|
-
<div className={composerBarClass}>
|
|
122
|
-
<span className={timestampClass}>Send from inbox</span>
|
|
88
|
+
<div className="mt-2 flex items-center justify-between gap-3 text-[13px] text-[var(--ink-soft)]">
|
|
89
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
90
|
+
<span>{item.source}</span>
|
|
91
|
+
<span className={mutedDotClass} />
|
|
92
|
+
<span>influence {formatCompactNumber(item.influenceScore)}</span>
|
|
93
|
+
<span className={mutedDotClass} />
|
|
94
|
+
<span className="inline-flex items-center gap-1">
|
|
95
|
+
{item.needsReply ? (
|
|
96
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
97
|
+
) : (
|
|
98
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
99
|
+
)}
|
|
100
|
+
{item.needsReply ? "open" : "replied"}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
123
104
|
<button
|
|
124
|
-
className={
|
|
125
|
-
|
|
126
|
-
onClick={onReplySend}
|
|
105
|
+
className={secondaryButtonClass}
|
|
106
|
+
onClick={onReplyToggle}
|
|
127
107
|
type="button"
|
|
128
108
|
>
|
|
129
|
-
|
|
109
|
+
<MessageCircle className="size-4" strokeWidth={2} />
|
|
110
|
+
{isReplying ? "Close reply" : "Reply"}
|
|
130
111
|
</button>
|
|
112
|
+
<Link
|
|
113
|
+
className={cx(secondaryButtonClass, "gap-1.5")}
|
|
114
|
+
to={item.entityKind === "dm" ? "/dms" : "/mentions"}
|
|
115
|
+
>
|
|
116
|
+
<ExternalLink className="size-4" strokeWidth={2} />
|
|
117
|
+
Open
|
|
118
|
+
</Link>
|
|
131
119
|
</div>
|
|
132
120
|
</div>
|
|
133
|
-
|
|
121
|
+
{isReplying ? (
|
|
122
|
+
<div className={composerShellClass}>
|
|
123
|
+
<textarea
|
|
124
|
+
className={composerInputClass}
|
|
125
|
+
onChange={(event) => onReplyChange(event.target.value)}
|
|
126
|
+
placeholder={
|
|
127
|
+
item.entityKind === "dm"
|
|
128
|
+
? `Reply to @${item.participant.handle}`
|
|
129
|
+
: `Reply to mention from @${item.participant.handle}`
|
|
130
|
+
}
|
|
131
|
+
rows={4}
|
|
132
|
+
value={replyDraft}
|
|
133
|
+
/>
|
|
134
|
+
<div className={composerBarClass}>
|
|
135
|
+
<span className={timestampClass}>Send from inbox</span>
|
|
136
|
+
<button
|
|
137
|
+
className={primaryButtonClass}
|
|
138
|
+
disabled={!replyDraft.trim()}
|
|
139
|
+
onClick={onReplySend}
|
|
140
|
+
type="button"
|
|
141
|
+
>
|
|
142
|
+
Send
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
) : null}
|
|
147
|
+
</div>
|
|
134
148
|
</article>
|
|
135
149
|
);
|
|
136
150
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { ExternalLink, Image as ImageIcon } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { LinkPreviewMetadata } from "#/lib/link-preview-metadata";
|
|
4
|
+
import type { TweetUrlEntity } from "#/lib/types";
|
|
5
|
+
import {
|
|
6
|
+
cx,
|
|
7
|
+
linkPreviewCardClass,
|
|
8
|
+
linkPreviewDescClass,
|
|
9
|
+
linkPreviewHostClass,
|
|
10
|
+
linkPreviewTitleClass,
|
|
11
|
+
} from "#/lib/ui";
|
|
12
|
+
|
|
13
|
+
type LinkPreviewState = Pick<
|
|
14
|
+
TweetUrlEntity,
|
|
15
|
+
| "expandedUrl"
|
|
16
|
+
| "displayUrl"
|
|
17
|
+
| "title"
|
|
18
|
+
| "description"
|
|
19
|
+
| "imageUrl"
|
|
20
|
+
| "siteName"
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
const previewCache = new Map<string, Promise<LinkPreviewMetadata | null>>();
|
|
24
|
+
const IMAGE_EXTENSION_PATTERN = /\.(?:avif|gif|jpe?g|png|webp)(?:[?#].*)?$/i;
|
|
25
|
+
const MAX_CONCURRENT_PREVIEW_FETCHES = 2;
|
|
26
|
+
let activePreviewFetches = 0;
|
|
27
|
+
const queuedPreviewFetches: Array<() => void> = [];
|
|
28
|
+
|
|
29
|
+
function needsHydration(preview: LinkPreviewState) {
|
|
30
|
+
const targetUrl = preview.expandedUrl || "";
|
|
31
|
+
if (
|
|
32
|
+
preview.imageUrl &&
|
|
33
|
+
preview.siteName &&
|
|
34
|
+
preview.title &&
|
|
35
|
+
isDirectImageUrl(targetUrl)
|
|
36
|
+
) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
!preview.imageUrl ||
|
|
41
|
+
!preview.title ||
|
|
42
|
+
!preview.description ||
|
|
43
|
+
preview.title === preview.displayUrl ||
|
|
44
|
+
preview.description === preview.displayUrl
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runQueuedPreviewFetches() {
|
|
49
|
+
while (
|
|
50
|
+
activePreviewFetches < MAX_CONCURRENT_PREVIEW_FETCHES &&
|
|
51
|
+
queuedPreviewFetches.length > 0
|
|
52
|
+
) {
|
|
53
|
+
const next = queuedPreviewFetches.shift();
|
|
54
|
+
next?.();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function schedulePreviewFetch(task: () => Promise<LinkPreviewMetadata | null>) {
|
|
59
|
+
return new Promise<LinkPreviewMetadata | null>((resolve) => {
|
|
60
|
+
queuedPreviewFetches.push(() => {
|
|
61
|
+
activePreviewFetches += 1;
|
|
62
|
+
task()
|
|
63
|
+
.then(resolve)
|
|
64
|
+
.catch(() => resolve(null))
|
|
65
|
+
.finally(() => {
|
|
66
|
+
activePreviewFetches = Math.max(0, activePreviewFetches - 1);
|
|
67
|
+
runQueuedPreviewFetches();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
runQueuedPreviewFetches();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fetchPreview(entry: TweetUrlEntity) {
|
|
75
|
+
const targetUrl = entry.expandedUrl || entry.url;
|
|
76
|
+
if (!targetUrl) return Promise.resolve(null);
|
|
77
|
+
const key = `${entry.url} ${targetUrl}`;
|
|
78
|
+
const cached = previewCache.get(key);
|
|
79
|
+
if (cached) return cached;
|
|
80
|
+
|
|
81
|
+
const params = new URLSearchParams({ url: targetUrl });
|
|
82
|
+
if (entry.url && entry.url !== targetUrl) {
|
|
83
|
+
params.set("shortUrl", entry.url);
|
|
84
|
+
}
|
|
85
|
+
const promise = schedulePreviewFetch(() =>
|
|
86
|
+
fetch(`/api/link-preview?${params.toString()}`)
|
|
87
|
+
.then(async (response) => {
|
|
88
|
+
if (!response.ok) return null;
|
|
89
|
+
const data = (await response.json()) as {
|
|
90
|
+
ok?: boolean;
|
|
91
|
+
preview?: LinkPreviewMetadata;
|
|
92
|
+
};
|
|
93
|
+
return data.ok ? (data.preview ?? null) : null;
|
|
94
|
+
})
|
|
95
|
+
.catch(() => null),
|
|
96
|
+
);
|
|
97
|
+
previewCache.set(key, promise);
|
|
98
|
+
return promise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function displayHost(url: string, fallback: string) {
|
|
102
|
+
try {
|
|
103
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
104
|
+
} catch {
|
|
105
|
+
return fallback;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isDirectImageUrl(url: string) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
if (IMAGE_EXTENSION_PATTERN.test(parsed.pathname)) return true;
|
|
113
|
+
return (
|
|
114
|
+
parsed.hostname === "pbs.twimg.com" &&
|
|
115
|
+
(parsed.pathname.startsWith("/media/") ||
|
|
116
|
+
parsed.pathname.startsWith("/amplify_video_thumb/"))
|
|
117
|
+
);
|
|
118
|
+
} catch {
|
|
119
|
+
return IMAGE_EXTENSION_PATTERN.test(url);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function LinkPreviewCard({
|
|
124
|
+
entry,
|
|
125
|
+
index,
|
|
126
|
+
}: {
|
|
127
|
+
entry: TweetUrlEntity;
|
|
128
|
+
index: number;
|
|
129
|
+
}) {
|
|
130
|
+
const targetUrl = entry.expandedUrl || entry.url;
|
|
131
|
+
const displayUrl = entry.displayUrl || displayHost(targetUrl, targetUrl);
|
|
132
|
+
const directImageUrl = isDirectImageUrl(targetUrl) ? targetUrl : null;
|
|
133
|
+
const initialPreview = useMemo<LinkPreviewState>(
|
|
134
|
+
() => ({
|
|
135
|
+
expandedUrl: targetUrl,
|
|
136
|
+
displayUrl,
|
|
137
|
+
title:
|
|
138
|
+
entry.title ??
|
|
139
|
+
(directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
|
|
140
|
+
description:
|
|
141
|
+
entry.description ?? (directImageUrl ? displayUrl : undefined),
|
|
142
|
+
imageUrl: entry.imageUrl ?? directImageUrl,
|
|
143
|
+
siteName:
|
|
144
|
+
entry.siteName ??
|
|
145
|
+
(directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
|
|
146
|
+
}),
|
|
147
|
+
[
|
|
148
|
+
directImageUrl,
|
|
149
|
+
displayUrl,
|
|
150
|
+
entry.description,
|
|
151
|
+
entry.imageUrl,
|
|
152
|
+
entry.siteName,
|
|
153
|
+
entry.title,
|
|
154
|
+
targetUrl,
|
|
155
|
+
],
|
|
156
|
+
);
|
|
157
|
+
const [preview, setPreview] = useState(initialPreview);
|
|
158
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
159
|
+
const [hydratedKey, setHydratedKey] = useState("");
|
|
160
|
+
const [canHydrate, setCanHydrate] = useState(false);
|
|
161
|
+
const cardRef = useRef<HTMLAnchorElement | null>(null);
|
|
162
|
+
const cacheKey = `${entry.url} ${targetUrl}`;
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
setPreview(initialPreview);
|
|
166
|
+
setImageFailed(false);
|
|
167
|
+
setHydratedKey("");
|
|
168
|
+
setCanHydrate(false);
|
|
169
|
+
}, [initialPreview]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!targetUrl) return;
|
|
173
|
+
if (!needsHydration(preview)) return;
|
|
174
|
+
const node = cardRef.current;
|
|
175
|
+
if (!node || typeof IntersectionObserver === "undefined") {
|
|
176
|
+
setCanHydrate(true);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const observer = new IntersectionObserver(
|
|
180
|
+
(entries) => {
|
|
181
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
182
|
+
setCanHydrate(true);
|
|
183
|
+
observer.disconnect();
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{ rootMargin: "320px 0px" },
|
|
187
|
+
);
|
|
188
|
+
observer.observe(node);
|
|
189
|
+
return () => observer.disconnect();
|
|
190
|
+
}, [preview, targetUrl]);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!targetUrl || !canHydrate) return;
|
|
194
|
+
if (!needsHydration(preview)) return;
|
|
195
|
+
if (hydratedKey === cacheKey) return;
|
|
196
|
+
let cancelled = false;
|
|
197
|
+
const timer = window.setTimeout(() => {
|
|
198
|
+
setHydratedKey(cacheKey);
|
|
199
|
+
void fetchPreview(entry).then((metadata) => {
|
|
200
|
+
if (cancelled || !metadata) return;
|
|
201
|
+
setPreview((current) => ({
|
|
202
|
+
expandedUrl: metadata.url || current.expandedUrl,
|
|
203
|
+
displayUrl: current.displayUrl,
|
|
204
|
+
title: metadata.title ?? current.title,
|
|
205
|
+
description: metadata.description ?? current.description,
|
|
206
|
+
imageUrl: metadata.imageUrl ?? current.imageUrl,
|
|
207
|
+
siteName: metadata.siteName ?? current.siteName,
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
}, 100);
|
|
211
|
+
return () => {
|
|
212
|
+
cancelled = true;
|
|
213
|
+
window.clearTimeout(timer);
|
|
214
|
+
};
|
|
215
|
+
}, [cacheKey, canHydrate, entry, hydratedKey, preview, targetUrl]);
|
|
216
|
+
|
|
217
|
+
const title = preview.title || entry.displayUrl;
|
|
218
|
+
const description =
|
|
219
|
+
preview.description && preview.description !== title
|
|
220
|
+
? preview.description
|
|
221
|
+
: preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
222
|
+
const host =
|
|
223
|
+
preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
224
|
+
const showImage = Boolean(preview.imageUrl && !imageFailed);
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<a
|
|
228
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
229
|
+
className={linkPreviewCardClass}
|
|
230
|
+
data-perf="link-preview-card"
|
|
231
|
+
href={preview.expandedUrl}
|
|
232
|
+
ref={cardRef}
|
|
233
|
+
rel="noreferrer"
|
|
234
|
+
target="_blank"
|
|
235
|
+
>
|
|
236
|
+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
|
|
237
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
238
|
+
<span className={linkPreviewHostClass}>{host}</span>
|
|
239
|
+
<ExternalLink
|
|
240
|
+
aria-hidden="true"
|
|
241
|
+
className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
|
|
242
|
+
strokeWidth={1.8}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
<span className={linkPreviewTitleClass}>{title}</span>
|
|
246
|
+
<span className={linkPreviewDescClass}>{description}</span>
|
|
247
|
+
<span className={cx(linkPreviewHostClass, "text-[12px]")}>
|
|
248
|
+
{entry.displayUrl}
|
|
249
|
+
</span>
|
|
250
|
+
</div>
|
|
251
|
+
<div className="flex aspect-[1.45] w-40 shrink-0 items-center justify-center overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
|
|
252
|
+
{showImage ? (
|
|
253
|
+
<img
|
|
254
|
+
alt={title}
|
|
255
|
+
className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
|
|
256
|
+
loading="lazy"
|
|
257
|
+
onError={() => setImageFailed(true)}
|
|
258
|
+
src={preview.imageUrl ?? ""}
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<ImageIcon
|
|
262
|
+
aria-hidden="true"
|
|
263
|
+
className="size-8 text-[var(--ink-soft)]"
|
|
264
|
+
strokeWidth={1.7}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</a>
|
|
269
|
+
);
|
|
270
|
+
}
|