birdclaw 0.5.1 → 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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -17,59 +17,64 @@ const THEME_OPTIONS = [
17
17
  label: string;
18
18
  }>;
19
19
 
20
- export function ThemeSlider() {
21
- const { isReady, theme, setTheme } = useTheme();
22
-
23
- return (
24
- <fieldset
25
- className="theme-slider-shell m-0 flex justify-center border-0 px-2 py-1 min-[1100px]:justify-start min-[1100px]:px-3"
26
- aria-label="Theme selector"
27
- >
28
- <div className="theme-slider flex flex-col items-center gap-1.5 min-[1100px]:flex-row">
29
- {THEME_OPTIONS.map((option, index) => {
30
- const Icon = option.icon;
31
- const isActive = option.key === theme || (index === 0 && !theme);
20
+ function themeIndex(theme: ThemeValue) {
21
+ return THEME_OPTIONS.findIndex((option) => option.key === theme);
22
+ }
32
23
 
33
- const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
34
- if (isActive) return;
24
+ export function ThemeSlider({ compact = false }: { compact?: boolean }) {
25
+ const { isReady, theme, setTheme } = useTheme();
26
+ const activeIndex = Math.max(themeIndex(theme), 0);
27
+ const activeOption = THEME_OPTIONS[activeIndex];
28
+ const nextOption = THEME_OPTIONS[(activeIndex + 1) % THEME_OPTIONS.length];
29
+ const Icon = activeOption.icon;
35
30
 
36
- const context: ThemeTransitionContext = {
37
- element: event.currentTarget,
38
- pointerClientX: event.clientX,
39
- pointerClientY: event.clientY,
40
- };
31
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
32
+ const context: ThemeTransitionContext = {
33
+ element: event.currentTarget,
34
+ pointerClientX: event.clientX,
35
+ pointerClientY: event.clientY,
36
+ };
41
37
 
42
- startThemeTransition({
43
- nextTheme: option.key,
44
- currentTheme: theme,
45
- setTheme,
46
- context,
47
- });
48
- };
38
+ startThemeTransition({
39
+ nextTheme: nextOption.key,
40
+ currentTheme: theme,
41
+ setTheme,
42
+ context,
43
+ });
44
+ };
49
45
 
50
- return (
51
- <button
52
- key={option.key}
53
- type="button"
54
- className={cx(
55
- "theme-slider-button inline-flex size-9 items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[background,color,transform] duration-150 hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
56
- isActive &&
57
- "theme-slider-button-active bg-[var(--bg-active)] text-[var(--ink)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_72%,transparent)]",
58
- )}
59
- onClick={handleClick}
60
- aria-label={option.label}
61
- aria-pressed={isActive}
62
- data-testid={`theme-${option.key}`}
63
- disabled={!isReady}
64
- >
65
- <Icon
66
- className="theme-slider-icon size-[17px]"
67
- strokeWidth={isActive ? 2.1 : 1.8}
68
- />
69
- </button>
70
- );
71
- })}
72
- </div>
73
- </fieldset>
46
+ return (
47
+ <div
48
+ className={cx(
49
+ "theme-toggle-shell flex justify-center",
50
+ compact
51
+ ? "shrink-0"
52
+ : "px-1 py-1 min-[1100px]:justify-start min-[1100px]:px-2",
53
+ )}
54
+ title={`${activeOption.label}; click for ${nextOption.label}`}
55
+ >
56
+ <button
57
+ type="button"
58
+ className={cx(
59
+ "theme-toggle-button inline-flex items-center justify-center rounded-full border bg-transparent text-[var(--ink-soft)] transition-[background,border-color,color,transform,box-shadow] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_38%,var(--line))] hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
60
+ compact
61
+ ? "size-8 border-transparent"
62
+ : "size-11 border-[var(--line)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_44%,transparent)]",
63
+ )}
64
+ onClick={handleClick}
65
+ aria-label={`Theme: ${activeOption.label}. Switch to ${nextOption.label}.`}
66
+ data-testid="theme-toggle"
67
+ disabled={!isReady}
68
+ >
69
+ <Icon
70
+ className={cx(
71
+ "theme-toggle-icon",
72
+ compact ? "size-[17px]" : "size-[19px]",
73
+ )}
74
+ strokeWidth={2}
75
+ aria-hidden="true"
76
+ />
77
+ </button>
78
+ </div>
74
79
  );
75
80
  }
@@ -1,9 +1,9 @@
1
1
  import {
2
- Bookmark,
3
2
  BookmarkCheck,
4
3
  CheckCircle2,
5
4
  Circle,
6
5
  Heart,
6
+ Image,
7
7
  MessageCircle,
8
8
  Repeat2,
9
9
  } from "lucide-react";
@@ -21,8 +21,6 @@ import {
21
21
  feedActionButtonClass,
22
22
  feedActionIconClass,
23
23
  feedActionIconWrapClass,
24
- feedActionIconWrapLikeClass,
25
- feedActionLikeClass,
26
24
  feedRowActionsClass,
27
25
  feedRowBodyClass,
28
26
  feedRowClass,
@@ -35,7 +33,6 @@ import {
35
33
  feedRowStatePillOpenClass,
36
34
  feedRowTextClass,
37
35
  feedRowTimestampClass,
38
- mutedDotClass,
39
36
  } from "#/lib/ui";
40
37
  import { AvatarChip } from "./AvatarChip";
41
38
  import { ConversationThread } from "./ConversationThread";
@@ -83,6 +80,64 @@ function isMediaUrlEntity(
83
80
  return false;
84
81
  }
85
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
+
86
141
  function isOwnStatusMediaUrl(
87
142
  value: string | null | undefined,
88
143
  tweetId: string,
@@ -108,13 +163,21 @@ function getVisibleEntities(
108
163
  entities: TweetEntities,
109
164
  media: TweetMediaItem[],
110
165
  tweetId: string,
166
+ text: string,
111
167
  ) {
112
168
  const mediaUrls = getMediaUrlSet(media);
113
169
  if (mediaUrls.size === 0) return entities;
170
+ const hideUnresolvedShortUrls = shouldHideUnresolvedShortUrls(
171
+ text,
172
+ entities,
173
+ mediaUrls,
174
+ );
114
175
  return {
115
176
  ...entities,
116
177
  urls: (entities.urls ?? []).filter(
117
- (entry) => !isMediaUrlEntity(entry, mediaUrls, tweetId),
178
+ (entry) =>
179
+ !isMediaUrlEntity(entry, mediaUrls, tweetId) &&
180
+ !(hideUnresolvedShortUrls && isUnresolvedShortUrlEntity(entry)),
118
181
  ),
119
182
  };
120
183
  }
@@ -123,19 +186,32 @@ function getHiddenMediaUrlRanges(
123
186
  entities: TweetEntities,
124
187
  media: TweetMediaItem[],
125
188
  tweetId: string,
189
+ text: string,
126
190
  ) {
127
191
  const mediaUrls = getMediaUrlSet(media);
128
192
  if (mediaUrls.size === 0) return [];
193
+ const hideUnresolvedShortUrls = shouldHideUnresolvedShortUrls(
194
+ text,
195
+ entities,
196
+ mediaUrls,
197
+ );
129
198
  return (entities.urls ?? [])
130
- .filter((entry) => isMediaUrlEntity(entry, mediaUrls, tweetId))
199
+ .filter(
200
+ (entry) =>
201
+ isMediaUrlEntity(entry, mediaUrls, tweetId) ||
202
+ (hideUnresolvedShortUrls && isUnresolvedShortUrlEntity(entry)),
203
+ )
131
204
  .map((entry) => ({ start: entry.start, end: entry.end }));
132
205
  }
133
206
 
134
- function getVisibleUrlCards(item: TimelineItem, entities: TweetEntities) {
135
- const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
207
+ function getVisibleUrlCards(
208
+ entities: TweetEntities,
209
+ quotedTweetId: string | null,
210
+ ) {
136
211
  return (entities.urls ?? []).filter((entry) => {
137
- if (!item.quotedTweet) return true;
138
- return !entry.expandedUrl.includes(quotedUrl ?? "");
212
+ if (isUnresolvedShortUrlEntity(entry)) return false;
213
+ if (!quotedTweetId) return true;
214
+ return !entry.expandedUrl.includes(quotedTweetId);
139
215
  });
140
216
  }
141
217
 
@@ -157,18 +233,44 @@ export function TimelineCard({
157
233
  }) {
158
234
  const canReply =
159
235
  showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
160
- const conversation = useConversationSurface(item.id);
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);
161
244
  const visibleEntities = getVisibleEntities(
162
- item.entities,
163
- item.media,
164
- item.id,
245
+ displayTweet.entities,
246
+ displayTweet.media,
247
+ displayTweet.id,
248
+ displayTweet.text,
165
249
  );
166
250
  const hiddenMediaUrlRanges = getHiddenMediaUrlRanges(
167
- item.entities,
168
- item.media,
169
- item.id,
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,
170
273
  );
171
- const hasConversation = Boolean(item.replyToTweet || item.replyToId);
172
274
 
173
275
  return (
174
276
  <article
@@ -182,24 +284,34 @@ export function TimelineCard({
182
284
  }}
183
285
  >
184
286
  <AvatarChip
185
- avatarUrl={item.author.avatarUrl}
186
- hue={item.author.avatarHue}
187
- name={item.author.displayName}
188
- profileId={item.author.id}
287
+ avatarUrl={displayAuthor.avatarUrl}
288
+ hue={displayAuthor.avatarHue}
289
+ name={displayAuthor.displayName}
290
+ profileId={displayAuthor.id}
189
291
  />
190
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}
191
301
  <header className={feedRowHeaderClass}>
192
- <ProfilePreview profile={item.author}>
302
+ <ProfilePreview profile={displayAuthor}>
193
303
  <span className="flex min-w-0 items-center gap-1.5">
194
304
  <span className={feedRowNameClass}>
195
- {item.author.displayName}
305
+ {displayAuthor.displayName}
306
+ </span>
307
+ <span className={feedRowHandleClass}>
308
+ @{displayAuthor.handle}
196
309
  </span>
197
- <span className={feedRowHandleClass}>@{item.author.handle}</span>
198
310
  </span>
199
311
  </ProfilePreview>
200
312
  <span className={feedRowDotClass}>·</span>
201
313
  <span className={feedRowTimestampClass}>
202
- {formatShortTimestamp(item.createdAt)}
314
+ {formatShortTimestamp(displayTweet.createdAt)}
203
315
  </span>
204
316
  {canReply || hasConversation ? (
205
317
  <span className="ml-auto inline-flex items-center gap-1">
@@ -218,50 +330,77 @@ export function TimelineCard({
218
330
  ) : null}
219
331
  {canReply ? (
220
332
  <span
221
- aria-label={item.isReplied ? "We replied" : "Reply open"}
333
+ aria-label={displayIsReplied ? "We replied" : "Reply open"}
222
334
  className={cx(
223
335
  feedRowStatePillClass,
224
- item.isReplied
336
+ displayIsReplied
225
337
  ? feedRowStatePillActiveClass
226
338
  : feedRowStatePillOpenClass,
227
339
  )}
228
- title={item.isReplied ? "We replied" : "Reply open"}
340
+ title={displayIsReplied ? "We replied" : "Reply open"}
229
341
  >
230
- {item.isReplied ? (
342
+ {displayIsReplied ? (
231
343
  <CheckCircle2 className="size-3.5" strokeWidth={2} />
232
344
  ) : (
233
345
  <Circle className="size-3" strokeWidth={2.2} />
234
346
  )}
235
- {item.isReplied ? "replied" : "open"}
347
+ {displayIsReplied ? "replied" : "open"}
236
348
  </span>
237
349
  ) : null}
238
350
  </span>
239
351
  ) : null}
240
352
  </header>
241
- <TweetRichText
242
- className={feedRowTextClass}
243
- entities={item.entities}
244
- hiddenUrlRanges={hiddenMediaUrlRanges}
245
- text={item.text}
246
- />
247
- <TweetMediaGrid items={item.media} />
248
- {item.replyToTweet ? (
249
- <div className={embeddedCardClass}>
250
- <EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
251
- </div>
252
- ) : null}
253
- {item.quotedTweet ? (
254
- <div className={embeddedCardClass}>
255
- <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
256
- </div>
257
- ) : null}
258
- {getVisibleUrlCards(item, visibleEntities).map((entry, index) => (
259
- <LinkPreviewCard
260
- key={`${entry.expandedUrl}-${String(index)}`}
261
- entry={entry}
262
- index={index}
263
- />
264
- ))}
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
+ )}
265
404
  <footer className={feedRowActionsClass}>
266
405
  <div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
267
406
  <button
@@ -291,7 +430,7 @@ export function TimelineCard({
291
430
  className={feedActionButtonClass}
292
431
  onClick={(event) => {
293
432
  event.stopPropagation();
294
- onReply(item.id);
433
+ onReply(interactionTweetId);
295
434
  }}
296
435
  type="button"
297
436
  aria-label="Reply"
@@ -305,57 +444,50 @@ export function TimelineCard({
305
444
  <span className="text-[13px]">Reply</span>
306
445
  </button>
307
446
  ) : null}
308
- <span className={cx(feedActionButtonClass, "cursor-default")}>
309
- <span className={feedActionIconWrapClass}>
310
- <Repeat2 className={feedActionIconClass} strokeWidth={1.7} />
311
- </span>
312
- </span>
313
- <span
314
- className={cx(
315
- feedActionButtonClass,
316
- feedActionLikeClass,
317
- "cursor-default",
318
- item.liked && "text-[var(--like)]",
319
- )}
320
- >
447
+ {showLikeIndicator ? (
321
448
  <span
449
+ aria-label={`${formatCompactNumber(displayLikeCount)} likes`}
322
450
  className={cx(
323
- feedActionIconWrapClass,
324
- feedActionIconWrapLikeClass,
451
+ "inline-flex items-center gap-1 px-2 py-1 text-[13px]",
452
+ displayLiked && "text-[var(--like)]",
325
453
  )}
454
+ title={`${formatCompactNumber(displayLikeCount)} likes`}
326
455
  >
327
456
  <Heart
328
457
  className={feedActionIconClass}
329
458
  strokeWidth={1.7}
330
- fill={item.liked ? "currentColor" : "none"}
459
+ fill={displayLiked ? "currentColor" : "none"}
331
460
  />
461
+ <span>{formatCompactNumber(displayLikeCount)}</span>
332
462
  </span>
333
- <span>{formatCompactNumber(item.likeCount)}</span>
334
- </span>
335
- <span className={cx(feedActionButtonClass, "cursor-default")}>
336
- <span className={feedActionIconWrapClass}>
337
- {item.bookmarked ? (
338
- <BookmarkCheck
339
- className={feedActionIconClass}
340
- strokeWidth={1.7}
341
- />
342
- ) : (
343
- <Bookmark className={feedActionIconClass} strokeWidth={1.7} />
344
- )}
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
+ />
345
474
  </span>
346
- </span>
347
- </div>
348
- <div className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
349
- <span>{item.mediaCount} media</span>
350
- <span className={mutedDotClass} />
351
- <span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
352
- <span className={mutedDotClass} />
353
- <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}
354
486
  </div>
355
487
  </footer>
356
488
  {conversation.isOpen ? (
357
489
  <ConversationThread
358
- anchorId={item.id}
490
+ anchorId={interactionTweetId}
359
491
  error={conversation.error}
360
492
  items={conversation.items}
361
493
  loading={conversation.loading}
@@ -25,6 +25,7 @@ import {
25
25
  tabButtonClass,
26
26
  tabButtonIndicatorClass,
27
27
  tabStripClass,
28
+ timestampClass,
28
29
  } from "#/lib/ui";
29
30
  import { useTimelineRouteData } from "./useTimelineRouteData";
30
31
 
@@ -68,13 +69,21 @@ export function TimelineRouteFrame({
68
69
  const [replyFilter, setReplyFilter] =
69
70
  useState<ReplyFilter>(initialReplyFilter);
70
71
  const [search, setSearch] = useState("");
71
- const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
72
- useTimelineRouteData({
73
- resource,
74
- replyFilter,
75
- search,
76
- errorFallback,
77
- });
72
+ const {
73
+ meta,
74
+ items,
75
+ loading,
76
+ error,
77
+ replyError,
78
+ retry,
79
+ refreshLocalView,
80
+ replyToTweet,
81
+ } = useTimelineRouteData({
82
+ resource,
83
+ replyFilter,
84
+ search,
85
+ errorFallback,
86
+ });
78
87
  const subtitleText = useMemo(() => subtitle(meta), [meta, subtitle]);
79
88
 
80
89
  return (
@@ -86,7 +95,7 @@ export function TimelineRouteFrame({
86
95
  <p className={pageSubtitleClass}>{subtitleText}</p>
87
96
  </div>
88
97
  <SyncNowButton
89
- accounts={syncKind === "mentions" ? meta?.accounts : undefined}
98
+ accounts={meta?.accounts}
90
99
  kind={syncKind}
91
100
  label={syncLabel}
92
101
  onSynced={refreshLocalView}
@@ -123,6 +132,11 @@ export function TimelineRouteFrame({
123
132
  })}
124
133
  </div>
125
134
  </header>
135
+ {replyError ? (
136
+ <p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
137
+ {replyError}
138
+ </p>
139
+ ) : null}
126
140
  <ConversationSurfaceScope>
127
141
  <section className={feedClass}>
128
142
  {loading ? (