birdclaw 0.5.0 → 0.6.0
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 +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -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 +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +205 -0
- package/src/lib/db.ts +35 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +1024 -189
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
1
|
import {
|
|
3
|
-
Bookmark,
|
|
4
2
|
BookmarkCheck,
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
Circle,
|
|
5
5
|
Heart,
|
|
6
|
+
Image,
|
|
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,
|
|
14
21
|
feedActionButtonClass,
|
|
15
22
|
feedActionIconClass,
|
|
16
23
|
feedActionIconWrapClass,
|
|
17
|
-
feedActionIconWrapLikeClass,
|
|
18
|
-
feedActionLikeClass,
|
|
19
24
|
feedRowActionsClass,
|
|
20
25
|
feedRowBodyClass,
|
|
21
26
|
feedRowClass,
|
|
@@ -23,9 +28,11 @@ import {
|
|
|
23
28
|
feedRowHandleClass,
|
|
24
29
|
feedRowHeaderClass,
|
|
25
30
|
feedRowNameClass,
|
|
31
|
+
feedRowStatePillActiveClass,
|
|
32
|
+
feedRowStatePillClass,
|
|
33
|
+
feedRowStatePillOpenClass,
|
|
26
34
|
feedRowTextClass,
|
|
27
35
|
feedRowTimestampClass,
|
|
28
|
-
mutedDotClass,
|
|
29
36
|
} from "#/lib/ui";
|
|
30
37
|
import { AvatarChip } from "./AvatarChip";
|
|
31
38
|
import { ConversationThread } from "./ConversationThread";
|
|
@@ -35,11 +42,176 @@ import { ProfilePreview } from "./ProfilePreview";
|
|
|
35
42
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
36
43
|
import { TweetRichText } from "./TweetRichText";
|
|
37
44
|
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
45
|
+
function comparableUrl(value: string | null | undefined) {
|
|
46
|
+
if (!value) return null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(value);
|
|
49
|
+
return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`;
|
|
50
|
+
} catch {
|
|
51
|
+
return value.split("?")[0] ?? value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getMediaUrlSet(media: TweetMediaItem[]) {
|
|
56
|
+
const urls = new Set<string>();
|
|
57
|
+
for (const item of media) {
|
|
58
|
+
for (const url of [item.url, item.thumbnailUrl]) {
|
|
59
|
+
const comparable = comparableUrl(url);
|
|
60
|
+
if (comparable) urls.add(comparable);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return urls;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isMediaUrlEntity(
|
|
67
|
+
entry: TweetUrlEntity,
|
|
68
|
+
mediaUrls: Set<string>,
|
|
69
|
+
tweetId: string,
|
|
70
|
+
) {
|
|
71
|
+
if (mediaUrls.size > 0 && isOwnStatusMediaUrl(entry.expandedUrl, tweetId)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
for (const url of [entry.url, entry.expandedUrl, entry.displayUrl]) {
|
|
75
|
+
const comparable = comparableUrl(url);
|
|
76
|
+
if (comparable && mediaUrls.has(comparable)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isShortUrl(value: string | null | undefined) {
|
|
84
|
+
if (!value) return false;
|
|
85
|
+
try {
|
|
86
|
+
const candidate = value.includes("://") ? value : `https://${value}`;
|
|
87
|
+
const parsed = new URL(candidate);
|
|
88
|
+
return parsed.hostname.replace(/^www\./, "") === "t.co";
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isUnresolvedShortUrlEntity(entry: TweetUrlEntity) {
|
|
95
|
+
if (isShortUrl(entry.expandedUrl)) return true;
|
|
96
|
+
if (entry.expandedUrl) return false;
|
|
97
|
+
if (isShortUrl(entry.displayUrl)) return true;
|
|
98
|
+
return !entry.displayUrl && isShortUrl(entry.url);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unresolvedShortUrlRanges(entities: TweetEntities) {
|
|
102
|
+
return (entities.urls ?? [])
|
|
103
|
+
.filter(isUnresolvedShortUrlEntity)
|
|
104
|
+
.map((entry) => ({ start: entry.start, end: entry.end }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function textOutsideRanges(
|
|
108
|
+
text: string,
|
|
109
|
+
ranges: Array<{ start: number; end: number }>,
|
|
110
|
+
) {
|
|
111
|
+
let cursor = 0;
|
|
112
|
+
let output = "";
|
|
113
|
+
for (const range of [...ranges].sort(
|
|
114
|
+
(left, right) => left.start - right.start,
|
|
115
|
+
)) {
|
|
116
|
+
if (
|
|
117
|
+
range.start < cursor ||
|
|
118
|
+
range.end <= range.start ||
|
|
119
|
+
range.end > text.length
|
|
120
|
+
) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
output += text.slice(cursor, range.start);
|
|
124
|
+
cursor = range.end;
|
|
125
|
+
}
|
|
126
|
+
output += text.slice(cursor);
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function shouldHideUnresolvedShortUrls(
|
|
131
|
+
text: string,
|
|
132
|
+
entities: TweetEntities,
|
|
133
|
+
mediaUrls: Set<string>,
|
|
134
|
+
) {
|
|
135
|
+
if (mediaUrls.size === 0) return false;
|
|
136
|
+
const ranges = unresolvedShortUrlRanges(entities);
|
|
137
|
+
if (ranges.length === 0) return false;
|
|
138
|
+
return textOutsideRanges(text, ranges).trim().length === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isOwnStatusMediaUrl(
|
|
142
|
+
value: string | null | undefined,
|
|
143
|
+
tweetId: string,
|
|
144
|
+
) {
|
|
145
|
+
if (!value) return false;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = new URL(value);
|
|
148
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
149
|
+
if (host !== "x.com" && host !== "twitter.com") return false;
|
|
150
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
151
|
+
const statusIndex = segments.indexOf("status");
|
|
152
|
+
if (statusIndex < 0 || segments[statusIndex + 1] !== tweetId) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const mediaSegment = segments[statusIndex + 2];
|
|
156
|
+
return mediaSegment === "photo" || mediaSegment === "video";
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getVisibleEntities(
|
|
163
|
+
entities: TweetEntities,
|
|
164
|
+
media: TweetMediaItem[],
|
|
165
|
+
tweetId: string,
|
|
166
|
+
text: string,
|
|
167
|
+
) {
|
|
168
|
+
const mediaUrls = getMediaUrlSet(media);
|
|
169
|
+
if (mediaUrls.size === 0) return entities;
|
|
170
|
+
const hideUnresolvedShortUrls = shouldHideUnresolvedShortUrls(
|
|
171
|
+
text,
|
|
172
|
+
entities,
|
|
173
|
+
mediaUrls,
|
|
174
|
+
);
|
|
175
|
+
return {
|
|
176
|
+
...entities,
|
|
177
|
+
urls: (entities.urls ?? []).filter(
|
|
178
|
+
(entry) =>
|
|
179
|
+
!isMediaUrlEntity(entry, mediaUrls, tweetId) &&
|
|
180
|
+
!(hideUnresolvedShortUrls && isUnresolvedShortUrlEntity(entry)),
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getHiddenMediaUrlRanges(
|
|
186
|
+
entities: TweetEntities,
|
|
187
|
+
media: TweetMediaItem[],
|
|
188
|
+
tweetId: string,
|
|
189
|
+
text: string,
|
|
190
|
+
) {
|
|
191
|
+
const mediaUrls = getMediaUrlSet(media);
|
|
192
|
+
if (mediaUrls.size === 0) return [];
|
|
193
|
+
const hideUnresolvedShortUrls = shouldHideUnresolvedShortUrls(
|
|
194
|
+
text,
|
|
195
|
+
entities,
|
|
196
|
+
mediaUrls,
|
|
197
|
+
);
|
|
198
|
+
return (entities.urls ?? [])
|
|
199
|
+
.filter(
|
|
200
|
+
(entry) =>
|
|
201
|
+
isMediaUrlEntity(entry, mediaUrls, tweetId) ||
|
|
202
|
+
(hideUnresolvedShortUrls && isUnresolvedShortUrlEntity(entry)),
|
|
203
|
+
)
|
|
204
|
+
.map((entry) => ({ start: entry.start, end: entry.end }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getVisibleUrlCards(
|
|
208
|
+
entities: TweetEntities,
|
|
209
|
+
quotedTweetId: string | null,
|
|
210
|
+
) {
|
|
211
|
+
return (entities.urls ?? []).filter((entry) => {
|
|
212
|
+
if (isUnresolvedShortUrlEntity(entry)) return false;
|
|
213
|
+
if (!quotedTweetId) return true;
|
|
214
|
+
return !entry.expandedUrl.includes(quotedTweetId);
|
|
43
215
|
});
|
|
44
216
|
}
|
|
45
217
|
|
|
@@ -61,122 +233,185 @@ export function TimelineCard({
|
|
|
61
233
|
}) {
|
|
62
234
|
const canReply =
|
|
63
235
|
showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
236
|
+
const displayTweet = item.retweetedTweet ?? item;
|
|
237
|
+
const displayTweetId = displayTweet.id;
|
|
238
|
+
const interactionTweetId =
|
|
239
|
+
item.retweetedTweet && displayTweetId === `${item.id}:retweeted`
|
|
240
|
+
? item.id
|
|
241
|
+
: displayTweetId;
|
|
242
|
+
const displayAuthor = displayTweet.author;
|
|
243
|
+
const conversation = useConversationSurface(item.id, interactionTweetId);
|
|
244
|
+
const visibleEntities = getVisibleEntities(
|
|
245
|
+
displayTweet.entities,
|
|
246
|
+
displayTweet.media,
|
|
247
|
+
displayTweet.id,
|
|
248
|
+
displayTweet.text,
|
|
68
249
|
);
|
|
69
|
-
const
|
|
70
|
-
|
|
250
|
+
const hiddenMediaUrlRanges = getHiddenMediaUrlRanges(
|
|
251
|
+
displayTweet.entities,
|
|
252
|
+
displayTweet.media,
|
|
253
|
+
displayTweet.id,
|
|
254
|
+
displayTweet.text,
|
|
255
|
+
);
|
|
256
|
+
const visibleUrlCards = getVisibleUrlCards(
|
|
257
|
+
visibleEntities,
|
|
258
|
+
item.retweetedTweet ? null : (item.quotedTweet?.id ?? null),
|
|
259
|
+
);
|
|
260
|
+
const displayMediaCount = item.retweetedTweet
|
|
261
|
+
? (displayTweet.mediaCount ?? displayTweet.media.length)
|
|
262
|
+
: item.mediaCount;
|
|
263
|
+
const displayIsReplied = displayTweet.isReplied ?? item.isReplied;
|
|
264
|
+
const displayLikeCount = displayTweet.likeCount ?? item.likeCount;
|
|
265
|
+
const displayBookmarked = displayTweet.bookmarked ?? item.bookmarked;
|
|
266
|
+
const displayLiked = displayTweet.liked ?? item.liked;
|
|
267
|
+
const showLikeIndicator = displayLiked || displayLikeCount > 0;
|
|
268
|
+
const showMediaIndicator = displayMediaCount > 0;
|
|
269
|
+
const hasConversation = Boolean(
|
|
270
|
+
item.retweetedTweet
|
|
271
|
+
? displayTweet.replyToId
|
|
272
|
+
: item.replyToTweet || item.replyToId,
|
|
71
273
|
);
|
|
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
|
-
}
|
|
106
274
|
|
|
107
275
|
return (
|
|
108
276
|
<article
|
|
109
277
|
className={cx(feedRowClass, "cursor-pointer")}
|
|
110
278
|
data-perf="timeline-card"
|
|
279
|
+
onFocus={conversation.prefetch}
|
|
280
|
+
onMouseEnter={conversation.prefetch}
|
|
111
281
|
onClick={(event) => {
|
|
112
282
|
if (isInteractiveTarget(event.target)) return;
|
|
113
|
-
|
|
283
|
+
conversation.toggle();
|
|
114
284
|
}}
|
|
115
285
|
>
|
|
116
286
|
<AvatarChip
|
|
117
|
-
avatarUrl={
|
|
118
|
-
hue={
|
|
119
|
-
name={
|
|
120
|
-
profileId={
|
|
287
|
+
avatarUrl={displayAuthor.avatarUrl}
|
|
288
|
+
hue={displayAuthor.avatarHue}
|
|
289
|
+
name={displayAuthor.displayName}
|
|
290
|
+
profileId={displayAuthor.id}
|
|
121
291
|
/>
|
|
122
292
|
<div className={feedRowBodyClass}>
|
|
293
|
+
{item.retweetedTweet ? (
|
|
294
|
+
<div className="inline-flex items-center gap-2 text-[13px] font-medium text-[var(--ink-soft)]">
|
|
295
|
+
<Repeat2 className="size-4" strokeWidth={1.8} />
|
|
296
|
+
<ProfilePreview profile={item.author}>
|
|
297
|
+
<span>{item.author.displayName} reposted</span>
|
|
298
|
+
</ProfilePreview>
|
|
299
|
+
</div>
|
|
300
|
+
) : null}
|
|
123
301
|
<header className={feedRowHeaderClass}>
|
|
124
|
-
<ProfilePreview profile={
|
|
302
|
+
<ProfilePreview profile={displayAuthor}>
|
|
125
303
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
126
304
|
<span className={feedRowNameClass}>
|
|
127
|
-
{
|
|
305
|
+
{displayAuthor.displayName}
|
|
306
|
+
</span>
|
|
307
|
+
<span className={feedRowHandleClass}>
|
|
308
|
+
@{displayAuthor.handle}
|
|
128
309
|
</span>
|
|
129
|
-
<span className={feedRowHandleClass}>@{item.author.handle}</span>
|
|
130
310
|
</span>
|
|
131
311
|
</ProfilePreview>
|
|
132
312
|
<span className={feedRowDotClass}>·</span>
|
|
133
313
|
<span className={feedRowTimestampClass}>
|
|
134
|
-
{formatShortTimestamp(
|
|
314
|
+
{formatShortTimestamp(displayTweet.createdAt)}
|
|
135
315
|
</span>
|
|
136
|
-
{canReply ? (
|
|
137
|
-
<span className="ml-auto inline-flex items-center gap-1
|
|
138
|
-
{
|
|
139
|
-
<span
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
316
|
+
{canReply || hasConversation ? (
|
|
317
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
318
|
+
{hasConversation ? (
|
|
319
|
+
<span
|
|
320
|
+
aria-label="Part of a conversation"
|
|
321
|
+
className={cx(
|
|
322
|
+
feedRowStatePillClass,
|
|
323
|
+
feedRowStatePillActiveClass,
|
|
324
|
+
)}
|
|
325
|
+
title="Part of a conversation"
|
|
326
|
+
>
|
|
327
|
+
<MessageCircle className="size-3.5" strokeWidth={2} />
|
|
328
|
+
thread
|
|
329
|
+
</span>
|
|
330
|
+
) : null}
|
|
331
|
+
{canReply ? (
|
|
332
|
+
<span
|
|
333
|
+
aria-label={displayIsReplied ? "We replied" : "Reply open"}
|
|
334
|
+
className={cx(
|
|
335
|
+
feedRowStatePillClass,
|
|
336
|
+
displayIsReplied
|
|
337
|
+
? feedRowStatePillActiveClass
|
|
338
|
+
: feedRowStatePillOpenClass,
|
|
339
|
+
)}
|
|
340
|
+
title={displayIsReplied ? "We replied" : "Reply open"}
|
|
341
|
+
>
|
|
342
|
+
{displayIsReplied ? (
|
|
343
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
344
|
+
) : (
|
|
345
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
346
|
+
)}
|
|
347
|
+
{displayIsReplied ? "replied" : "open"}
|
|
348
|
+
</span>
|
|
349
|
+
) : null}
|
|
143
350
|
</span>
|
|
144
351
|
) : null}
|
|
145
352
|
</header>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
353
|
+
{item.retweetedTweet ? (
|
|
354
|
+
<>
|
|
355
|
+
<TweetRichText
|
|
356
|
+
className={feedRowTextClass}
|
|
357
|
+
entities={displayTweet.entities}
|
|
358
|
+
hiddenUrlRanges={hiddenMediaUrlRanges}
|
|
359
|
+
text={displayTweet.text}
|
|
360
|
+
/>
|
|
361
|
+
<TweetMediaGrid items={displayTweet.media} />
|
|
362
|
+
{visibleUrlCards.map((entry, index) => (
|
|
363
|
+
<LinkPreviewCard
|
|
364
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
365
|
+
entry={entry}
|
|
366
|
+
index={index}
|
|
367
|
+
/>
|
|
368
|
+
))}
|
|
369
|
+
</>
|
|
370
|
+
) : (
|
|
371
|
+
<>
|
|
372
|
+
<TweetRichText
|
|
373
|
+
className={feedRowTextClass}
|
|
374
|
+
entities={item.entities}
|
|
375
|
+
hiddenUrlRanges={hiddenMediaUrlRanges}
|
|
376
|
+
text={item.text}
|
|
377
|
+
/>
|
|
378
|
+
<TweetMediaGrid items={item.media} />
|
|
379
|
+
{item.replyToTweet ? (
|
|
380
|
+
<div className={embeddedCardClass}>
|
|
381
|
+
<EmbeddedTweetCard
|
|
382
|
+
item={item.replyToTweet}
|
|
383
|
+
label="In reply to"
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
) : null}
|
|
387
|
+
{item.quotedTweet ? (
|
|
388
|
+
<div className={embeddedCardClass}>
|
|
389
|
+
<EmbeddedTweetCard
|
|
390
|
+
item={item.quotedTweet}
|
|
391
|
+
label="Quoted tweet"
|
|
392
|
+
/>
|
|
393
|
+
</div>
|
|
394
|
+
) : null}
|
|
395
|
+
{visibleUrlCards.map((entry, index) => (
|
|
396
|
+
<LinkPreviewCard
|
|
397
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
398
|
+
entry={entry}
|
|
399
|
+
index={index}
|
|
400
|
+
/>
|
|
401
|
+
))}
|
|
402
|
+
</>
|
|
403
|
+
)}
|
|
169
404
|
<footer className={feedRowActionsClass}>
|
|
170
405
|
<div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
|
|
171
406
|
<button
|
|
172
|
-
aria-expanded={
|
|
407
|
+
aria-expanded={conversation.isOpen}
|
|
173
408
|
aria-label={
|
|
174
|
-
|
|
409
|
+
conversation.isOpen ? "Hide conversation" : "Show conversation"
|
|
175
410
|
}
|
|
176
411
|
className={feedActionButtonClass}
|
|
177
412
|
onClick={(event) => {
|
|
178
413
|
event.stopPropagation();
|
|
179
|
-
|
|
414
|
+
conversation.toggle();
|
|
180
415
|
}}
|
|
181
416
|
type="button"
|
|
182
417
|
>
|
|
@@ -187,7 +422,7 @@ export function TimelineCard({
|
|
|
187
422
|
/>
|
|
188
423
|
</span>
|
|
189
424
|
<span className="text-[13px]">
|
|
190
|
-
{
|
|
425
|
+
{conversation.isOpen ? "Hide thread" : "Thread"}
|
|
191
426
|
</span>
|
|
192
427
|
</button>
|
|
193
428
|
{canReply ? (
|
|
@@ -195,7 +430,7 @@ export function TimelineCard({
|
|
|
195
430
|
className={feedActionButtonClass}
|
|
196
431
|
onClick={(event) => {
|
|
197
432
|
event.stopPropagation();
|
|
198
|
-
onReply(
|
|
433
|
+
onReply(interactionTweetId);
|
|
199
434
|
}}
|
|
200
435
|
type="button"
|
|
201
436
|
aria-label="Reply"
|
|
@@ -209,60 +444,53 @@ export function TimelineCard({
|
|
|
209
444
|
<span className="text-[13px]">Reply</span>
|
|
210
445
|
</button>
|
|
211
446
|
) : null}
|
|
212
|
-
|
|
213
|
-
<span className={feedActionIconWrapClass}>
|
|
214
|
-
<Repeat2 className={feedActionIconClass} strokeWidth={1.7} />
|
|
215
|
-
</span>
|
|
216
|
-
</span>
|
|
217
|
-
<span
|
|
218
|
-
className={cx(
|
|
219
|
-
feedActionButtonClass,
|
|
220
|
-
feedActionLikeClass,
|
|
221
|
-
"cursor-default",
|
|
222
|
-
item.liked && "text-[var(--like)]",
|
|
223
|
-
)}
|
|
224
|
-
>
|
|
447
|
+
{showLikeIndicator ? (
|
|
225
448
|
<span
|
|
449
|
+
aria-label={`${formatCompactNumber(displayLikeCount)} likes`}
|
|
226
450
|
className={cx(
|
|
227
|
-
|
|
228
|
-
|
|
451
|
+
"inline-flex items-center gap-1 px-2 py-1 text-[13px]",
|
|
452
|
+
displayLiked && "text-[var(--like)]",
|
|
229
453
|
)}
|
|
454
|
+
title={`${formatCompactNumber(displayLikeCount)} likes`}
|
|
230
455
|
>
|
|
231
456
|
<Heart
|
|
232
457
|
className={feedActionIconClass}
|
|
233
458
|
strokeWidth={1.7}
|
|
234
|
-
fill={
|
|
459
|
+
fill={displayLiked ? "currentColor" : "none"}
|
|
235
460
|
/>
|
|
461
|
+
<span>{formatCompactNumber(displayLikeCount)}</span>
|
|
236
462
|
</span>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)}
|
|
463
|
+
) : null}
|
|
464
|
+
{displayBookmarked ? (
|
|
465
|
+
<span
|
|
466
|
+
aria-label="Bookmarked"
|
|
467
|
+
className="inline-flex items-center px-2 py-1"
|
|
468
|
+
title="Bookmarked"
|
|
469
|
+
>
|
|
470
|
+
<BookmarkCheck
|
|
471
|
+
className={feedActionIconClass}
|
|
472
|
+
strokeWidth={1.7}
|
|
473
|
+
/>
|
|
249
474
|
</span>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
475
|
+
) : null}
|
|
476
|
+
{showMediaIndicator ? (
|
|
477
|
+
<span
|
|
478
|
+
aria-label={`${String(displayMediaCount)} media attachments`}
|
|
479
|
+
className="inline-flex items-center gap-1 px-2 py-1 text-[13px]"
|
|
480
|
+
title={`${String(displayMediaCount)} media attachments`}
|
|
481
|
+
>
|
|
482
|
+
<Image className={feedActionIconClass} strokeWidth={1.7} />
|
|
483
|
+
<span>{displayMediaCount}</span>
|
|
484
|
+
</span>
|
|
485
|
+
) : null}
|
|
258
486
|
</div>
|
|
259
487
|
</footer>
|
|
260
|
-
{
|
|
488
|
+
{conversation.isOpen ? (
|
|
261
489
|
<ConversationThread
|
|
262
|
-
anchorId={
|
|
263
|
-
error={
|
|
264
|
-
items={
|
|
265
|
-
loading={
|
|
490
|
+
anchorId={interactionTweetId}
|
|
491
|
+
error={conversation.error}
|
|
492
|
+
items={conversation.items}
|
|
493
|
+
loading={conversation.loading}
|
|
266
494
|
/>
|
|
267
495
|
) : null}
|
|
268
496
|
</div>
|