birdclaw 0.4.0 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -1,29 +1,27 @@
1
1
  import { Link } from "@tanstack/react-router";
2
+ import { ExternalLink, MessageCircle } from "lucide-react";
2
3
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
3
4
  import type { InboxItem } from "#/lib/types";
4
5
  import {
5
- actionButtonClass,
6
- actionRowClass,
7
- bodyCopyClass,
8
- cardFooterClass,
9
- cardHeaderClass,
10
6
  composerBarClass,
11
7
  composerInputClass,
12
8
  composerShellClass,
13
- contentCardClass,
14
9
  cx,
15
- eyebrowClass,
16
- identityBlockClass,
17
- identityRowClass,
10
+ feedRowBodyClass,
11
+ feedRowClass,
12
+ feedRowDotClass,
13
+ feedRowHandleClass,
14
+ feedRowHeaderClass,
15
+ feedRowNameClass,
16
+ feedRowTextClass,
17
+ feedRowTimestampClass,
18
18
  inboxAnalysisClass,
19
- inboxTitleClass,
20
- metaStackClass,
21
- metricRowClass,
22
19
  mutedDotClass,
23
- navLinkClass,
24
20
  pillAlertClass,
25
21
  pillClass,
26
22
  pillSoftClass,
23
+ primaryButtonClass,
24
+ secondaryButtonClass,
27
25
  timestampClass,
28
26
  } from "#/lib/ui";
29
27
  import { AvatarChip } from "./AvatarChip";
@@ -44,93 +42,97 @@ export function InboxCard({
44
42
  onReplySend: () => void;
45
43
  }) {
46
44
  return (
47
- <article className={cx(contentCardClass, "inbox-card")}>
48
- <div className={cardHeaderClass}>
49
- <div className={identityBlockClass}>
50
- <AvatarChip
51
- avatarUrl={item.participant.avatarUrl}
52
- hue={item.participant.avatarHue}
53
- name={item.participant.displayName}
54
- profileId={item.participant.id}
55
- />
56
- <div>
57
- <div className={identityRowClass}>
58
- <strong>{item.participant.displayName}</strong>
59
- <span>@{item.participant.handle}</span>
60
- <span className={mutedDotClass} />
61
- <span>
62
- {formatCompactNumber(item.participant.followersCount)} followers
63
- </span>
64
- </div>
65
- </div>
66
- </div>
67
- <div className={metaStackClass}>
68
- <span className={cx(pillClass, pillSoftClass)}>
69
- {item.entityKind}
70
- </span>
71
- <span className={cx(pillClass, pillAlertClass)}>
72
- score {item.score}
45
+ <article className={cx(feedRowClass, "items-start")}>
46
+ <AvatarChip
47
+ avatarUrl={item.participant.avatarUrl}
48
+ hue={item.participant.avatarHue}
49
+ name={item.participant.displayName}
50
+ profileId={item.participant.id}
51
+ />
52
+ <div className={feedRowBodyClass}>
53
+ <header className={feedRowHeaderClass}>
54
+ <span className="flex min-w-0 items-center gap-1.5">
55
+ <span className={feedRowNameClass}>
56
+ {item.participant.displayName}
57
+ </span>
58
+ <span className={feedRowHandleClass}>
59
+ @{item.participant.handle}
60
+ </span>
73
61
  </span>
74
- <span className={timestampClass}>
62
+ <span className={feedRowDotClass}>·</span>
63
+ <span className={feedRowTimestampClass}>
75
64
  {formatShortTimestamp(item.createdAt)}
76
65
  </span>
66
+ <span className="ml-auto flex items-center gap-1.5">
67
+ <span className={cx(pillClass, pillSoftClass)}>
68
+ {item.entityKind}
69
+ </span>
70
+ <span className={cx(pillClass, pillAlertClass)}>
71
+ score {item.score}
72
+ </span>
73
+ </span>
74
+ </header>
75
+ <h3 className="text-[15px] font-bold text-[var(--ink)]">
76
+ {item.title}
77
+ </h3>
78
+ <p className={feedRowTextClass}>{item.text}</p>
79
+ <div className={inboxAnalysisClass}>
80
+ <strong className="text-[var(--ink)]">{item.summary}</strong>
81
+ <p>{item.reasoning}</p>
77
82
  </div>
78
- </div>
79
- <p className={eyebrowClass}>ai triage</p>
80
- <h3 className={inboxTitleClass}>{item.title}</h3>
81
- <p className={bodyCopyClass}>{item.text}</p>
82
- <div className={inboxAnalysisClass}>
83
- <strong>{item.summary}</strong>
84
- <p>{item.reasoning}</p>
85
- </div>
86
- <div className={cardFooterClass}>
87
- <div className={metricRowClass}>
88
- <span>{item.source}</span>
89
- <span>influence {item.influenceScore}</span>
90
- <span>{item.needsReply ? "needs reply" : "resolved"}</span>
91
- </div>
92
- <div className={actionRowClass}>
93
- <button
94
- className={navLinkClass}
95
- onClick={onReplyToggle}
96
- type="button"
97
- >
98
- {isReplying ? "Close reply" : "Reply"}
99
- </button>
100
- <Link
101
- className={actionButtonClass}
102
- to={item.entityKind === "dm" ? "/dms" : "/mentions"}
103
- >
104
- Open
105
- </Link>
106
- </div>
107
- </div>
108
- {isReplying ? (
109
- <div className={composerShellClass}>
110
- <textarea
111
- className={composerInputClass}
112
- onChange={(event) => onReplyChange(event.target.value)}
113
- placeholder={
114
- item.entityKind === "dm"
115
- ? `Reply to @${item.participant.handle}`
116
- : `Reply to mention from @${item.participant.handle}`
117
- }
118
- rows={4}
119
- value={replyDraft}
120
- />
121
- <div className={composerBarClass}>
122
- <span className={timestampClass}>Send from inbox</span>
83
+ <div className="mt-2 flex items-center justify-between gap-3 text-[13px] text-[var(--ink-soft)]">
84
+ <div className="flex flex-wrap items-center gap-2">
85
+ <span>{item.source}</span>
86
+ <span className={mutedDotClass} />
87
+ <span>influence {formatCompactNumber(item.influenceScore)}</span>
88
+ <span className={mutedDotClass} />
89
+ <span>{item.needsReply ? "needs reply" : "resolved"}</span>
90
+ </div>
91
+ <div className="flex items-center gap-2">
123
92
  <button
124
- className={actionButtonClass}
125
- disabled={!replyDraft.trim()}
126
- onClick={onReplySend}
93
+ className={secondaryButtonClass}
94
+ onClick={onReplyToggle}
127
95
  type="button"
128
96
  >
129
- Send
97
+ <MessageCircle className="size-4" strokeWidth={2} />
98
+ {isReplying ? "Close reply" : "Reply"}
130
99
  </button>
100
+ <Link
101
+ className={cx(secondaryButtonClass, "gap-1.5")}
102
+ to={item.entityKind === "dm" ? "/dms" : "/mentions"}
103
+ >
104
+ <ExternalLink className="size-4" strokeWidth={2} />
105
+ Open
106
+ </Link>
131
107
  </div>
132
108
  </div>
133
- ) : null}
109
+ {isReplying ? (
110
+ <div className={composerShellClass}>
111
+ <textarea
112
+ className={composerInputClass}
113
+ onChange={(event) => onReplyChange(event.target.value)}
114
+ placeholder={
115
+ item.entityKind === "dm"
116
+ ? `Reply to @${item.participant.handle}`
117
+ : `Reply to mention from @${item.participant.handle}`
118
+ }
119
+ rows={4}
120
+ value={replyDraft}
121
+ />
122
+ <div className={composerBarClass}>
123
+ <span className={timestampClass}>Send from inbox</span>
124
+ <button
125
+ className={primaryButtonClass}
126
+ disabled={!replyDraft.trim()}
127
+ onClick={onReplySend}
128
+ type="button"
129
+ >
130
+ Send
131
+ </button>
132
+ </div>
133
+ </div>
134
+ ) : null}
135
+ </div>
134
136
  </article>
135
137
  );
136
138
  }
@@ -0,0 +1,270 @@
1
+ import { ExternalLink, Image as ImageIcon } from "lucide-react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import type { LinkPreviewMetadata } from "#/lib/link-preview-metadata";
4
+ import type { TweetUrlEntity } from "#/lib/types";
5
+ import {
6
+ cx,
7
+ linkPreviewCardClass,
8
+ linkPreviewDescClass,
9
+ linkPreviewHostClass,
10
+ linkPreviewTitleClass,
11
+ } from "#/lib/ui";
12
+
13
+ type LinkPreviewState = Pick<
14
+ TweetUrlEntity,
15
+ | "expandedUrl"
16
+ | "displayUrl"
17
+ | "title"
18
+ | "description"
19
+ | "imageUrl"
20
+ | "siteName"
21
+ >;
22
+
23
+ const previewCache = new Map<string, Promise<LinkPreviewMetadata | null>>();
24
+ const IMAGE_EXTENSION_PATTERN = /\.(?:avif|gif|jpe?g|png|webp)(?:[?#].*)?$/i;
25
+ const MAX_CONCURRENT_PREVIEW_FETCHES = 2;
26
+ let activePreviewFetches = 0;
27
+ const queuedPreviewFetches: Array<() => void> = [];
28
+
29
+ function needsHydration(preview: LinkPreviewState) {
30
+ const targetUrl = preview.expandedUrl || "";
31
+ if (
32
+ preview.imageUrl &&
33
+ preview.siteName &&
34
+ preview.title &&
35
+ isDirectImageUrl(targetUrl)
36
+ ) {
37
+ return false;
38
+ }
39
+ return (
40
+ !preview.imageUrl ||
41
+ !preview.title ||
42
+ !preview.description ||
43
+ preview.title === preview.displayUrl ||
44
+ preview.description === preview.displayUrl
45
+ );
46
+ }
47
+
48
+ function runQueuedPreviewFetches() {
49
+ while (
50
+ activePreviewFetches < MAX_CONCURRENT_PREVIEW_FETCHES &&
51
+ queuedPreviewFetches.length > 0
52
+ ) {
53
+ const next = queuedPreviewFetches.shift();
54
+ next?.();
55
+ }
56
+ }
57
+
58
+ function schedulePreviewFetch(task: () => Promise<LinkPreviewMetadata | null>) {
59
+ return new Promise<LinkPreviewMetadata | null>((resolve) => {
60
+ queuedPreviewFetches.push(() => {
61
+ activePreviewFetches += 1;
62
+ task()
63
+ .then(resolve)
64
+ .catch(() => resolve(null))
65
+ .finally(() => {
66
+ activePreviewFetches = Math.max(0, activePreviewFetches - 1);
67
+ runQueuedPreviewFetches();
68
+ });
69
+ });
70
+ runQueuedPreviewFetches();
71
+ });
72
+ }
73
+
74
+ function fetchPreview(entry: TweetUrlEntity) {
75
+ const targetUrl = entry.expandedUrl || entry.url;
76
+ if (!targetUrl) return Promise.resolve(null);
77
+ const key = `${entry.url} ${targetUrl}`;
78
+ const cached = previewCache.get(key);
79
+ if (cached) return cached;
80
+
81
+ const params = new URLSearchParams({ url: targetUrl });
82
+ if (entry.url && entry.url !== targetUrl) {
83
+ params.set("shortUrl", entry.url);
84
+ }
85
+ const promise = schedulePreviewFetch(() =>
86
+ fetch(`/api/link-preview?${params.toString()}`)
87
+ .then(async (response) => {
88
+ if (!response.ok) return null;
89
+ const data = (await response.json()) as {
90
+ ok?: boolean;
91
+ preview?: LinkPreviewMetadata;
92
+ };
93
+ return data.ok ? (data.preview ?? null) : null;
94
+ })
95
+ .catch(() => null),
96
+ );
97
+ previewCache.set(key, promise);
98
+ return promise;
99
+ }
100
+
101
+ function displayHost(url: string, fallback: string) {
102
+ try {
103
+ return new URL(url).hostname.replace(/^www\./, "");
104
+ } catch {
105
+ return fallback;
106
+ }
107
+ }
108
+
109
+ function isDirectImageUrl(url: string) {
110
+ try {
111
+ const parsed = new URL(url);
112
+ if (IMAGE_EXTENSION_PATTERN.test(parsed.pathname)) return true;
113
+ return (
114
+ parsed.hostname === "pbs.twimg.com" &&
115
+ (parsed.pathname.startsWith("/media/") ||
116
+ parsed.pathname.startsWith("/amplify_video_thumb/"))
117
+ );
118
+ } catch {
119
+ return IMAGE_EXTENSION_PATTERN.test(url);
120
+ }
121
+ }
122
+
123
+ export function LinkPreviewCard({
124
+ entry,
125
+ index,
126
+ }: {
127
+ entry: TweetUrlEntity;
128
+ index: number;
129
+ }) {
130
+ const targetUrl = entry.expandedUrl || entry.url;
131
+ const displayUrl = entry.displayUrl || displayHost(targetUrl, targetUrl);
132
+ const directImageUrl = isDirectImageUrl(targetUrl) ? targetUrl : null;
133
+ const initialPreview = useMemo<LinkPreviewState>(
134
+ () => ({
135
+ expandedUrl: targetUrl,
136
+ displayUrl,
137
+ title:
138
+ entry.title ??
139
+ (directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
140
+ description:
141
+ entry.description ?? (directImageUrl ? displayUrl : undefined),
142
+ imageUrl: entry.imageUrl ?? directImageUrl,
143
+ siteName:
144
+ entry.siteName ??
145
+ (directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
146
+ }),
147
+ [
148
+ directImageUrl,
149
+ displayUrl,
150
+ entry.description,
151
+ entry.imageUrl,
152
+ entry.siteName,
153
+ entry.title,
154
+ targetUrl,
155
+ ],
156
+ );
157
+ const [preview, setPreview] = useState(initialPreview);
158
+ const [imageFailed, setImageFailed] = useState(false);
159
+ const [hydratedKey, setHydratedKey] = useState("");
160
+ const [canHydrate, setCanHydrate] = useState(false);
161
+ const cardRef = useRef<HTMLAnchorElement | null>(null);
162
+ const cacheKey = `${entry.url} ${targetUrl}`;
163
+
164
+ useEffect(() => {
165
+ setPreview(initialPreview);
166
+ setImageFailed(false);
167
+ setHydratedKey("");
168
+ setCanHydrate(false);
169
+ }, [initialPreview]);
170
+
171
+ useEffect(() => {
172
+ if (!targetUrl) return;
173
+ if (!needsHydration(preview)) return;
174
+ const node = cardRef.current;
175
+ if (!node || typeof IntersectionObserver === "undefined") {
176
+ setCanHydrate(true);
177
+ return;
178
+ }
179
+ const observer = new IntersectionObserver(
180
+ (entries) => {
181
+ if (entries.some((entry) => entry.isIntersecting)) {
182
+ setCanHydrate(true);
183
+ observer.disconnect();
184
+ }
185
+ },
186
+ { rootMargin: "320px 0px" },
187
+ );
188
+ observer.observe(node);
189
+ return () => observer.disconnect();
190
+ }, [preview, targetUrl]);
191
+
192
+ useEffect(() => {
193
+ if (!targetUrl || !canHydrate) return;
194
+ if (!needsHydration(preview)) return;
195
+ if (hydratedKey === cacheKey) return;
196
+ let cancelled = false;
197
+ const timer = window.setTimeout(() => {
198
+ setHydratedKey(cacheKey);
199
+ void fetchPreview(entry).then((metadata) => {
200
+ if (cancelled || !metadata) return;
201
+ setPreview((current) => ({
202
+ expandedUrl: metadata.url || current.expandedUrl,
203
+ displayUrl: current.displayUrl,
204
+ title: metadata.title ?? current.title,
205
+ description: metadata.description ?? current.description,
206
+ imageUrl: metadata.imageUrl ?? current.imageUrl,
207
+ siteName: metadata.siteName ?? current.siteName,
208
+ }));
209
+ });
210
+ }, 100);
211
+ return () => {
212
+ cancelled = true;
213
+ window.clearTimeout(timer);
214
+ };
215
+ }, [cacheKey, canHydrate, entry, hydratedKey, preview, targetUrl]);
216
+
217
+ const title = preview.title || entry.displayUrl;
218
+ const description =
219
+ preview.description && preview.description !== title
220
+ ? preview.description
221
+ : preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
222
+ const host =
223
+ preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
224
+ const showImage = Boolean(preview.imageUrl && !imageFailed);
225
+
226
+ return (
227
+ <a
228
+ key={`${entry.expandedUrl}-${String(index)}`}
229
+ className={linkPreviewCardClass}
230
+ data-perf="link-preview-card"
231
+ href={preview.expandedUrl}
232
+ ref={cardRef}
233
+ rel="noreferrer"
234
+ target="_blank"
235
+ >
236
+ <div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
237
+ <div className="flex min-w-0 items-center gap-2">
238
+ <span className={linkPreviewHostClass}>{host}</span>
239
+ <ExternalLink
240
+ aria-hidden="true"
241
+ className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
242
+ strokeWidth={1.8}
243
+ />
244
+ </div>
245
+ <span className={linkPreviewTitleClass}>{title}</span>
246
+ <span className={linkPreviewDescClass}>{description}</span>
247
+ <span className={cx(linkPreviewHostClass, "text-[12px]")}>
248
+ {entry.displayUrl}
249
+ </span>
250
+ </div>
251
+ <div className="flex aspect-[1.45] w-40 shrink-0 items-center justify-center overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
252
+ {showImage ? (
253
+ <img
254
+ alt={title}
255
+ className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
256
+ loading="lazy"
257
+ onError={() => setImageFailed(true)}
258
+ src={preview.imageUrl ?? ""}
259
+ />
260
+ ) : (
261
+ <ImageIcon
262
+ aria-hidden="true"
263
+ className="size-8 text-[var(--ink-soft)]"
264
+ strokeWidth={1.7}
265
+ />
266
+ )}
267
+ </div>
268
+ </a>
269
+ );
270
+ }
@@ -9,6 +9,7 @@ import {
9
9
  profilePreviewHandleClass,
10
10
  profilePreviewHeaderClass,
11
11
  profilePreviewMetaClass,
12
+ profilePreviewNameClass,
12
13
  profilePreviewTriggerClass,
13
14
  } from "#/lib/ui";
14
15
  import { AvatarChip } from "./AvatarChip";
@@ -40,12 +41,16 @@ export function ProfilePreview({
40
41
  name={profile.displayName}
41
42
  profileId={profile.id}
42
43
  />
43
- <span>
44
- <strong>{profile.displayName}</strong>
44
+ <span className="flex min-w-0 flex-col">
45
+ <span className={profilePreviewNameClass}>
46
+ {profile.displayName}
47
+ </span>
45
48
  <span className={profilePreviewHandleClass}>@{profile.handle}</span>
46
49
  </span>
47
50
  </span>
48
- <span className={profilePreviewBioClass}>{profile.bio}</span>
51
+ {profile.bio ? (
52
+ <span className={profilePreviewBioClass}>{profile.bio}</span>
53
+ ) : null}
49
54
  <span className={profilePreviewMetaClass}>
50
55
  {formatCompactNumber(profile.followersCount)} followers
51
56
  </span>
@@ -1,18 +1,17 @@
1
+ import { Search } from "lucide-react";
1
2
  import { useEffect, useMemo, useState } from "react";
2
3
  import { TimelineCard } from "#/components/TimelineCard";
3
4
  import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
4
5
  import {
5
- cx,
6
- eyebrowClass,
7
- feedPageClass,
8
- heroControlsClass,
9
- heroCopyClass,
10
- heroShellClass,
11
- heroTitleClass,
12
- pageWrapClass,
13
- textFieldClass,
14
- textFieldWideClass,
15
- timelineLaneClass,
6
+ emptyStateClass,
7
+ feedClass,
8
+ pageHeaderClass,
9
+ pageHeaderRowClass,
10
+ pageSubtitleClass,
11
+ pageTitleClass,
12
+ searchFieldIconClass,
13
+ searchFieldInputClass,
14
+ searchFieldShellClass,
16
15
  } from "#/lib/ui";
17
16
 
18
17
  interface SavedTimelineViewProps {
@@ -23,9 +22,13 @@ interface SavedTimelineViewProps {
23
22
  searchPlaceholder: string;
24
23
  }
25
24
 
25
+ const TITLES: Record<SavedTimelineViewProps["filter"], string> = {
26
+ liked: "Likes",
27
+ bookmarked: "Bookmarks",
28
+ };
29
+
26
30
  export function SavedTimelineView({
27
31
  filter,
28
- eyebrow,
29
32
  title,
30
33
  loadingLabel,
31
34
  searchPlaceholder,
@@ -57,9 +60,11 @@ export function SavedTimelineView({
57
60
 
58
61
  const subtitle = useMemo(() => {
59
62
  if (!meta) {
60
- return items.length > 0 ? `${items.length} visible` : loadingLabel;
63
+ return items.length > 0
64
+ ? `${String(items.length)} visible`
65
+ : loadingLabel;
61
66
  }
62
- return `${items.length} visible · ${meta.transport.statusText}`;
67
+ return `${String(items.length)} visible · ${meta.transport.statusText}`;
63
68
  }, [items.length, loadingLabel, meta]);
64
69
 
65
70
  async function replyToTweet(tweetId: string) {
@@ -81,35 +86,40 @@ export function SavedTimelineView({
81
86
  }
82
87
 
83
88
  return (
84
- <main className={pageWrapClass}>
85
- <div className={feedPageClass}>
86
- <section className={heroShellClass}>
87
- <div>
88
- <p className={eyebrowClass}>{eyebrow}</p>
89
- <h2 className={heroTitleClass}>{title}</h2>
90
- <p className={heroCopyClass}>{subtitle}</p>
89
+ <>
90
+ <header className={pageHeaderClass}>
91
+ <div className={pageHeaderRowClass}>
92
+ <div className="flex min-w-0 flex-col">
93
+ <h1 className={pageTitleClass}>{TITLES[filter]}</h1>
94
+ <p className={pageSubtitleClass}>{title}</p>
95
+ <p className={pageSubtitleClass}>{subtitle}</p>
91
96
  </div>
92
- <div className={heroControlsClass}>
97
+ </div>
98
+ <div className="px-4 pb-3">
99
+ <label className={searchFieldShellClass}>
100
+ <Search className={searchFieldIconClass} strokeWidth={2} />
93
101
  <input
94
- className={cx(textFieldClass, textFieldWideClass)}
102
+ className={searchFieldInputClass}
95
103
  onChange={(event) => setSearch(event.target.value)}
96
104
  placeholder={searchPlaceholder}
97
105
  value={search}
98
106
  />
99
- </div>
100
- </section>
101
-
102
- <section className={timelineLaneClass}>
103
- {items.map((item) => (
104
- <TimelineCard
105
- key={item.id}
106
- item={item}
107
- onReply={replyToTweet}
108
- showReplyControls={false}
109
- />
110
- ))}
111
- </section>
112
- </div>
113
- </main>
107
+ </label>
108
+ </div>
109
+ </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>
123
+ </>
114
124
  );
115
125
  }