birdclaw 0.5.0 → 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 +20 -0
- package/README.md +5 -0
- package/bin/birdclaw.mjs +50 -11
- package/package.json +7 -6
- package/public/birdclaw-mark.png +0 -0
- 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 +46 -1
- package/src/components/AppNav.tsx +3 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +24 -9
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +157 -61
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +13 -2
- 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 +18 -3
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +2 -0
- package/src/lib/queries.ts +93 -28
- package/src/lib/ui.ts +11 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/dms.tsx +100 -27
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
1
|
import {
|
|
3
2
|
Bookmark,
|
|
4
3
|
BookmarkCheck,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
Circle,
|
|
5
6
|
Heart,
|
|
6
7
|
MessageCircle,
|
|
7
8
|
Repeat2,
|
|
8
9
|
} from "lucide-react";
|
|
9
10
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
TimelineItem,
|
|
13
|
+
TweetEntities,
|
|
14
|
+
TweetMediaItem,
|
|
15
|
+
TweetUrlEntity,
|
|
16
|
+
} from "#/lib/types";
|
|
17
|
+
import { useConversationSurface } from "#/lib/conversation-surface";
|
|
11
18
|
import {
|
|
12
19
|
cx,
|
|
13
20
|
embeddedCardClass,
|
|
@@ -23,6 +30,9 @@ import {
|
|
|
23
30
|
feedRowHandleClass,
|
|
24
31
|
feedRowHeaderClass,
|
|
25
32
|
feedRowNameClass,
|
|
33
|
+
feedRowStatePillActiveClass,
|
|
34
|
+
feedRowStatePillClass,
|
|
35
|
+
feedRowStatePillOpenClass,
|
|
26
36
|
feedRowTextClass,
|
|
27
37
|
feedRowTimestampClass,
|
|
28
38
|
mutedDotClass,
|
|
@@ -35,9 +45,95 @@ import { ProfilePreview } from "./ProfilePreview";
|
|
|
35
45
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
36
46
|
import { TweetRichText } from "./TweetRichText";
|
|
37
47
|
|
|
38
|
-
function
|
|
48
|
+
function comparableUrl(value: string | null | undefined) {
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(value);
|
|
52
|
+
return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`;
|
|
53
|
+
} catch {
|
|
54
|
+
return value.split("?")[0] ?? value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getMediaUrlSet(media: TweetMediaItem[]) {
|
|
59
|
+
const urls = new Set<string>();
|
|
60
|
+
for (const item of media) {
|
|
61
|
+
for (const url of [item.url, item.thumbnailUrl]) {
|
|
62
|
+
const comparable = comparableUrl(url);
|
|
63
|
+
if (comparable) urls.add(comparable);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return urls;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isMediaUrlEntity(
|
|
70
|
+
entry: TweetUrlEntity,
|
|
71
|
+
mediaUrls: Set<string>,
|
|
72
|
+
tweetId: string,
|
|
73
|
+
) {
|
|
74
|
+
if (mediaUrls.size > 0 && isOwnStatusMediaUrl(entry.expandedUrl, tweetId)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
for (const url of [entry.url, entry.expandedUrl, entry.displayUrl]) {
|
|
78
|
+
const comparable = comparableUrl(url);
|
|
79
|
+
if (comparable && mediaUrls.has(comparable)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isOwnStatusMediaUrl(
|
|
87
|
+
value: string | null | undefined,
|
|
88
|
+
tweetId: string,
|
|
89
|
+
) {
|
|
90
|
+
if (!value) return false;
|
|
91
|
+
try {
|
|
92
|
+
const parsed = new URL(value);
|
|
93
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
94
|
+
if (host !== "x.com" && host !== "twitter.com") return false;
|
|
95
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
96
|
+
const statusIndex = segments.indexOf("status");
|
|
97
|
+
if (statusIndex < 0 || segments[statusIndex + 1] !== tweetId) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const mediaSegment = segments[statusIndex + 2];
|
|
101
|
+
return mediaSegment === "photo" || mediaSegment === "video";
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getVisibleEntities(
|
|
108
|
+
entities: TweetEntities,
|
|
109
|
+
media: TweetMediaItem[],
|
|
110
|
+
tweetId: string,
|
|
111
|
+
) {
|
|
112
|
+
const mediaUrls = getMediaUrlSet(media);
|
|
113
|
+
if (mediaUrls.size === 0) return entities;
|
|
114
|
+
return {
|
|
115
|
+
...entities,
|
|
116
|
+
urls: (entities.urls ?? []).filter(
|
|
117
|
+
(entry) => !isMediaUrlEntity(entry, mediaUrls, tweetId),
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getHiddenMediaUrlRanges(
|
|
123
|
+
entities: TweetEntities,
|
|
124
|
+
media: TweetMediaItem[],
|
|
125
|
+
tweetId: string,
|
|
126
|
+
) {
|
|
127
|
+
const mediaUrls = getMediaUrlSet(media);
|
|
128
|
+
if (mediaUrls.size === 0) return [];
|
|
129
|
+
return (entities.urls ?? [])
|
|
130
|
+
.filter((entry) => isMediaUrlEntity(entry, mediaUrls, tweetId))
|
|
131
|
+
.map((entry) => ({ start: entry.start, end: entry.end }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getVisibleUrlCards(item: TimelineItem, entities: TweetEntities) {
|
|
39
135
|
const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
|
|
40
|
-
return (
|
|
136
|
+
return (entities.urls ?? []).filter((entry) => {
|
|
41
137
|
if (!item.quotedTweet) return true;
|
|
42
138
|
return !entry.expandedUrl.includes(quotedUrl ?? "");
|
|
43
139
|
});
|
|
@@ -61,56 +157,28 @@ export function TimelineCard({
|
|
|
61
157
|
}) {
|
|
62
158
|
const canReply =
|
|
63
159
|
showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
160
|
+
const conversation = useConversationSurface(item.id);
|
|
161
|
+
const visibleEntities = getVisibleEntities(
|
|
162
|
+
item.entities,
|
|
163
|
+
item.media,
|
|
164
|
+
item.id,
|
|
68
165
|
);
|
|
69
|
-
const
|
|
70
|
-
|
|
166
|
+
const hiddenMediaUrlRanges = getHiddenMediaUrlRanges(
|
|
167
|
+
item.entities,
|
|
168
|
+
item.media,
|
|
169
|
+
item.id,
|
|
71
170
|
);
|
|
72
|
-
|
|
73
|
-
async function loadConversation() {
|
|
74
|
-
if (conversationLoading || conversationItems.length > 0) return;
|
|
75
|
-
setConversationLoading(true);
|
|
76
|
-
setConversationError(null);
|
|
77
|
-
try {
|
|
78
|
-
const response = await fetch(
|
|
79
|
-
`/api/conversation?tweetId=${encodeURIComponent(item.id)}`,
|
|
80
|
-
);
|
|
81
|
-
const data = (await response.json()) as {
|
|
82
|
-
ok?: boolean;
|
|
83
|
-
error?: string;
|
|
84
|
-
items?: EmbeddedTweet[];
|
|
85
|
-
};
|
|
86
|
-
if (!response.ok || data.ok === false) {
|
|
87
|
-
throw new Error(data.error ?? "Conversation unavailable");
|
|
88
|
-
}
|
|
89
|
-
setConversationItems((data.items ?? []).filter(Boolean));
|
|
90
|
-
} catch (error) {
|
|
91
|
-
setConversationError(
|
|
92
|
-
error instanceof Error ? error.message : "Conversation unavailable",
|
|
93
|
-
);
|
|
94
|
-
} finally {
|
|
95
|
-
setConversationLoading(false);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function toggleConversation() {
|
|
100
|
-
const nextOpen = !conversationOpen;
|
|
101
|
-
setConversationOpen(nextOpen);
|
|
102
|
-
if (nextOpen) {
|
|
103
|
-
void loadConversation();
|
|
104
|
-
}
|
|
105
|
-
}
|
|
171
|
+
const hasConversation = Boolean(item.replyToTweet || item.replyToId);
|
|
106
172
|
|
|
107
173
|
return (
|
|
108
174
|
<article
|
|
109
175
|
className={cx(feedRowClass, "cursor-pointer")}
|
|
110
176
|
data-perf="timeline-card"
|
|
177
|
+
onFocus={conversation.prefetch}
|
|
178
|
+
onMouseEnter={conversation.prefetch}
|
|
111
179
|
onClick={(event) => {
|
|
112
180
|
if (isInteractiveTarget(event.target)) return;
|
|
113
|
-
|
|
181
|
+
conversation.toggle();
|
|
114
182
|
}}
|
|
115
183
|
>
|
|
116
184
|
<AvatarChip
|
|
@@ -133,19 +201,47 @@ export function TimelineCard({
|
|
|
133
201
|
<span className={feedRowTimestampClass}>
|
|
134
202
|
{formatShortTimestamp(item.createdAt)}
|
|
135
203
|
</span>
|
|
136
|
-
{canReply ? (
|
|
137
|
-
<span className="ml-auto inline-flex items-center gap-1
|
|
138
|
-
{
|
|
139
|
-
<span
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
204
|
+
{canReply || hasConversation ? (
|
|
205
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
206
|
+
{hasConversation ? (
|
|
207
|
+
<span
|
|
208
|
+
aria-label="Part of a conversation"
|
|
209
|
+
className={cx(
|
|
210
|
+
feedRowStatePillClass,
|
|
211
|
+
feedRowStatePillActiveClass,
|
|
212
|
+
)}
|
|
213
|
+
title="Part of a conversation"
|
|
214
|
+
>
|
|
215
|
+
<MessageCircle className="size-3.5" strokeWidth={2} />
|
|
216
|
+
thread
|
|
217
|
+
</span>
|
|
218
|
+
) : null}
|
|
219
|
+
{canReply ? (
|
|
220
|
+
<span
|
|
221
|
+
aria-label={item.isReplied ? "We replied" : "Reply open"}
|
|
222
|
+
className={cx(
|
|
223
|
+
feedRowStatePillClass,
|
|
224
|
+
item.isReplied
|
|
225
|
+
? feedRowStatePillActiveClass
|
|
226
|
+
: feedRowStatePillOpenClass,
|
|
227
|
+
)}
|
|
228
|
+
title={item.isReplied ? "We replied" : "Reply open"}
|
|
229
|
+
>
|
|
230
|
+
{item.isReplied ? (
|
|
231
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
232
|
+
) : (
|
|
233
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
234
|
+
)}
|
|
235
|
+
{item.isReplied ? "replied" : "open"}
|
|
236
|
+
</span>
|
|
237
|
+
) : null}
|
|
143
238
|
</span>
|
|
144
239
|
) : null}
|
|
145
240
|
</header>
|
|
146
241
|
<TweetRichText
|
|
147
242
|
className={feedRowTextClass}
|
|
148
243
|
entities={item.entities}
|
|
244
|
+
hiddenUrlRanges={hiddenMediaUrlRanges}
|
|
149
245
|
text={item.text}
|
|
150
246
|
/>
|
|
151
247
|
<TweetMediaGrid items={item.media} />
|
|
@@ -159,7 +255,7 @@ export function TimelineCard({
|
|
|
159
255
|
<EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
|
|
160
256
|
</div>
|
|
161
257
|
) : null}
|
|
162
|
-
{getVisibleUrlCards(item).map((entry, index) => (
|
|
258
|
+
{getVisibleUrlCards(item, visibleEntities).map((entry, index) => (
|
|
163
259
|
<LinkPreviewCard
|
|
164
260
|
key={`${entry.expandedUrl}-${String(index)}`}
|
|
165
261
|
entry={entry}
|
|
@@ -169,14 +265,14 @@ export function TimelineCard({
|
|
|
169
265
|
<footer className={feedRowActionsClass}>
|
|
170
266
|
<div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
|
|
171
267
|
<button
|
|
172
|
-
aria-expanded={
|
|
268
|
+
aria-expanded={conversation.isOpen}
|
|
173
269
|
aria-label={
|
|
174
|
-
|
|
270
|
+
conversation.isOpen ? "Hide conversation" : "Show conversation"
|
|
175
271
|
}
|
|
176
272
|
className={feedActionButtonClass}
|
|
177
273
|
onClick={(event) => {
|
|
178
274
|
event.stopPropagation();
|
|
179
|
-
|
|
275
|
+
conversation.toggle();
|
|
180
276
|
}}
|
|
181
277
|
type="button"
|
|
182
278
|
>
|
|
@@ -187,7 +283,7 @@ export function TimelineCard({
|
|
|
187
283
|
/>
|
|
188
284
|
</span>
|
|
189
285
|
<span className="text-[13px]">
|
|
190
|
-
{
|
|
286
|
+
{conversation.isOpen ? "Hide thread" : "Thread"}
|
|
191
287
|
</span>
|
|
192
288
|
</button>
|
|
193
289
|
{canReply ? (
|
|
@@ -257,12 +353,12 @@ export function TimelineCard({
|
|
|
257
353
|
<span>{item.accountHandle}</span>
|
|
258
354
|
</div>
|
|
259
355
|
</footer>
|
|
260
|
-
{
|
|
356
|
+
{conversation.isOpen ? (
|
|
261
357
|
<ConversationThread
|
|
262
358
|
anchorId={item.id}
|
|
263
|
-
error={
|
|
264
|
-
items={
|
|
265
|
-
loading={
|
|
359
|
+
error={conversation.error}
|
|
360
|
+
items={conversation.items}
|
|
361
|
+
loading={conversation.loading}
|
|
266
362
|
/>
|
|
267
363
|
) : null}
|
|
268
364
|
</div>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Search } from "lucide-react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
FeedEmpty,
|
|
5
|
+
FeedError,
|
|
6
|
+
FeedLoading,
|
|
7
|
+
TweetSkeletonRows,
|
|
8
|
+
} from "#/components/FeedState";
|
|
9
|
+
import { SyncNowButton } from "#/components/SyncNowButton";
|
|
10
|
+
import { TimelineCard } from "#/components/TimelineCard";
|
|
11
|
+
import { ConversationSurfaceScope } from "#/lib/conversation-surface";
|
|
12
|
+
import type { QueryEnvelope, ReplyFilter } from "#/lib/types";
|
|
13
|
+
import type { WebSyncKind } from "#/lib/web-sync";
|
|
14
|
+
import {
|
|
15
|
+
cx,
|
|
16
|
+
feedClass,
|
|
17
|
+
pageHeaderClass,
|
|
18
|
+
pageHeaderRowClass,
|
|
19
|
+
pageSubtitleClass,
|
|
20
|
+
pageTitleClass,
|
|
21
|
+
searchFieldIconClass,
|
|
22
|
+
searchFieldInputClass,
|
|
23
|
+
searchFieldShellClass,
|
|
24
|
+
tabButtonActiveClass,
|
|
25
|
+
tabButtonClass,
|
|
26
|
+
tabButtonIndicatorClass,
|
|
27
|
+
tabStripClass,
|
|
28
|
+
} from "#/lib/ui";
|
|
29
|
+
import { useTimelineRouteData } from "./useTimelineRouteData";
|
|
30
|
+
|
|
31
|
+
const TABS: Array<{ value: ReplyFilter; label: string }> = [
|
|
32
|
+
{ value: "all", label: "All" },
|
|
33
|
+
{ value: "unreplied", label: "Unreplied" },
|
|
34
|
+
{ value: "replied", label: "Replied" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
interface TimelineRouteFrameProps {
|
|
38
|
+
title: string;
|
|
39
|
+
resource: "home" | "mentions";
|
|
40
|
+
initialReplyFilter: ReplyFilter;
|
|
41
|
+
searchPlaceholder: string;
|
|
42
|
+
syncKind: WebSyncKind;
|
|
43
|
+
syncLabel: string;
|
|
44
|
+
loadingLabel: string;
|
|
45
|
+
loadingDetail: string;
|
|
46
|
+
errorTitle: string;
|
|
47
|
+
errorFallback: string;
|
|
48
|
+
emptyLabel: string;
|
|
49
|
+
emptyDetail: string;
|
|
50
|
+
subtitle: (meta: QueryEnvelope | null) => string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TimelineRouteFrame({
|
|
54
|
+
title,
|
|
55
|
+
resource,
|
|
56
|
+
initialReplyFilter,
|
|
57
|
+
searchPlaceholder,
|
|
58
|
+
syncKind,
|
|
59
|
+
syncLabel,
|
|
60
|
+
loadingLabel,
|
|
61
|
+
loadingDetail,
|
|
62
|
+
errorTitle,
|
|
63
|
+
errorFallback,
|
|
64
|
+
emptyLabel,
|
|
65
|
+
emptyDetail,
|
|
66
|
+
subtitle,
|
|
67
|
+
}: TimelineRouteFrameProps) {
|
|
68
|
+
const [replyFilter, setReplyFilter] =
|
|
69
|
+
useState<ReplyFilter>(initialReplyFilter);
|
|
70
|
+
const [search, setSearch] = useState("");
|
|
71
|
+
const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
|
|
72
|
+
useTimelineRouteData({
|
|
73
|
+
resource,
|
|
74
|
+
replyFilter,
|
|
75
|
+
search,
|
|
76
|
+
errorFallback,
|
|
77
|
+
});
|
|
78
|
+
const subtitleText = useMemo(() => subtitle(meta), [meta, subtitle]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<header className={pageHeaderClass}>
|
|
83
|
+
<div className={pageHeaderRowClass}>
|
|
84
|
+
<div className="flex min-w-0 flex-col">
|
|
85
|
+
<h1 className={pageTitleClass}>{title}</h1>
|
|
86
|
+
<p className={pageSubtitleClass}>{subtitleText}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<SyncNowButton
|
|
89
|
+
accounts={syncKind === "mentions" ? meta?.accounts : undefined}
|
|
90
|
+
kind={syncKind}
|
|
91
|
+
label={syncLabel}
|
|
92
|
+
onSynced={refreshLocalView}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="px-4 pb-3">
|
|
96
|
+
<label className={searchFieldShellClass}>
|
|
97
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
98
|
+
<input
|
|
99
|
+
className={searchFieldInputClass}
|
|
100
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
101
|
+
placeholder={searchPlaceholder}
|
|
102
|
+
value={search}
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
</div>
|
|
106
|
+
<div className={tabStripClass}>
|
|
107
|
+
{TABS.map((tab) => {
|
|
108
|
+
const active = replyFilter === tab.value;
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
key={tab.value}
|
|
112
|
+
type="button"
|
|
113
|
+
aria-pressed={active}
|
|
114
|
+
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
115
|
+
onClick={() => setReplyFilter(tab.value)}
|
|
116
|
+
>
|
|
117
|
+
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
118
|
+
{tab.label}
|
|
119
|
+
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
120
|
+
</span>
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
</header>
|
|
126
|
+
<ConversationSurfaceScope>
|
|
127
|
+
<section className={feedClass}>
|
|
128
|
+
{loading ? (
|
|
129
|
+
<FeedLoading detail={loadingDetail} label={loadingLabel}>
|
|
130
|
+
<TweetSkeletonRows />
|
|
131
|
+
</FeedLoading>
|
|
132
|
+
) : error ? (
|
|
133
|
+
<FeedError
|
|
134
|
+
action={
|
|
135
|
+
<button
|
|
136
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
137
|
+
onClick={retry}
|
|
138
|
+
type="button"
|
|
139
|
+
>
|
|
140
|
+
Retry
|
|
141
|
+
</button>
|
|
142
|
+
}
|
|
143
|
+
message={error}
|
|
144
|
+
title={errorTitle}
|
|
145
|
+
/>
|
|
146
|
+
) : items.length === 0 ? (
|
|
147
|
+
<FeedEmpty detail={emptyDetail} label={emptyLabel} />
|
|
148
|
+
) : null}
|
|
149
|
+
{items.map((item) => (
|
|
150
|
+
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
151
|
+
))}
|
|
152
|
+
</section>
|
|
153
|
+
</ConversationSurfaceScope>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -1,39 +1,136 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
1
3
|
import type { TweetMediaItem } from "#/lib/types";
|
|
2
4
|
import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
3
5
|
|
|
4
6
|
export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
7
|
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
5
8
|
if (items.length === 0) {
|
|
6
9
|
return null;
|
|
7
10
|
}
|
|
8
11
|
|
|
12
|
+
const visibleItems = items.slice(0, 4);
|
|
13
|
+
const selectedItem =
|
|
14
|
+
selectedIndex === null ? null : (visibleItems[selectedIndex] ?? null);
|
|
15
|
+
const selectedVideoUrl =
|
|
16
|
+
selectedItem?.type === "video" || selectedItem?.type === "gif"
|
|
17
|
+
? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
|
|
18
|
+
: null;
|
|
19
|
+
|
|
9
20
|
return (
|
|
10
|
-
|
|
11
|
-
{items.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
<>
|
|
22
|
+
<div className={tweetMediaGridClass(Math.min(items.length, 4))}>
|
|
23
|
+
{visibleItems.map((item, index) => (
|
|
24
|
+
<button
|
|
25
|
+
key={item.url + String(index)}
|
|
26
|
+
aria-label={`Open tweet media ${String(index + 1)}`}
|
|
27
|
+
className={tweetMediaTileClass(index, Math.min(items.length, 4))}
|
|
28
|
+
onClick={(event) => {
|
|
29
|
+
event.stopPropagation();
|
|
30
|
+
setSelectedIndex(index);
|
|
31
|
+
}}
|
|
32
|
+
style={
|
|
33
|
+
visibleItems.length === 1 && item.width && item.height
|
|
34
|
+
? {
|
|
35
|
+
aspectRatio: `${String(item.width)} / ${String(item.height)}`,
|
|
36
|
+
}
|
|
37
|
+
: undefined
|
|
38
|
+
}
|
|
39
|
+
type="button"
|
|
40
|
+
>
|
|
41
|
+
{item.type === "image" ? (
|
|
42
|
+
<img
|
|
43
|
+
alt={item.altText ?? `Tweet media ${String(index + 1)}`}
|
|
44
|
+
className="tweet-media-image block size-full object-contain"
|
|
45
|
+
loading="lazy"
|
|
46
|
+
src={item.thumbnailUrl ?? item.url}
|
|
47
|
+
/>
|
|
48
|
+
) : (
|
|
49
|
+
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
50
|
+
{item.type === "video"
|
|
51
|
+
? "Video"
|
|
52
|
+
: item.type === "gif"
|
|
53
|
+
? "GIF"
|
|
54
|
+
: "Media"}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
</button>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
{selectedItem ? (
|
|
61
|
+
<div
|
|
62
|
+
aria-modal="true"
|
|
63
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
|
|
64
|
+
onClick={(event) => {
|
|
65
|
+
event.stopPropagation();
|
|
66
|
+
setSelectedIndex(null);
|
|
67
|
+
}}
|
|
68
|
+
role="dialog"
|
|
18
69
|
>
|
|
19
|
-
|
|
70
|
+
<button
|
|
71
|
+
aria-label="Close media viewer"
|
|
72
|
+
className="absolute right-4 top-4 grid size-10 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
|
73
|
+
onClick={(event) => {
|
|
74
|
+
event.stopPropagation();
|
|
75
|
+
setSelectedIndex(null);
|
|
76
|
+
}}
|
|
77
|
+
type="button"
|
|
78
|
+
>
|
|
79
|
+
<X className="size-5" strokeWidth={1.8} />
|
|
80
|
+
</button>
|
|
81
|
+
{selectedItem.type === "image" ? (
|
|
20
82
|
<img
|
|
21
|
-
alt={
|
|
22
|
-
className="
|
|
23
|
-
|
|
24
|
-
src={
|
|
83
|
+
alt={selectedItem.altText ?? "Tweet media"}
|
|
84
|
+
className="max-h-[92vh] max-w-[92vw] object-contain"
|
|
85
|
+
onClick={(event) => event.stopPropagation()}
|
|
86
|
+
src={selectedItem.url}
|
|
87
|
+
/>
|
|
88
|
+
) : selectedVideoUrl ? (
|
|
89
|
+
<video
|
|
90
|
+
autoPlay={selectedItem.type === "gif"}
|
|
91
|
+
className="max-h-[92vh] max-w-[92vw]"
|
|
92
|
+
controls
|
|
93
|
+
loop={selectedItem.type === "gif"}
|
|
94
|
+
muted={selectedItem.type === "gif"}
|
|
95
|
+
onClick={(event) => event.stopPropagation()}
|
|
96
|
+
playsInline
|
|
97
|
+
poster={selectedItem.thumbnailUrl}
|
|
98
|
+
src={selectedVideoUrl}
|
|
25
99
|
/>
|
|
26
100
|
) : (
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
101
|
+
<div
|
|
102
|
+
className="grid min-h-64 min-w-80 place-items-center gap-3 rounded-2xl border border-white/20 bg-black p-6 text-white"
|
|
103
|
+
onClick={(event) => event.stopPropagation()}
|
|
104
|
+
>
|
|
105
|
+
<span>
|
|
106
|
+
{selectedItem.type === "video"
|
|
107
|
+
? "Video"
|
|
108
|
+
: selectedItem.type === "gif"
|
|
109
|
+
? "GIF"
|
|
110
|
+
: "Media"}
|
|
111
|
+
</span>
|
|
112
|
+
<a
|
|
113
|
+
className="rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-white/20"
|
|
114
|
+
href={selectedItem.url}
|
|
115
|
+
rel="noreferrer"
|
|
116
|
+
target="_blank"
|
|
117
|
+
>
|
|
118
|
+
Open media
|
|
119
|
+
</a>
|
|
120
|
+
</div>
|
|
34
121
|
)}
|
|
35
|
-
</
|
|
36
|
-
)
|
|
37
|
-
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</>
|
|
38
125
|
);
|
|
39
126
|
}
|
|
127
|
+
|
|
128
|
+
function playableVideoUrl(url: string) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = new URL(url);
|
|
131
|
+
if (parsed.hostname === "video.twimg.com") return url;
|
|
132
|
+
return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(parsed.pathname) ? url : undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(url) ? url : undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import {
|
|
3
4
|
collectTweetSegments,
|
|
4
5
|
enrichFallbackUrlEntities,
|
|
@@ -16,10 +17,12 @@ export function TweetRichText({
|
|
|
16
17
|
text,
|
|
17
18
|
entities,
|
|
18
19
|
className = "body-copy",
|
|
20
|
+
hiddenUrlRanges = [],
|
|
19
21
|
}: {
|
|
20
22
|
text: string;
|
|
21
23
|
entities: TweetEntities;
|
|
22
24
|
className?: string;
|
|
25
|
+
hiddenUrlRanges?: Array<{ start: number; end: number }>;
|
|
23
26
|
}) {
|
|
24
27
|
const richEntities = enrichFallbackUrlEntities(text, entities);
|
|
25
28
|
const segments = collectTweetSegments(richEntities);
|
|
@@ -39,12 +42,20 @@ export function TweetRichText({
|
|
|
39
42
|
const prefix = text.slice(cursor, segment.start);
|
|
40
43
|
cursor = segment.end;
|
|
41
44
|
|
|
42
|
-
let node = (
|
|
45
|
+
let node: ReactNode = (
|
|
43
46
|
<Fragment key={`segment-${String(index)}`}>
|
|
44
47
|
{text.slice(segment.start, segment.end)}
|
|
45
48
|
</Fragment>
|
|
46
49
|
);
|
|
47
|
-
if (
|
|
50
|
+
if (
|
|
51
|
+
segment.kind === "url" &&
|
|
52
|
+
hiddenUrlRanges.some(
|
|
53
|
+
(range) =>
|
|
54
|
+
range.start === segment.start && range.end === segment.end,
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
node = null;
|
|
58
|
+
} else if (segment.kind === "mention" && segment.profile) {
|
|
48
59
|
node = (
|
|
49
60
|
<ProfilePreview
|
|
50
61
|
key={`segment-${String(index)}`}
|