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,24 +1,36 @@
1
+ import { useState } from "react";
2
+ import {
3
+ Bookmark,
4
+ BookmarkCheck,
5
+ Heart,
6
+ MessageCircle,
7
+ Repeat2,
8
+ } from "lucide-react";
1
9
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
- import type { TimelineItem } from "#/lib/types";
10
+ import type { EmbeddedTweet, TimelineItem } from "#/lib/types";
3
11
  import {
4
- actionButtonClass,
5
- cardFooterClass,
6
- cardHeaderClass,
7
- contentCardClass,
8
12
  cx,
9
- identityBlockClass,
10
- identityRowClass,
11
- linkPreviewCardClass,
12
- metaStackClass,
13
- metricRowClass,
13
+ embeddedCardClass,
14
+ feedActionButtonClass,
15
+ feedActionIconClass,
16
+ feedActionIconWrapClass,
17
+ feedActionIconWrapLikeClass,
18
+ feedActionLikeClass,
19
+ feedRowActionsClass,
20
+ feedRowBodyClass,
21
+ feedRowClass,
22
+ feedRowDotClass,
23
+ feedRowHandleClass,
24
+ feedRowHeaderClass,
25
+ feedRowNameClass,
26
+ feedRowTextClass,
27
+ feedRowTimestampClass,
14
28
  mutedDotClass,
15
- pillAlertClass,
16
- pillClass,
17
- pillSoftClass,
18
- timestampClass,
19
29
  } from "#/lib/ui";
20
30
  import { AvatarChip } from "./AvatarChip";
31
+ import { ConversationThread } from "./ConversationThread";
21
32
  import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
33
+ import { LinkPreviewCard } from "./LinkPreviewCard";
22
34
  import { ProfilePreview } from "./ProfilePreview";
23
35
  import { TweetMediaGrid } from "./TweetMediaGrid";
24
36
  import { TweetRichText } from "./TweetRichText";
@@ -31,6 +43,13 @@ function getVisibleUrlCards(item: TimelineItem) {
31
43
  });
32
44
  }
33
45
 
46
+ function isInteractiveTarget(target: EventTarget | null) {
47
+ return (
48
+ target instanceof Element &&
49
+ Boolean(target.closest("a,button,input,textarea,select,[role='button']"))
50
+ );
51
+ }
52
+
34
53
  export function TimelineCard({
35
54
  item,
36
55
  onReply,
@@ -42,86 +61,211 @@ export function TimelineCard({
42
61
  }) {
43
62
  const canReply =
44
63
  showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
64
+ const [conversationOpen, setConversationOpen] = useState(false);
65
+ const [conversationLoading, setConversationLoading] = useState(false);
66
+ const [conversationError, setConversationError] = useState<string | null>(
67
+ null,
68
+ );
69
+ const [conversationItems, setConversationItems] = useState<EmbeddedTweet[]>(
70
+ [],
71
+ );
72
+
73
+ async function loadConversation() {
74
+ if (conversationLoading || conversationItems.length > 0) return;
75
+ setConversationLoading(true);
76
+ setConversationError(null);
77
+ try {
78
+ const response = await fetch(
79
+ `/api/conversation?tweetId=${encodeURIComponent(item.id)}`,
80
+ );
81
+ const data = (await response.json()) as {
82
+ ok?: boolean;
83
+ error?: string;
84
+ items?: EmbeddedTweet[];
85
+ };
86
+ if (!response.ok || data.ok === false) {
87
+ throw new Error(data.error ?? "Conversation unavailable");
88
+ }
89
+ setConversationItems((data.items ?? []).filter(Boolean));
90
+ } catch (error) {
91
+ setConversationError(
92
+ error instanceof Error ? error.message : "Conversation unavailable",
93
+ );
94
+ } finally {
95
+ setConversationLoading(false);
96
+ }
97
+ }
98
+
99
+ function toggleConversation() {
100
+ const nextOpen = !conversationOpen;
101
+ setConversationOpen(nextOpen);
102
+ if (nextOpen) {
103
+ void loadConversation();
104
+ }
105
+ }
45
106
 
46
107
  return (
47
- <article className={contentCardClass}>
48
- <header className={cardHeaderClass}>
49
- <div className={identityBlockClass}>
50
- <AvatarChip
51
- avatarUrl={item.author.avatarUrl}
52
- hue={item.author.avatarHue}
53
- name={item.author.displayName}
54
- profileId={item.author.id}
108
+ <article
109
+ className={cx(feedRowClass, "cursor-pointer")}
110
+ data-perf="timeline-card"
111
+ onClick={(event) => {
112
+ if (isInteractiveTarget(event.target)) return;
113
+ toggleConversation();
114
+ }}
115
+ >
116
+ <AvatarChip
117
+ avatarUrl={item.author.avatarUrl}
118
+ hue={item.author.avatarHue}
119
+ name={item.author.displayName}
120
+ profileId={item.author.id}
121
+ />
122
+ <div className={feedRowBodyClass}>
123
+ <header className={feedRowHeaderClass}>
124
+ <ProfilePreview profile={item.author}>
125
+ <span className="flex min-w-0 items-center gap-1.5">
126
+ <span className={feedRowNameClass}>
127
+ {item.author.displayName}
128
+ </span>
129
+ <span className={feedRowHandleClass}>@{item.author.handle}</span>
130
+ </span>
131
+ </ProfilePreview>
132
+ <span className={feedRowDotClass}>·</span>
133
+ <span className={feedRowTimestampClass}>
134
+ {formatShortTimestamp(item.createdAt)}
135
+ </span>
136
+ {canReply ? (
137
+ <span className="ml-auto inline-flex items-center gap-1 text-[12px] text-[var(--ink-soft)]">
138
+ {item.isReplied ? (
139
+ <span className="text-[var(--accent)]">replied</span>
140
+ ) : (
141
+ <span className="text-[var(--alert)]">needs reply</span>
142
+ )}
143
+ </span>
144
+ ) : null}
145
+ </header>
146
+ <TweetRichText
147
+ className={feedRowTextClass}
148
+ entities={item.entities}
149
+ text={item.text}
150
+ />
151
+ <TweetMediaGrid items={item.media} />
152
+ {item.replyToTweet ? (
153
+ <div className={embeddedCardClass}>
154
+ <EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
155
+ </div>
156
+ ) : null}
157
+ {item.quotedTweet ? (
158
+ <div className={embeddedCardClass}>
159
+ <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
160
+ </div>
161
+ ) : null}
162
+ {getVisibleUrlCards(item).map((entry, index) => (
163
+ <LinkPreviewCard
164
+ key={`${entry.expandedUrl}-${String(index)}`}
165
+ entry={entry}
166
+ index={index}
55
167
  />
56
- <div>
57
- <ProfilePreview profile={item.author}>
58
- <div className={identityRowClass}>
59
- <strong>{item.author.displayName}</strong>
60
- <span>@{item.author.handle}</span>
61
- <span className={mutedDotClass} />
62
- <span>
63
- {formatCompactNumber(item.author.followersCount)} followers
168
+ ))}
169
+ <footer className={feedRowActionsClass}>
170
+ <div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
171
+ <button
172
+ aria-expanded={conversationOpen}
173
+ aria-label={
174
+ conversationOpen ? "Hide conversation" : "Show conversation"
175
+ }
176
+ className={feedActionButtonClass}
177
+ onClick={(event) => {
178
+ event.stopPropagation();
179
+ toggleConversation();
180
+ }}
181
+ type="button"
182
+ >
183
+ <span className={feedActionIconWrapClass}>
184
+ <MessageCircle
185
+ className={feedActionIconClass}
186
+ strokeWidth={1.7}
187
+ />
188
+ </span>
189
+ <span className="text-[13px]">
190
+ {conversationOpen ? "Hide thread" : "Thread"}
191
+ </span>
192
+ </button>
193
+ {canReply ? (
194
+ <button
195
+ className={feedActionButtonClass}
196
+ onClick={(event) => {
197
+ event.stopPropagation();
198
+ onReply(item.id);
199
+ }}
200
+ type="button"
201
+ aria-label="Reply"
202
+ >
203
+ <span className={feedActionIconWrapClass}>
204
+ <MessageCircle
205
+ className={feedActionIconClass}
206
+ strokeWidth={1.7}
207
+ />
64
208
  </span>
65
- </div>
66
- </ProfilePreview>
67
- </div>
68
- </div>
69
- <div className={metaStackClass}>
70
- {canReply ? (
209
+ <span className="text-[13px]">Reply</span>
210
+ </button>
211
+ ) : null}
212
+ <span className={cx(feedActionButtonClass, "cursor-default")}>
213
+ <span className={feedActionIconWrapClass}>
214
+ <Repeat2 className={feedActionIconClass} strokeWidth={1.7} />
215
+ </span>
216
+ </span>
71
217
  <span
72
218
  className={cx(
73
- pillClass,
74
- item.isReplied ? pillSoftClass : pillAlertClass,
219
+ feedActionButtonClass,
220
+ feedActionLikeClass,
221
+ "cursor-default",
222
+ item.liked && "text-[var(--like)]",
75
223
  )}
76
224
  >
77
- {item.isReplied ? "replied" : "needs reply"}
225
+ <span
226
+ className={cx(
227
+ feedActionIconWrapClass,
228
+ feedActionIconWrapLikeClass,
229
+ )}
230
+ >
231
+ <Heart
232
+ className={feedActionIconClass}
233
+ strokeWidth={1.7}
234
+ fill={item.liked ? "currentColor" : "none"}
235
+ />
236
+ </span>
237
+ <span>{formatCompactNumber(item.likeCount)}</span>
78
238
  </span>
79
- ) : null}
80
- <span className={timestampClass}>
81
- {formatShortTimestamp(item.createdAt)}
82
- </span>
83
- </div>
84
- </header>
85
- <TweetRichText entities={item.entities} text={item.text} />
86
- <TweetMediaGrid items={item.media} />
87
- {item.replyToTweet ? (
88
- <EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
89
- ) : null}
90
- {item.quotedTweet ? (
91
- <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
92
- ) : null}
93
- {getVisibleUrlCards(item).map((entry, index) => (
94
- <a
95
- key={`${entry.expandedUrl}-${String(index)}`}
96
- className={linkPreviewCardClass}
97
- href={entry.expandedUrl}
98
- rel="noreferrer"
99
- target="_blank"
100
- >
101
- <strong>{entry.title ?? entry.displayUrl}</strong>
102
- <span className="text-[var(--ink-soft)]">
103
- {entry.description ?? entry.displayUrl}
104
- </span>
105
- <span className={timestampClass}>{entry.displayUrl}</span>
106
- </a>
107
- ))}
108
- <footer className={cardFooterClass}>
109
- <div className={metricRowClass}>
110
- <span>{formatCompactNumber(item.likeCount)} likes</span>
111
- <span>{item.mediaCount} media</span>
112
- <span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
113
- <span>{item.accountHandle}</span>
114
- </div>
115
- {canReply ? (
116
- <button
117
- className={actionButtonClass}
118
- onClick={() => onReply(item.id)}
119
- type="button"
120
- >
121
- Reply
122
- </button>
239
+ <span className={cx(feedActionButtonClass, "cursor-default")}>
240
+ <span className={feedActionIconWrapClass}>
241
+ {item.bookmarked ? (
242
+ <BookmarkCheck
243
+ className={feedActionIconClass}
244
+ strokeWidth={1.7}
245
+ />
246
+ ) : (
247
+ <Bookmark className={feedActionIconClass} strokeWidth={1.7} />
248
+ )}
249
+ </span>
250
+ </span>
251
+ </div>
252
+ <div className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
253
+ <span>{item.mediaCount} media</span>
254
+ <span className={mutedDotClass} />
255
+ <span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
256
+ <span className={mutedDotClass} />
257
+ <span>{item.accountHandle}</span>
258
+ </div>
259
+ </footer>
260
+ {conversationOpen ? (
261
+ <ConversationThread
262
+ anchorId={item.id}
263
+ error={conversationError}
264
+ items={conversationItems}
265
+ loading={conversationLoading}
266
+ />
123
267
  ) : null}
124
- </footer>
268
+ </div>
125
269
  </article>
126
270
  );
127
271
  }
@@ -1,5 +1,8 @@
1
1
  import { Fragment } from "react";
2
- import { collectTweetSegments } from "#/lib/tweet-render";
2
+ import {
3
+ collectTweetSegments,
4
+ enrichFallbackUrlEntities,
5
+ } from "#/lib/tweet-render";
3
6
  import type { TweetEntities } from "#/lib/types";
4
7
  import {
5
8
  bodyCopyClass,
@@ -18,7 +21,8 @@ export function TweetRichText({
18
21
  entities: TweetEntities;
19
22
  className?: string;
20
23
  }) {
21
- const segments = collectTweetSegments(entities);
24
+ const richEntities = enrichFallbackUrlEntities(text, entities);
25
+ const segments = collectTweetSegments(richEntities);
22
26
  let cursor = 0;
23
27
 
24
28
  return (