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.
Files changed (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. 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 { EmbeddedTweet, TimelineItem } from "#/lib/types";
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 getVisibleUrlCards(item: TimelineItem) {
39
- const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
40
- return (item.entities.urls ?? []).filter((entry) => {
41
- if (!item.quotedTweet) return true;
42
- return !entry.expandedUrl.includes(quotedUrl ?? "");
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 [conversationOpen, setConversationOpen] = useState(false);
65
- const [conversationLoading, setConversationLoading] = useState(false);
66
- const [conversationError, setConversationError] = useState<string | null>(
67
- null,
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 [conversationItems, setConversationItems] = useState<EmbeddedTweet[]>(
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
- toggleConversation();
283
+ conversation.toggle();
114
284
  }}
115
285
  >
116
286
  <AvatarChip
117
- avatarUrl={item.author.avatarUrl}
118
- hue={item.author.avatarHue}
119
- name={item.author.displayName}
120
- profileId={item.author.id}
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={item.author}>
302
+ <ProfilePreview profile={displayAuthor}>
125
303
  <span className="flex min-w-0 items-center gap-1.5">
126
304
  <span className={feedRowNameClass}>
127
- {item.author.displayName}
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(item.createdAt)}
314
+ {formatShortTimestamp(displayTweet.createdAt)}
135
315
  </span>
136
- {canReply ? (
137
- <span className="ml-auto inline-flex items-center gap-1 text-[12px] text-[var(--ink-soft)]">
138
- {item.isReplied ? (
139
- <span className="text-[var(--accent)]">replied</span>
140
- ) : (
141
- <span className="text-[var(--alert)]">needs reply</span>
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
- <TweetRichText
147
- className={feedRowTextClass}
148
- entities={item.entities}
149
- text={item.text}
150
- />
151
- <TweetMediaGrid items={item.media} />
152
- {item.replyToTweet ? (
153
- <div className={embeddedCardClass}>
154
- <EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
155
- </div>
156
- ) : null}
157
- {item.quotedTweet ? (
158
- <div className={embeddedCardClass}>
159
- <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
160
- </div>
161
- ) : null}
162
- {getVisibleUrlCards(item).map((entry, index) => (
163
- <LinkPreviewCard
164
- key={`${entry.expandedUrl}-${String(index)}`}
165
- entry={entry}
166
- index={index}
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={conversationOpen}
407
+ aria-expanded={conversation.isOpen}
173
408
  aria-label={
174
- conversationOpen ? "Hide conversation" : "Show conversation"
409
+ conversation.isOpen ? "Hide conversation" : "Show conversation"
175
410
  }
176
411
  className={feedActionButtonClass}
177
412
  onClick={(event) => {
178
413
  event.stopPropagation();
179
- toggleConversation();
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
- {conversationOpen ? "Hide thread" : "Thread"}
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(item.id);
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
- <span className={cx(feedActionButtonClass, "cursor-default")}>
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
- feedActionIconWrapClass,
228
- feedActionIconWrapLikeClass,
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={item.liked ? "currentColor" : "none"}
459
+ fill={displayLiked ? "currentColor" : "none"}
235
460
  />
461
+ <span>{formatCompactNumber(displayLikeCount)}</span>
236
462
  </span>
237
- <span>{formatCompactNumber(item.likeCount)}</span>
238
- </span>
239
- <span className={cx(feedActionButtonClass, "cursor-default")}>
240
- <span className={feedActionIconWrapClass}>
241
- {item.bookmarked ? (
242
- <BookmarkCheck
243
- className={feedActionIconClass}
244
- strokeWidth={1.7}
245
- />
246
- ) : (
247
- <Bookmark className={feedActionIconClass} strokeWidth={1.7} />
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
- </span>
251
- </div>
252
- <div className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
253
- <span>{item.mediaCount} media</span>
254
- <span className={mutedDotClass} />
255
- <span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
256
- <span className={mutedDotClass} />
257
- <span>{item.accountHandle}</span>
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
- {conversationOpen ? (
488
+ {conversation.isOpen ? (
261
489
  <ConversationThread
262
- anchorId={item.id}
263
- error={conversationError}
264
- items={conversationItems}
265
- loading={conversationLoading}
490
+ anchorId={interactionTweetId}
491
+ error={conversation.error}
492
+ items={conversation.items}
493
+ loading={conversation.loading}
266
494
  />
267
495
  ) : null}
268
496
  </div>