birdclaw 0.8.0 → 0.8.2
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 +22 -0
- package/package.json +5 -8
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +9 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +9 -4
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +59 -100
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +24 -6
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +149 -47
- package/src/lib/api-client.ts +117 -2
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/authored-live.ts +2 -62
- package/src/lib/bird.ts +32 -1
- package/src/lib/client-cache.ts +109 -0
- package/src/lib/db.ts +57 -4
- package/src/lib/mention-threads-live.ts +2 -1
- package/src/lib/mentions-live.ts +2 -46
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +184 -24
- package/src/lib/present.ts +99 -1
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +68 -6
- package/src/lib/sqlite.ts +5 -2
- package/src/lib/timeline-collections-live.ts +2 -1
- package/src/lib/timeline-live.ts +2 -1
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-search-live.ts +2 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +0 -13
- package/src/routes/api/period-digest.tsx +21 -69
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +75 -9
- package/src/routes/today.tsx +67 -11
- package/vite.config.ts +0 -2
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type MouseEventHandler,
|
|
4
|
-
type ReactNode,
|
|
5
|
-
useLayoutEffect,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
} from "react";
|
|
9
|
-
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
1
|
+
import { Fragment, type MouseEventHandler, type ReactNode } from "react";
|
|
2
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
10
3
|
import type { PeriodDigestContext } from "#/lib/period-digest";
|
|
11
4
|
import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
|
|
12
5
|
import { renderTweetPlainText } from "#/lib/tweet-render";
|
|
@@ -14,34 +7,17 @@ import type { ProfileRecord } from "#/lib/types";
|
|
|
14
7
|
import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
|
|
15
8
|
import { safeHttpUrl } from "#/lib/url-safety";
|
|
16
9
|
import { AvatarChip } from "./AvatarChip";
|
|
10
|
+
import { useFloatingPreview } from "./FloatingPreview";
|
|
17
11
|
import { ProfilePreview } from "./ProfilePreview";
|
|
12
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
18
13
|
|
|
19
14
|
type CitationTweet = PeriodDigestContext["tweets"][number];
|
|
20
15
|
type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
|
|
21
|
-
type VerticalBounds = { top: number; bottom: number };
|
|
22
|
-
|
|
23
16
|
type InlineLookup = {
|
|
24
17
|
tweetsById: Map<string, CitationTweet>;
|
|
25
18
|
profilesByHandle: Map<string, ProfileRecord>;
|
|
26
19
|
};
|
|
27
20
|
|
|
28
|
-
function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
|
|
29
|
-
let top = 0;
|
|
30
|
-
let bottom = window.innerHeight;
|
|
31
|
-
for (
|
|
32
|
-
let current = element.parentElement;
|
|
33
|
-
current;
|
|
34
|
-
current = current.parentElement
|
|
35
|
-
) {
|
|
36
|
-
const style = window.getComputedStyle(current);
|
|
37
|
-
if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
|
|
38
|
-
const rect = current.getBoundingClientRect();
|
|
39
|
-
top = Math.max(top, rect.top);
|
|
40
|
-
bottom = Math.min(bottom, rect.bottom);
|
|
41
|
-
}
|
|
42
|
-
return { top, bottom };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
21
|
function normalizeTweetReference(value: string) {
|
|
46
22
|
return value
|
|
47
23
|
.trim()
|
|
@@ -134,13 +110,16 @@ function TweetSourceLink({
|
|
|
134
110
|
children,
|
|
135
111
|
href,
|
|
136
112
|
onClick,
|
|
113
|
+
describedBy,
|
|
137
114
|
}: {
|
|
138
115
|
children: ReactNode;
|
|
139
116
|
href: string;
|
|
140
117
|
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
118
|
+
describedBy?: string;
|
|
141
119
|
}) {
|
|
142
120
|
return (
|
|
143
121
|
<a
|
|
122
|
+
aria-describedby={describedBy}
|
|
144
123
|
className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
|
|
145
124
|
href={href}
|
|
146
125
|
onClick={onClick}
|
|
@@ -159,93 +138,73 @@ function TweetPreviewToken({
|
|
|
159
138
|
tweet: CitationTweet;
|
|
160
139
|
children: ReactNode;
|
|
161
140
|
}) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const cardRect = card?.getBoundingClientRect();
|
|
175
|
-
const cardHeight = Math.max(
|
|
176
|
-
card?.offsetHeight ?? 0,
|
|
177
|
-
cardRect?.height ?? 0,
|
|
178
|
-
220,
|
|
179
|
-
);
|
|
180
|
-
const bounds = nearestVerticalClipBounds(shell);
|
|
181
|
-
const belowSpace = bounds.bottom - shellRect.bottom;
|
|
182
|
-
const aboveSpace = shellRect.top - bounds.top;
|
|
183
|
-
setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
|
|
184
|
-
};
|
|
185
|
-
updatePlacement();
|
|
186
|
-
const frame = window.requestAnimationFrame(updatePlacement);
|
|
187
|
-
return () => window.cancelAnimationFrame(frame);
|
|
188
|
-
}, [open]);
|
|
189
|
-
|
|
190
|
-
function closePreview() {
|
|
191
|
-
setOpen(false);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const previewText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
|
|
141
|
+
const preview = useFloatingPreview();
|
|
142
|
+
|
|
143
|
+
const article = tweet.entities?.article;
|
|
144
|
+
const renderedText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
|
|
145
|
+
const previewText = article
|
|
146
|
+
? [article.title, article.previewText]
|
|
147
|
+
.filter(
|
|
148
|
+
(value, index, values): value is string =>
|
|
149
|
+
Boolean(value) && values.indexOf(value) === index,
|
|
150
|
+
)
|
|
151
|
+
.join("\n\n")
|
|
152
|
+
: renderedText;
|
|
195
153
|
|
|
196
154
|
return (
|
|
197
155
|
<span
|
|
198
|
-
ref={
|
|
199
|
-
className="
|
|
200
|
-
|
|
201
|
-
onFocus={() => setOpen(true)}
|
|
202
|
-
onPointerEnter={() => setOpen(true)}
|
|
203
|
-
onPointerLeave={closePreview}
|
|
156
|
+
ref={preview.referenceRef}
|
|
157
|
+
className="inline align-baseline"
|
|
158
|
+
{...preview.referenceProps}
|
|
204
159
|
>
|
|
205
160
|
<TweetSourceLink
|
|
161
|
+
describedBy={preview.open ? preview.floatingId : undefined}
|
|
206
162
|
href={getTweetUrl(tweet)}
|
|
207
163
|
onClick={(event) => {
|
|
208
|
-
closePreview();
|
|
164
|
+
preview.closePreview();
|
|
209
165
|
event.currentTarget.blur();
|
|
210
166
|
}}
|
|
211
167
|
>
|
|
212
168
|
{children}
|
|
213
169
|
</TweetSourceLink>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
170
|
+
{preview.open ? (
|
|
171
|
+
<span
|
|
172
|
+
id={preview.floatingId}
|
|
173
|
+
ref={preview.floatingRef}
|
|
174
|
+
className="fixed z-40 w-[360px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]"
|
|
175
|
+
role="tooltip"
|
|
176
|
+
style={preview.floatingStyle}
|
|
177
|
+
{...preview.floatingProps}
|
|
178
|
+
>
|
|
179
|
+
<span className="block" data-floating-preview-content>
|
|
180
|
+
<span className="mb-2 flex items-center gap-2">
|
|
181
|
+
<AvatarChip
|
|
182
|
+
avatarUrl={tweet.authorProfile.avatarUrl}
|
|
183
|
+
hue={tweet.authorProfile.avatarHue}
|
|
184
|
+
name={tweet.name}
|
|
185
|
+
profileId={tweet.authorProfile.id}
|
|
186
|
+
size="small"
|
|
187
|
+
/>
|
|
188
|
+
<span className="min-w-0">
|
|
189
|
+
<span className="block truncate font-bold">{tweet.name}</span>
|
|
190
|
+
<span className="block truncate text-[12px] text-[var(--ink-soft)]">
|
|
191
|
+
@{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
|
|
192
|
+
</span>
|
|
193
|
+
</span>
|
|
194
|
+
</span>
|
|
195
|
+
<span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
196
|
+
{previewText}
|
|
197
|
+
</span>
|
|
198
|
+
<span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
|
|
199
|
+
<span>{tweet.source}</span>
|
|
200
|
+
{tweet.likeCount > 0 ? (
|
|
201
|
+
<span>{formatCompactNumber(tweet.likeCount)} likes</span>
|
|
202
|
+
) : null}
|
|
203
|
+
{tweet.needsReply ? <span>reply open</span> : null}
|
|
235
204
|
</span>
|
|
236
205
|
</span>
|
|
237
206
|
</span>
|
|
238
|
-
|
|
239
|
-
{previewText}
|
|
240
|
-
</span>
|
|
241
|
-
<span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
|
|
242
|
-
<span>{tweet.source}</span>
|
|
243
|
-
{tweet.likeCount > 0 ? (
|
|
244
|
-
<span>{formatCompactNumber(tweet.likeCount)} likes</span>
|
|
245
|
-
) : null}
|
|
246
|
-
{tweet.needsReply ? <span>reply open</span> : null}
|
|
247
|
-
</span>
|
|
248
|
-
</span>
|
|
207
|
+
) : null}
|
|
249
208
|
</span>
|
|
250
209
|
);
|
|
251
210
|
}
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Fragment,
|
|
3
|
-
type ReactNode,
|
|
4
|
-
useLayoutEffect,
|
|
5
|
-
useRef,
|
|
6
|
-
useState,
|
|
7
|
-
} from "react";
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
8
2
|
import { formatCompactNumber } from "#/lib/present";
|
|
9
3
|
import {
|
|
10
4
|
collectTweetSegmentsForText,
|
|
@@ -25,25 +19,7 @@ import {
|
|
|
25
19
|
} from "#/lib/ui";
|
|
26
20
|
import { safeHttpUrl } from "#/lib/url-safety";
|
|
27
21
|
import { AvatarChip } from "./AvatarChip";
|
|
28
|
-
|
|
29
|
-
type VerticalBounds = { top: number; bottom: number };
|
|
30
|
-
|
|
31
|
-
function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
|
|
32
|
-
let top = 0;
|
|
33
|
-
let bottom = window.innerHeight;
|
|
34
|
-
for (
|
|
35
|
-
let current = element.parentElement;
|
|
36
|
-
current;
|
|
37
|
-
current = current.parentElement
|
|
38
|
-
) {
|
|
39
|
-
const style = window.getComputedStyle(current);
|
|
40
|
-
if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
|
|
41
|
-
const rect = current.getBoundingClientRect();
|
|
42
|
-
top = Math.max(top, rect.top);
|
|
43
|
-
bottom = Math.min(bottom, rect.bottom);
|
|
44
|
-
}
|
|
45
|
-
return { top, bottom };
|
|
46
|
-
}
|
|
22
|
+
import { useFloatingPreview } from "./FloatingPreview";
|
|
47
23
|
|
|
48
24
|
function ProfilePreviewBio({ profile }: { profile: ProfileRecord }) {
|
|
49
25
|
const segments = collectTweetSegmentsForText(
|
|
@@ -98,74 +74,56 @@ export function ProfilePreview({
|
|
|
98
74
|
children: ReactNode;
|
|
99
75
|
className?: string;
|
|
100
76
|
}) {
|
|
101
|
-
const
|
|
102
|
-
const shellRef = useRef<HTMLSpanElement | null>(null);
|
|
103
|
-
const cardRef = useRef<HTMLSpanElement | null>(null);
|
|
104
|
-
|
|
105
|
-
function updatePlacement() {
|
|
106
|
-
const shell = shellRef.current;
|
|
107
|
-
if (!shell) return;
|
|
108
|
-
const shellRect = shell.getBoundingClientRect();
|
|
109
|
-
const card = cardRef.current;
|
|
110
|
-
const cardRect = card?.getBoundingClientRect();
|
|
111
|
-
const cardHeight = Math.max(
|
|
112
|
-
card?.offsetHeight ?? 0,
|
|
113
|
-
cardRect?.height ?? 0,
|
|
114
|
-
180,
|
|
115
|
-
);
|
|
116
|
-
const bounds = nearestVerticalClipBounds(shell);
|
|
117
|
-
const belowSpace = bounds.bottom - shellRect.bottom;
|
|
118
|
-
const aboveSpace = shellRect.top - bounds.top;
|
|
119
|
-
setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
useLayoutEffect(() => {
|
|
123
|
-
updatePlacement();
|
|
124
|
-
const frame = window.requestAnimationFrame(updatePlacement);
|
|
125
|
-
return () => window.cancelAnimationFrame(frame);
|
|
126
|
-
}, []);
|
|
77
|
+
const preview = useFloatingPreview();
|
|
127
78
|
|
|
128
79
|
return (
|
|
129
80
|
<span
|
|
130
|
-
ref={
|
|
131
|
-
className={cx(profilePreviewClass,
|
|
132
|
-
|
|
133
|
-
onPointerEnter={updatePlacement}
|
|
81
|
+
ref={preview.referenceRef}
|
|
82
|
+
className={cx(profilePreviewClass, className)}
|
|
83
|
+
{...preview.referenceProps}
|
|
134
84
|
>
|
|
135
85
|
<a
|
|
86
|
+
aria-controls={preview.open ? preview.floatingId : undefined}
|
|
87
|
+
aria-expanded={preview.open}
|
|
136
88
|
className={profilePreviewTriggerClass}
|
|
137
89
|
href={`/profiles/${encodeURIComponent(profile.handle)}`}
|
|
138
90
|
>
|
|
139
91
|
{children}
|
|
140
92
|
</a>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
93
|
+
{preview.open ? (
|
|
94
|
+
<span
|
|
95
|
+
aria-label={`${profile.displayName} profile preview`}
|
|
96
|
+
id={preview.floatingId}
|
|
97
|
+
ref={preview.floatingRef}
|
|
98
|
+
className={profilePreviewCardClass}
|
|
99
|
+
role="group"
|
|
100
|
+
style={preview.floatingStyle}
|
|
101
|
+
{...preview.floatingProps}
|
|
102
|
+
>
|
|
103
|
+
<span className="grid gap-2" data-floating-preview-content>
|
|
104
|
+
<span className={profilePreviewHeaderClass}>
|
|
105
|
+
<AvatarChip
|
|
106
|
+
avatarUrl={profile.avatarUrl}
|
|
107
|
+
hue={profile.avatarHue}
|
|
108
|
+
name={profile.displayName}
|
|
109
|
+
profileId={profile.id}
|
|
110
|
+
/>
|
|
111
|
+
<span className="flex min-w-0 flex-col">
|
|
112
|
+
<span className={profilePreviewNameClass}>
|
|
113
|
+
{profile.displayName}
|
|
114
|
+
</span>
|
|
115
|
+
<span className={profilePreviewHandleClass}>
|
|
116
|
+
@{profile.handle}
|
|
117
|
+
</span>
|
|
118
|
+
</span>
|
|
119
|
+
</span>
|
|
120
|
+
{profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
|
|
121
|
+
<span className={profilePreviewMetaClass}>
|
|
122
|
+
{formatCompactNumber(profile.followersCount)} followers
|
|
160
123
|
</span>
|
|
161
|
-
<span className={profilePreviewHandleClass}>@{profile.handle}</span>
|
|
162
124
|
</span>
|
|
163
125
|
</span>
|
|
164
|
-
|
|
165
|
-
<span className={profilePreviewMetaClass}>
|
|
166
|
-
{formatCompactNumber(profile.followersCount)} followers
|
|
167
|
-
</span>
|
|
168
|
-
</span>
|
|
126
|
+
) : null}
|
|
169
127
|
</span>
|
|
170
128
|
);
|
|
171
129
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
formatExactTimestamp,
|
|
4
|
+
formatShortTimestamp,
|
|
5
|
+
formatSmartTimestamp,
|
|
6
|
+
} from "#/lib/present";
|
|
7
|
+
|
|
8
|
+
const CLOCK_INTERVAL_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
type ClockSnapshot = number | null;
|
|
11
|
+
|
|
12
|
+
const listeners = new Set<() => void>();
|
|
13
|
+
let currentNow = Date.now();
|
|
14
|
+
let clockInterval: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
|
|
16
|
+
function updateClock() {
|
|
17
|
+
currentNow = Date.now();
|
|
18
|
+
for (const listener of listeners) listener();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function subscribeToClock(listener: () => void) {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
|
|
24
|
+
if (clockInterval === null) {
|
|
25
|
+
currentNow = Date.now();
|
|
26
|
+
clockInterval = setInterval(updateClock, CLOCK_INTERVAL_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
listeners.delete(listener);
|
|
31
|
+
if (listeners.size === 0 && clockInterval !== null) {
|
|
32
|
+
clearInterval(clockInterval);
|
|
33
|
+
clockInterval = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getClockSnapshot(): ClockSnapshot {
|
|
39
|
+
return currentNow;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getServerClockSnapshot(): ClockSnapshot {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SmartTimestamp({
|
|
47
|
+
className,
|
|
48
|
+
value,
|
|
49
|
+
}: {
|
|
50
|
+
className?: string;
|
|
51
|
+
value: string;
|
|
52
|
+
}) {
|
|
53
|
+
const now = useSyncExternalStore(
|
|
54
|
+
subscribeToClock,
|
|
55
|
+
getClockSnapshot,
|
|
56
|
+
getServerClockSnapshot,
|
|
57
|
+
);
|
|
58
|
+
const exactTimestamp = formatExactTimestamp(value);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<time
|
|
62
|
+
aria-label={exactTimestamp}
|
|
63
|
+
className={className}
|
|
64
|
+
dateTime={value}
|
|
65
|
+
title={exactTimestamp}
|
|
66
|
+
>
|
|
67
|
+
{now === null
|
|
68
|
+
? formatShortTimestamp(value)
|
|
69
|
+
: formatSmartTimestamp(value, now)}
|
|
70
|
+
</time>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
Repeat2,
|
|
9
9
|
UserSearch,
|
|
10
10
|
} from "lucide-react";
|
|
11
|
-
import { formatCompactNumber
|
|
12
|
-
import {
|
|
11
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
12
|
+
import {
|
|
13
|
+
isTweetArticleUrlEntity,
|
|
14
|
+
normalizeTweetUrlEntityRangeForText,
|
|
15
|
+
} from "#/lib/tweet-render";
|
|
13
16
|
import type {
|
|
14
17
|
TimelineItem,
|
|
15
18
|
TweetEntities,
|
|
@@ -41,6 +44,8 @@ import { ConversationThread } from "./ConversationThread";
|
|
|
41
44
|
import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
|
|
42
45
|
import { LinkPreviewCard } from "./LinkPreviewCard";
|
|
43
46
|
import { ProfilePreview } from "./ProfilePreview";
|
|
47
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
48
|
+
import { TweetArticleCard } from "./TweetArticleCard";
|
|
44
49
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
45
50
|
import { TweetRichText } from "./TweetRichText";
|
|
46
51
|
|
|
@@ -212,6 +217,9 @@ function getVisibleUrlCards(
|
|
|
212
217
|
) {
|
|
213
218
|
return (entities.urls ?? []).filter((entry) => {
|
|
214
219
|
if (isUnresolvedShortUrlEntity(entry)) return false;
|
|
220
|
+
if (entities.article && isTweetArticleUrlEntity(entry, entities.article)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
215
223
|
if (!quotedTweetId) return true;
|
|
216
224
|
return !entry.expandedUrl.includes(quotedTweetId);
|
|
217
225
|
});
|
|
@@ -276,7 +284,10 @@ export function TimelineCard({
|
|
|
276
284
|
|
|
277
285
|
return (
|
|
278
286
|
<article
|
|
279
|
-
className={cx(
|
|
287
|
+
className={cx(
|
|
288
|
+
feedRowClass,
|
|
289
|
+
"cursor-pointer [content-visibility:auto] [contain-intrinsic-size:auto_280px]",
|
|
290
|
+
)}
|
|
280
291
|
data-perf="timeline-card"
|
|
281
292
|
onFocus={conversation.prefetch}
|
|
282
293
|
onMouseEnter={conversation.prefetch}
|
|
@@ -312,9 +323,10 @@ export function TimelineCard({
|
|
|
312
323
|
</span>
|
|
313
324
|
</ProfilePreview>
|
|
314
325
|
<span className={feedRowDotClass}>·</span>
|
|
315
|
-
<
|
|
316
|
-
{
|
|
317
|
-
|
|
326
|
+
<SmartTimestamp
|
|
327
|
+
className={feedRowTimestampClass}
|
|
328
|
+
value={displayTweet.createdAt}
|
|
329
|
+
/>
|
|
318
330
|
{canReply || hasConversation ? (
|
|
319
331
|
<span className="ml-auto inline-flex items-center gap-1">
|
|
320
332
|
{hasConversation ? (
|
|
@@ -361,6 +373,9 @@ export function TimelineCard({
|
|
|
361
373
|
text={displayTweet.text}
|
|
362
374
|
/>
|
|
363
375
|
<TweetMediaGrid items={displayTweet.media} />
|
|
376
|
+
{displayTweet.entities.article ? (
|
|
377
|
+
<TweetArticleCard article={displayTweet.entities.article} />
|
|
378
|
+
) : null}
|
|
364
379
|
{visibleUrlCards.map((entry, index) => (
|
|
365
380
|
<LinkPreviewCard
|
|
366
381
|
key={`${entry.expandedUrl}-${String(index)}`}
|
|
@@ -378,6 +393,9 @@ export function TimelineCard({
|
|
|
378
393
|
text={item.text}
|
|
379
394
|
/>
|
|
380
395
|
<TweetMediaGrid items={item.media} />
|
|
396
|
+
{item.entities.article ? (
|
|
397
|
+
<TweetArticleCard article={item.entities.article} />
|
|
398
|
+
) : null}
|
|
381
399
|
{item.replyToTweet ? (
|
|
382
400
|
<div className={embeddedCardClass}>
|
|
383
401
|
<EmbeddedTweetCard
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BookOpen, ExternalLink } from "lucide-react";
|
|
2
|
+
import type { TweetArticle } from "#/lib/types";
|
|
3
|
+
import {
|
|
4
|
+
linkPreviewCardClass,
|
|
5
|
+
linkPreviewDescClass,
|
|
6
|
+
linkPreviewHostClass,
|
|
7
|
+
linkPreviewTitleClass,
|
|
8
|
+
} from "#/lib/ui";
|
|
9
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
10
|
+
|
|
11
|
+
function safeArticleImageUrl(value: string | undefined) {
|
|
12
|
+
const url = safeHttpUrl(value);
|
|
13
|
+
if (!url) return null;
|
|
14
|
+
try {
|
|
15
|
+
return new URL(url).hostname === "pbs.twimg.com" ? url : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function TweetArticleCard({ article }: { article: TweetArticle }) {
|
|
22
|
+
const href = safeHttpUrl(article.url);
|
|
23
|
+
if (!href) return null;
|
|
24
|
+
const imageUrl = safeArticleImageUrl(article.coverImageUrl);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<a
|
|
28
|
+
aria-label={`Read article: ${article.title}`}
|
|
29
|
+
className={linkPreviewCardClass}
|
|
30
|
+
data-perf="tweet-article-card"
|
|
31
|
+
href={href}
|
|
32
|
+
rel="noreferrer"
|
|
33
|
+
target="_blank"
|
|
34
|
+
>
|
|
35
|
+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
|
|
36
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
37
|
+
<BookOpen
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className="size-3.5 shrink-0 text-[var(--ink-soft)]"
|
|
40
|
+
strokeWidth={1.8}
|
|
41
|
+
/>
|
|
42
|
+
<span className={linkPreviewHostClass}>Article on X</span>
|
|
43
|
+
<ExternalLink
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
|
|
46
|
+
strokeWidth={1.8}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<span className={linkPreviewTitleClass}>{article.title}</span>
|
|
50
|
+
{article.previewText ? (
|
|
51
|
+
<span className={linkPreviewDescClass}>{article.previewText}</span>
|
|
52
|
+
) : null}
|
|
53
|
+
</div>
|
|
54
|
+
{imageUrl ? (
|
|
55
|
+
<div className="flex aspect-[1.45] w-40 shrink-0 overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
|
|
56
|
+
<img
|
|
57
|
+
alt=""
|
|
58
|
+
className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
|
|
59
|
+
loading="lazy"
|
|
60
|
+
src={imageUrl}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
64
|
+
</a>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -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,12 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function useDebouncedValue<T>(value: T, delayMs: number) {
|
|
4
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timeout = window.setTimeout(() => setDebouncedValue(value), delayMs);
|
|
8
|
+
return () => window.clearTimeout(timeout);
|
|
9
|
+
}, [delayMs, value]);
|
|
10
|
+
|
|
11
|
+
return debouncedValue;
|
|
12
|
+
}
|