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
@@ -0,0 +1,452 @@
1
+ import { Fragment, type ReactNode, useState } from "react";
2
+ import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
3
+ import type { PeriodDigestContext } from "#/lib/period-digest";
4
+ import type { ProfileRecord } from "#/lib/types";
5
+ import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
6
+ import { safeHttpUrl } from "#/lib/url-safety";
7
+ import { AvatarChip } from "./AvatarChip";
8
+ import { ProfilePreview } from "./ProfilePreview";
9
+
10
+ type InlineLookup = {
11
+ tweetsById: Map<string, PeriodDigestContext["tweets"][number]>;
12
+ profilesByHandle: Map<string, ProfileRecord>;
13
+ };
14
+
15
+ function normalizeTweetReference(value: string) {
16
+ return value
17
+ .trim()
18
+ .replace(/^\(/, "")
19
+ .replace(/\)$/, "")
20
+ .replace(/^tweet_/, "");
21
+ }
22
+
23
+ function tweetReferencesFromToken(token: string) {
24
+ return Array.from(token.matchAll(/\b(?:tweet_)?[A-Za-z0-9_:-]{3,}\b/g))
25
+ .map((match) => match[0])
26
+ .filter((value) => value.startsWith("tweet_") || /^\d{12,25}$/.test(value));
27
+ }
28
+
29
+ function trailingReadableBounds(value: string) {
30
+ let start = 0;
31
+ for (const separator of [". ", "? ", "! ", "; ", ": "]) {
32
+ const index = value.lastIndexOf(separator);
33
+ if (index >= 0) start = Math.max(start, index + separator.length);
34
+ }
35
+
36
+ let end = value.length;
37
+ while (start < end && /\s/.test(value[start] ?? "")) start += 1;
38
+ while (end > start && /\s/.test(value[end - 1] ?? "")) end -= 1;
39
+
40
+ let clauseStart = start;
41
+ for (const separator of [", with ", ", while ", ", and "]) {
42
+ const index = value.lastIndexOf(separator, end);
43
+ if (index >= start) {
44
+ clauseStart = index + 2;
45
+ break;
46
+ }
47
+ }
48
+
49
+ if (clauseStart > start || end - start > 140) {
50
+ while (clauseStart < end && /\s/.test(value[clauseStart] ?? "")) {
51
+ clauseStart += 1;
52
+ }
53
+ if (end > clauseStart) start = clauseStart;
54
+ }
55
+
56
+ return end > start ? { start, end } : null;
57
+ }
58
+
59
+ function trimBullet(value: string) {
60
+ return value.replace(/^[-*]\s+/, "");
61
+ }
62
+
63
+ function getTweetUrl(tweet: PeriodDigestContext["tweets"][number]) {
64
+ return tweet.url || `https://x.com/${tweet.author}/status/${tweet.id}`;
65
+ }
66
+
67
+ function TweetPreviewToken({
68
+ tweet,
69
+ children,
70
+ }: {
71
+ tweet: PeriodDigestContext["tweets"][number];
72
+ children: ReactNode;
73
+ }) {
74
+ const [open, setOpen] = useState(false);
75
+
76
+ function closePreview() {
77
+ setOpen(false);
78
+ }
79
+
80
+ return (
81
+ <span
82
+ className="relative inline align-baseline"
83
+ onBlur={closePreview}
84
+ onFocus={() => setOpen(true)}
85
+ onPointerEnter={() => setOpen(true)}
86
+ onPointerLeave={closePreview}
87
+ >
88
+ <a
89
+ className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
90
+ href={getTweetUrl(tweet)}
91
+ onClick={(event) => {
92
+ closePreview();
93
+ event.currentTarget.blur();
94
+ }}
95
+ rel="noreferrer"
96
+ target="_blank"
97
+ >
98
+ {children}
99
+ </a>
100
+ <span
101
+ aria-hidden={!open}
102
+ className={cx(
103
+ "absolute left-1/2 top-[calc(100%+10px)] z-40 w-[360px] -translate-x-1/2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]",
104
+ open ? "block" : "hidden",
105
+ )}
106
+ >
107
+ <span className="mb-2 flex items-center gap-2">
108
+ <AvatarChip
109
+ avatarUrl={tweet.authorProfile.avatarUrl}
110
+ hue={tweet.authorProfile.avatarHue}
111
+ name={tweet.name}
112
+ profileId={tweet.authorProfile.id}
113
+ size="small"
114
+ />
115
+ <span className="min-w-0">
116
+ <span className="block truncate font-bold">{tweet.name}</span>
117
+ <span className="block truncate text-[12px] text-[var(--ink-soft)]">
118
+ @{tweet.author} · {formatShortTimestamp(tweet.createdAt)}
119
+ </span>
120
+ </span>
121
+ </span>
122
+ <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
123
+ {tweet.text}
124
+ </span>
125
+ <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
126
+ <span>{tweet.source}</span>
127
+ {tweet.likeCount > 0 ? (
128
+ <span>{formatCompactNumber(tweet.likeCount)} likes</span>
129
+ ) : null}
130
+ {tweet.needsReply ? <span>reply open</span> : null}
131
+ </span>
132
+ </span>
133
+ </span>
134
+ );
135
+ }
136
+
137
+ function additionalCitationLinks(
138
+ tweets: Array<PeriodDigestContext["tweets"][number]>,
139
+ key: string,
140
+ ) {
141
+ return tweets.slice(1).flatMap((tweet, index) => [
142
+ index === 0 ? " " : ", ",
143
+ <TweetPreviewToken key={`${key}-source-${String(index + 2)}`} tweet={tweet}>
144
+ {`source ${String(index + 2)}`}
145
+ </TweetPreviewToken>,
146
+ ]);
147
+ }
148
+
149
+ function fallbackCitationLinks(
150
+ tweets: Array<PeriodDigestContext["tweets"][number]>,
151
+ key: string,
152
+ ) {
153
+ return tweets.flatMap((tweet, index) => [
154
+ index === 0 ? "" : ", ",
155
+ <TweetPreviewToken
156
+ key={`${key}-fallback-${String(index + 1)}`}
157
+ tweet={tweet}
158
+ >
159
+ {tweets.length === 1 ? "source" : `source ${String(index + 1)}`}
160
+ </TweetPreviewToken>,
161
+ ]);
162
+ }
163
+
164
+ function linkTrailingCitationText(
165
+ nodes: ReactNode[],
166
+ tweets: Array<PeriodDigestContext["tweets"][number]>,
167
+ key: string,
168
+ ) {
169
+ const tweet = tweets[0];
170
+ if (!tweet) return false;
171
+ const last = nodes.at(-1);
172
+ if (typeof last !== "string") return false;
173
+
174
+ const match = /(["“][^"”]+["”])(\s*)$/.exec(last);
175
+ if (match) {
176
+ const quoted = match[1];
177
+ const trailing = match[2] ?? "";
178
+ const before = last.slice(0, match.index);
179
+ nodes[nodes.length - 1] = before;
180
+ nodes.push(
181
+ <TweetPreviewToken key={key} tweet={tweet}>
182
+ {quoted}
183
+ </TweetPreviewToken>,
184
+ ...additionalCitationLinks(tweets, key),
185
+ /^\s*$/.test(trailing) ? "" : trailing,
186
+ );
187
+ return true;
188
+ }
189
+
190
+ const bounds = trailingReadableBounds(last);
191
+ if (!bounds) return false;
192
+
193
+ const before = last.slice(0, bounds.start);
194
+ const readable = last.slice(bounds.start, bounds.end);
195
+ const trailing = last.slice(bounds.end);
196
+ nodes[nodes.length - 1] = before;
197
+ nodes.push(
198
+ <TweetPreviewToken key={key} tweet={tweet}>
199
+ {readable}
200
+ </TweetPreviewToken>,
201
+ ...additionalCitationLinks(tweets, key),
202
+ /^\s*$/.test(trailing) ? "" : trailing,
203
+ );
204
+ return true;
205
+ }
206
+
207
+ function renderInline(text: string, lookup: InlineLookup) {
208
+ const pattern =
209
+ /(\[[^\]\n]+\]\s*\(https?:\/\/[^\s)]+\)|\*\*[^*]+\*\*|@[A-Za-z0-9_]{1,20}|\((?:\s*(?:tweet_[A-Za-z0-9_:-]+|\d{12,25})\s*,?)+\)|\btweet_[A-Za-z0-9_:-]+\b|\b\d{12,25}\b)/g;
210
+ const nodes: ReactNode[] = [];
211
+ let cursor = 0;
212
+ let match: RegExpExecArray | null;
213
+
214
+ while ((match = pattern.exec(text))) {
215
+ const token = match[0];
216
+ if (match.index > cursor) {
217
+ nodes.push(text.slice(cursor, match.index));
218
+ }
219
+ cursor = match.index + token.length;
220
+
221
+ if (token.startsWith("**") && token.endsWith("**")) {
222
+ nodes.push(
223
+ <strong key={`${token}-${String(match.index)}`}>
224
+ {token.slice(2, -2)}
225
+ </strong>,
226
+ );
227
+ continue;
228
+ }
229
+
230
+ const markdownLink = /^\[([^\]\n]+)\]\s*\((https?:\/\/[^\s)]+)\)$/.exec(
231
+ token,
232
+ );
233
+ if (markdownLink) {
234
+ const href = safeHttpUrl(markdownLink[2]);
235
+ nodes.push(
236
+ href ? (
237
+ <a
238
+ key={`${token}-${String(match.index)}`}
239
+ className={tweetLinkClass}
240
+ href={href}
241
+ rel="noreferrer"
242
+ target="_blank"
243
+ >
244
+ {markdownLink[1]}
245
+ </a>
246
+ ) : (
247
+ markdownLink[1]
248
+ ),
249
+ );
250
+ continue;
251
+ }
252
+
253
+ if (token.startsWith("@")) {
254
+ const profile = lookup.profilesByHandle.get(token.slice(1).toLowerCase());
255
+ nodes.push(
256
+ profile ? (
257
+ <ProfilePreview
258
+ key={`${token}-${String(match.index)}`}
259
+ profile={profile}
260
+ >
261
+ <span className={tweetMentionClass}>{token}</span>
262
+ </ProfilePreview>
263
+ ) : (
264
+ <a
265
+ key={`${token}-${String(match.index)}`}
266
+ className={tweetMentionClass}
267
+ href={`https://x.com/${token.slice(1)}`}
268
+ rel="noreferrer"
269
+ target="_blank"
270
+ >
271
+ {token}
272
+ </a>
273
+ ),
274
+ );
275
+ continue;
276
+ }
277
+
278
+ const references = tweetReferencesFromToken(token);
279
+ const resolvedTweets = references.map((reference) =>
280
+ lookup.tweetsById.get(normalizeTweetReference(reference)),
281
+ );
282
+ const allReferencesResolved =
283
+ references.length > 0 && resolvedTweets.every(Boolean);
284
+ const tweets = resolvedTweets.filter(
285
+ (tweet): tweet is PeriodDigestContext["tweets"][number] => Boolean(tweet),
286
+ );
287
+ const tweet = tweets[0];
288
+ const isParenthesizedTweetRef =
289
+ token.startsWith("(") && token.endsWith(")");
290
+ if (
291
+ isParenthesizedTweetRef &&
292
+ references.length > 1 &&
293
+ !allReferencesResolved
294
+ ) {
295
+ nodes.push(token);
296
+ continue;
297
+ }
298
+ if (
299
+ tweet &&
300
+ isParenthesizedTweetRef &&
301
+ allReferencesResolved &&
302
+ linkTrailingCitationText(nodes, tweets, `${token}-${String(match.index)}`)
303
+ ) {
304
+ continue;
305
+ }
306
+ if (tweet && isParenthesizedTweetRef && allReferencesResolved) {
307
+ nodes.push(
308
+ ...fallbackCitationLinks(tweets, `${token}-${String(match.index)}`),
309
+ );
310
+ continue;
311
+ }
312
+ nodes.push(
313
+ tweet ? (
314
+ <TweetPreviewToken
315
+ key={`${token}-${String(match.index)}`}
316
+ tweet={tweet}
317
+ >
318
+ {isParenthesizedTweetRef ? "source" : token}
319
+ </TweetPreviewToken>
320
+ ) : (
321
+ token
322
+ ),
323
+ );
324
+ }
325
+
326
+ if (cursor < text.length) {
327
+ nodes.push(text.slice(cursor));
328
+ }
329
+
330
+ return nodes.map((node, index) => (
331
+ <Fragment
332
+ key={typeof node === "string" ? `${node}-${String(index)}` : index}
333
+ >
334
+ {node}
335
+ </Fragment>
336
+ ));
337
+ }
338
+
339
+ function buildLookup(context?: PeriodDigestContext | null): InlineLookup {
340
+ const tweetsById = new Map<string, PeriodDigestContext["tweets"][number]>();
341
+ const profilesByHandle = new Map<string, ProfileRecord>();
342
+ for (const tweet of context?.tweets ?? []) {
343
+ const normalized = normalizeTweetReference(tweet.id);
344
+ tweetsById.set(normalized, tweet);
345
+ tweetsById.set(`tweet_${normalized}`, tweet);
346
+ profilesByHandle.set(tweet.author.toLowerCase(), tweet.authorProfile);
347
+ }
348
+ return { tweetsById, profilesByHandle };
349
+ }
350
+
351
+ export function MarkdownViewer({
352
+ markdown,
353
+ context,
354
+ className,
355
+ }: {
356
+ markdown: string;
357
+ context?: PeriodDigestContext | null;
358
+ className?: string;
359
+ }) {
360
+ const lookup = buildLookup(context);
361
+ const normalizedMarkdown = markdown.replace(
362
+ /\]\s*\r?\n\s*\((https?:\/\/[^\s)]+)\)/g,
363
+ "]($1)",
364
+ );
365
+ const lines = normalizedMarkdown.split(/\r?\n/);
366
+ const nodes: ReactNode[] = [];
367
+ let listItems: ReactNode[][] = [];
368
+
369
+ const flushList = () => {
370
+ if (listItems.length === 0) return;
371
+ nodes.push(
372
+ <ul
373
+ className="my-3 flex list-disc flex-col gap-2 pl-5 marker:text-[var(--ink-soft)]"
374
+ key={`list-${String(nodes.length)}`}
375
+ >
376
+ {listItems.map((item, index) => (
377
+ <li key={String(index)}>{item}</li>
378
+ ))}
379
+ </ul>,
380
+ );
381
+ listItems = [];
382
+ };
383
+
384
+ for (const line of lines) {
385
+ const trimmed = line.trim();
386
+ if (!trimmed) {
387
+ flushList();
388
+ continue;
389
+ }
390
+ if (trimmed.startsWith("### ")) {
391
+ flushList();
392
+ nodes.push(
393
+ <h3
394
+ className="mt-6 mb-2 text-[14px] font-bold uppercase tracking-wide text-[var(--ink-soft)]"
395
+ key={`h3-${String(nodes.length)}`}
396
+ >
397
+ {renderInline(trimmed.slice(4), lookup)}
398
+ </h3>,
399
+ );
400
+ continue;
401
+ }
402
+ if (trimmed.startsWith("## ")) {
403
+ flushList();
404
+ nodes.push(
405
+ <h2
406
+ className="mt-7 mb-2 text-[18px] font-bold text-[var(--ink)]"
407
+ key={`h2-${String(nodes.length)}`}
408
+ >
409
+ {renderInline(trimmed.slice(3), lookup)}
410
+ </h2>,
411
+ );
412
+ continue;
413
+ }
414
+ if (trimmed.startsWith("# ")) {
415
+ flushList();
416
+ nodes.push(
417
+ <h1
418
+ className="mt-2 mb-3 text-[20px] font-bold text-[var(--ink)]"
419
+ key={`h1-${String(nodes.length)}`}
420
+ >
421
+ {renderInline(trimmed.slice(2), lookup)}
422
+ </h1>,
423
+ );
424
+ continue;
425
+ }
426
+ if (/^[-*]\s+/.test(trimmed)) {
427
+ listItems.push(renderInline(trimBullet(trimmed), lookup));
428
+ continue;
429
+ }
430
+ flushList();
431
+ nodes.push(
432
+ <p
433
+ className="my-3 whitespace-pre-wrap [overflow-wrap:anywhere]"
434
+ key={`p-${String(nodes.length)}`}
435
+ >
436
+ {renderInline(trimmed, lookup)}
437
+ </p>,
438
+ );
439
+ }
440
+ flushList();
441
+
442
+ return (
443
+ <article
444
+ className={cx(
445
+ "max-w-none px-4 py-4 text-[15px] leading-7 text-[var(--ink)]",
446
+ className,
447
+ )}
448
+ >
449
+ {nodes}
450
+ </article>
451
+ );
452
+ }
@@ -1,9 +1,16 @@
1
1
  import { Search } from "lucide-react";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ FeedEmpty,
5
+ FeedError,
6
+ FeedLoading,
7
+ TweetSkeletonRows,
8
+ } from "#/components/FeedState";
9
+ import { SyncNowButton } from "#/components/SyncNowButton";
3
10
  import { TimelineCard } from "#/components/TimelineCard";
4
- import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
11
+ import { useTimelineRouteData } from "#/components/useTimelineRouteData";
12
+ import { ConversationSurfaceScope } from "#/lib/conversation-surface";
5
13
  import {
6
- emptyStateClass,
7
14
  feedClass,
8
15
  pageHeaderClass,
9
16
  pageHeaderRowClass,
@@ -33,30 +40,15 @@ export function SavedTimelineView({
33
40
  loadingLabel,
34
41
  searchPlaceholder,
35
42
  }: SavedTimelineViewProps) {
36
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
37
- const [items, setItems] = useState<TimelineItem[]>([]);
38
43
  const [search, setSearch] = useState("");
39
- const [refreshTick, setRefreshTick] = useState(0);
40
-
41
- useEffect(() => {
42
- fetch("/api/status")
43
- .then((response) => response.json())
44
- .then((data: QueryEnvelope) => setMeta(data));
45
- }, []);
46
-
47
- useEffect(() => {
48
- const url = new URL("/api/query", window.location.origin);
49
- url.searchParams.set("resource", "home");
50
- url.searchParams.set(filter, "true");
51
- url.searchParams.set("refresh", String(refreshTick));
52
- if (search.trim()) {
53
- url.searchParams.set("search", search.trim());
54
- }
55
-
56
- fetch(url)
57
- .then((response) => response.json())
58
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
59
- }, [filter, refreshTick, search]);
44
+ const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
45
+ useTimelineRouteData({
46
+ resource: "home",
47
+ search,
48
+ errorFallback: `${TITLES[filter]} unavailable`,
49
+ likedOnly: filter === "liked",
50
+ bookmarkedOnly: filter === "bookmarked",
51
+ });
60
52
 
61
53
  const subtitle = useMemo(() => {
62
54
  if (!meta) {
@@ -67,23 +59,7 @@ export function SavedTimelineView({
67
59
  return `${String(items.length)} visible · ${meta.transport.statusText}`;
68
60
  }, [items.length, loadingLabel, meta]);
69
61
 
70
- async function replyToTweet(tweetId: string) {
71
- const text = window.prompt("Reply text");
72
- if (!text?.trim()) return;
73
-
74
- await fetch("/api/action", {
75
- method: "POST",
76
- headers: { "content-type": "application/json" },
77
- body: JSON.stringify({
78
- kind: "replyTweet",
79
- accountId: "acct_primary",
80
- tweetId,
81
- text,
82
- }),
83
- });
84
-
85
- setRefreshTick((value) => value + 1);
86
- }
62
+ const syncKind = filter === "liked" ? "likes" : "bookmarks";
87
63
 
88
64
  return (
89
65
  <>
@@ -94,6 +70,12 @@ export function SavedTimelineView({
94
70
  <p className={pageSubtitleClass}>{title}</p>
95
71
  <p className={pageSubtitleClass}>{subtitle}</p>
96
72
  </div>
73
+ <SyncNowButton
74
+ accounts={meta?.accounts}
75
+ kind={syncKind}
76
+ label={filter === "liked" ? "Sync likes" : "Sync bookmarks"}
77
+ onSynced={refreshLocalView}
78
+ />
97
79
  </div>
98
80
  <div className="px-4 pb-3">
99
81
  <label className={searchFieldShellClass}>
@@ -107,19 +89,45 @@ export function SavedTimelineView({
107
89
  </label>
108
90
  </div>
109
91
  </header>
110
- <section className={feedClass}>
111
- {items.length === 0 ? (
112
- <div className={emptyStateClass}>Nothing saved here yet.</div>
113
- ) : null}
114
- {items.map((item) => (
115
- <TimelineCard
116
- key={item.id}
117
- item={item}
118
- onReply={replyToTweet}
119
- showReplyControls={false}
120
- />
121
- ))}
122
- </section>
92
+ <ConversationSurfaceScope>
93
+ <section className={feedClass}>
94
+ {loading ? (
95
+ <FeedLoading
96
+ detail={`Reading local ${TITLES[filter].toLowerCase()}`}
97
+ label={loadingLabel}
98
+ >
99
+ <TweetSkeletonRows />
100
+ </FeedLoading>
101
+ ) : error ? (
102
+ <FeedError
103
+ action={
104
+ <button
105
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
106
+ onClick={retry}
107
+ type="button"
108
+ >
109
+ Retry
110
+ </button>
111
+ }
112
+ message={error}
113
+ title={`Could not load ${TITLES[filter].toLowerCase()}`}
114
+ />
115
+ ) : items.length === 0 ? (
116
+ <FeedEmpty
117
+ detail="Sync this collection or broaden the search."
118
+ label="Nothing saved here yet"
119
+ />
120
+ ) : null}
121
+ {items.map((item) => (
122
+ <TimelineCard
123
+ key={item.id}
124
+ item={item}
125
+ onReply={replyToTweet}
126
+ showReplyControls={false}
127
+ />
128
+ ))}
129
+ </section>
130
+ </ConversationSurfaceScope>
123
131
  </>
124
132
  );
125
133
  }