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