birdclaw 0.4.1 → 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 +33 -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 +450 -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 +1565 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +316 -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 +191 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +11 -98
  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 +185 -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 +20 -3
  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,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { Sparkles } from "lucide-react";
2
3
  import { useEffect, useMemo, useState } from "react";
3
4
  import { InboxCard } from "#/components/InboxCard";
4
5
  import type {
@@ -8,21 +9,19 @@ import type {
8
9
  QueryEnvelope,
9
10
  } from "#/lib/types";
10
11
  import {
11
- actionButtonClass,
12
12
  cx,
13
- eyebrowClass,
14
- feedPageClass,
15
- heroControlsClass,
16
- heroCopyClass,
17
- heroShellClass,
18
- heroTitleClass,
19
- inboxLaneClass,
20
- navLinkActiveClass,
21
- navLinkClass,
22
- pageWrapClass,
23
- segmentActiveClass,
24
- segmentClass,
25
- segmentedClass,
13
+ emptyStateClass,
14
+ feedClass,
15
+ pageHeaderClass,
16
+ pageHeaderRowClass,
17
+ pageSubtitleClass,
18
+ pageTitleClass,
19
+ primaryButtonClass,
20
+ secondaryButtonClass,
21
+ tabButtonActiveClass,
22
+ tabButtonClass,
23
+ tabButtonIndicatorClass,
24
+ tabStripClass,
26
25
  textFieldClass,
27
26
  textFieldShortClass,
28
27
  timestampClass,
@@ -32,6 +31,12 @@ export const Route = createFileRoute("/inbox")({
32
31
  component: InboxRoute,
33
32
  });
34
33
 
34
+ const TABS: Array<{ value: InboxKind; label: string }> = [
35
+ { value: "mixed", label: "Mixed" },
36
+ { value: "mentions", label: "Mentions" },
37
+ { value: "dms", label: "DMs" },
38
+ ];
39
+
35
40
  function InboxRoute() {
36
41
  const [meta, setMeta] = useState<QueryEnvelope | null>(null);
37
42
  const [items, setItems] = useState<InboxItem[]>([]);
@@ -70,7 +75,7 @@ function InboxRoute() {
70
75
 
71
76
  const subtitle = useMemo(() => {
72
77
  if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
73
- return `${stats.total} items in queue · ${stats.openai} OpenAI scored · ${meta.transport.statusText}`;
78
+ return `${String(stats.total)} in queue · ${String(stats.openai)} OpenAI scored · ${meta.transport.statusText}`;
74
79
  }, [meta, stats]);
75
80
 
76
81
  async function scoreNow() {
@@ -122,80 +127,87 @@ function InboxRoute() {
122
127
  }
123
128
 
124
129
  return (
125
- <main className={pageWrapClass}>
126
- <div className={feedPageClass}>
127
- <section className={heroShellClass}>
128
- <div>
129
- <p className={eyebrowClass}>inbox</p>
130
- <h2 className={heroTitleClass}>AI triage for mentions and DMs.</h2>
131
- <p className={heroCopyClass}>{subtitle}</p>
132
- </div>
133
- <div className={heroControlsClass}>
134
- <div className={segmentedClass}>
135
- {(["mixed", "mentions", "dms"] as const).map((value) => (
136
- <button
137
- key={value}
138
- className={cx(
139
- segmentClass,
140
- value === kind && segmentActiveClass,
141
- )}
142
- onClick={() => setKind(value)}
143
- type="button"
144
- >
145
- {value}
146
- </button>
147
- ))}
148
- </div>
149
- <input
150
- className={cx(textFieldClass, textFieldShortClass)}
151
- inputMode="numeric"
152
- onChange={(event) => setMinScore(event.target.value)}
153
- placeholder="Min AI score"
154
- value={minScore}
155
- />
156
- <button
157
- className={cx(navLinkClass, hideLowSignal && navLinkActiveClass)}
158
- onClick={() => setHideLowSignal((value) => !value)}
159
- type="button"
160
- >
161
- {hideLowSignal ? "Hide low-signal" : "Show all"}
162
- </button>
163
- <button
164
- className={actionButtonClass}
165
- disabled={isScoring}
166
- onClick={() => void scoreNow()}
167
- type="button"
168
- >
169
- {isScoring ? "Scoring..." : "Score with OpenAI"}
170
- </button>
130
+ <>
131
+ <header className={pageHeaderClass}>
132
+ <div className={pageHeaderRowClass}>
133
+ <div className="flex min-w-0 flex-col">
134
+ <h1 className={pageTitleClass}>Inbox</h1>
135
+ <p className={pageSubtitleClass}>{subtitle}</p>
171
136
  </div>
172
- </section>
173
-
174
- <section className={inboxLaneClass}>
175
- {items.map((item) => (
176
- <InboxCard
177
- key={item.id}
178
- isReplying={activeReplyId === item.id}
179
- item={item}
180
- onReplyChange={setReplyDraft}
181
- onReplySend={() => void sendReply(item)}
182
- onReplyToggle={() => {
183
- if (activeReplyId === item.id) {
184
- setActiveReplyId(null);
185
- setReplyDraft("");
186
- return;
187
- }
188
- setActiveReplyId(item.id);
137
+ <button
138
+ className={primaryButtonClass}
139
+ disabled={isScoring}
140
+ onClick={() => void scoreNow()}
141
+ type="button"
142
+ >
143
+ <Sparkles className="size-4" strokeWidth={2.2} />
144
+ {isScoring ? "Scoring..." : "Score with OpenAI"}
145
+ </button>
146
+ </div>
147
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
148
+ <input
149
+ className={cx(textFieldClass, textFieldShortClass)}
150
+ inputMode="numeric"
151
+ onChange={(event) => setMinScore(event.target.value)}
152
+ placeholder="Min AI score"
153
+ value={minScore}
154
+ />
155
+ <button
156
+ className={secondaryButtonClass}
157
+ onClick={() => setHideLowSignal((value) => !value)}
158
+ type="button"
159
+ aria-pressed={hideLowSignal}
160
+ >
161
+ {hideLowSignal ? "Hide low-signal" : "Show all"}
162
+ </button>
163
+ </div>
164
+ <div className={tabStripClass}>
165
+ {TABS.map((tab) => {
166
+ const active = kind === tab.value;
167
+ return (
168
+ <button
169
+ key={tab.value}
170
+ type="button"
171
+ aria-pressed={active}
172
+ className={cx(tabButtonClass, active && tabButtonActiveClass)}
173
+ onClick={() => setKind(tab.value)}
174
+ >
175
+ <span className="relative inline-flex flex-col items-center justify-center py-1">
176
+ {tab.value}
177
+ {active ? <span className={tabButtonIndicatorClass} /> : null}
178
+ </span>
179
+ </button>
180
+ );
181
+ })}
182
+ </div>
183
+ </header>
184
+ <section className={feedClass}>
185
+ {items.length === 0 ? (
186
+ <div className={emptyStateClass}>Inbox clear.</div>
187
+ ) : null}
188
+ {items.map((item) => (
189
+ <InboxCard
190
+ key={item.id}
191
+ isReplying={activeReplyId === item.id}
192
+ item={item}
193
+ onReplyChange={setReplyDraft}
194
+ onReplySend={() => void sendReply(item)}
195
+ onReplyToggle={() => {
196
+ if (activeReplyId === item.id) {
197
+ setActiveReplyId(null);
189
198
  setReplyDraft("");
190
- }}
191
- replyDraft={activeReplyId === item.id ? replyDraft : ""}
192
- />
193
- ))}
194
- </section>
195
- </div>
199
+ return;
200
+ }
201
+ setActiveReplyId(item.id);
202
+ setReplyDraft("");
203
+ }}
204
+ replyDraft={activeReplyId === item.id ? replyDraft : ""}
205
+ />
206
+ ))}
207
+ </section>
196
208
  {isSendingReply ? (
197
- <p className={timestampClass}>Sending reply...</p>
209
+ <p className={cx(timestampClass, "px-4 py-2")}>Sending reply...</p>
198
210
  ) : null}
199
- </main>
211
+ </>
200
212
  );
201
213
  }
@@ -1,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { Search } from "lucide-react";
2
3
  import { useEffect, useMemo, useState } from "react";
3
4
  import { TimelineCard } from "#/components/TimelineCard";
4
5
  import type {
@@ -9,25 +10,31 @@ import type {
9
10
  } from "#/lib/types";
10
11
  import {
11
12
  cx,
12
- eyebrowClass,
13
- feedPageClass,
14
- heroControlsClass,
15
- heroCopyClass,
16
- heroShellClass,
17
- heroTitleClass,
18
- pageWrapClass,
19
- segmentActiveClass,
20
- segmentClass,
21
- segmentedClass,
22
- textFieldClass,
23
- textFieldWideClass,
24
- timelineLaneClass,
13
+ emptyStateClass,
14
+ feedClass,
15
+ pageHeaderClass,
16
+ pageHeaderRowClass,
17
+ pageSubtitleClass,
18
+ pageTitleClass,
19
+ searchFieldIconClass,
20
+ searchFieldInputClass,
21
+ searchFieldShellClass,
22
+ tabButtonActiveClass,
23
+ tabButtonClass,
24
+ tabButtonIndicatorClass,
25
+ tabStripClass,
25
26
  } from "#/lib/ui";
26
27
 
27
28
  export const Route = createFileRoute("/")({
28
29
  component: HomeRoute,
29
30
  });
30
31
 
32
+ const TABS: Array<{ value: ReplyFilter; label: string }> = [
33
+ { value: "all", label: "All" },
34
+ { value: "unreplied", label: "Unreplied" },
35
+ { value: "replied", label: "Replied" },
36
+ ];
37
+
31
38
  function HomeRoute() {
32
39
  const [meta, setMeta] = useState<QueryEnvelope | null>(null);
33
40
  const [items, setItems] = useState<TimelineItem[]>([]);
@@ -57,7 +64,7 @@ function HomeRoute() {
57
64
 
58
65
  const subtitle = useMemo(() => {
59
66
  if (!meta) return "Loading local context...";
60
- return `${meta.stats.home} home items · ${meta.stats.needsReply} waiting on action · ${meta.transport.statusText}`;
67
+ return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
61
68
  }, [meta]);
62
69
 
63
70
  async function replyToTweet(tweetId: string) {
@@ -79,47 +86,53 @@ function HomeRoute() {
79
86
  }
80
87
 
81
88
  return (
82
- <main className={pageWrapClass}>
83
- <div className={feedPageClass}>
84
- <section className={heroShellClass}>
85
- <div>
86
- <p className={eyebrowClass}>home timeline</p>
87
- <h2 className={heroTitleClass}>
88
- Read first. Act only where signal survives.
89
- </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}>Home</h1>
94
+ <p className={pageSubtitleClass}>{subtitle}</p>
91
95
  </div>
92
- <div className={heroControlsClass}>
96
+ </div>
97
+ <div className="px-4 pb-3">
98
+ <label className={searchFieldShellClass}>
99
+ <Search className={searchFieldIconClass} strokeWidth={2} />
93
100
  <input
94
- className={cx(textFieldClass, textFieldWideClass)}
101
+ className={searchFieldInputClass}
95
102
  onChange={(event) => setSearch(event.target.value)}
96
103
  placeholder="Search local timeline"
97
104
  value={search}
98
105
  />
99
- <div className={segmentedClass}>
100
- {(["all", "replied", "unreplied"] as const).map((value) => (
101
- <button
102
- key={value}
103
- className={cx(
104
- segmentClass,
105
- value === replyFilter && segmentActiveClass,
106
- )}
107
- onClick={() => setReplyFilter(value)}
108
- type="button"
109
- >
110
- {value}
111
- </button>
112
- ))}
113
- </div>
114
- </div>
115
- </section>
116
-
117
- <section className={timelineLaneClass}>
118
- {items.map((item) => (
119
- <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
120
- ))}
121
- </section>
122
- </div>
123
- </main>
106
+ </label>
107
+ </div>
108
+ <div className={tabStripClass}>
109
+ {TABS.map((tab) => {
110
+ const active = replyFilter === tab.value;
111
+ return (
112
+ <button
113
+ key={tab.value}
114
+ type="button"
115
+ aria-pressed={active}
116
+ className={cx(tabButtonClass, active && tabButtonActiveClass)}
117
+ onClick={() => setReplyFilter(tab.value)}
118
+ >
119
+ <span className="relative inline-flex flex-col items-center justify-center py-1">
120
+ {tab.value}
121
+ {active ? <span className={tabButtonIndicatorClass} /> : null}
122
+ </span>
123
+ </button>
124
+ );
125
+ })}
126
+ </div>
127
+ </header>
128
+ <section className={feedClass}>
129
+ {items.length === 0 ? (
130
+ <div className={emptyStateClass}>No posts to show.</div>
131
+ ) : null}
132
+ {items.map((item) => (
133
+ <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
134
+ ))}
135
+ </section>
136
+ </>
124
137
  );
125
138
  }