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.
- package/CHANGELOG.md +33 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/src/cli.ts +450 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1565 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/db.ts +191 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +233 -7
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
219
|
+
feedActionButtonClass,
|
|
220
|
+
feedActionLikeClass,
|
|
221
|
+
"cursor-default",
|
|
222
|
+
item.liked && "text-[var(--like)]",
|
|
75
223
|
)}
|
|
76
224
|
>
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
</
|
|
268
|
+
</div>
|
|
125
269
|
</article>
|
|
126
270
|
);
|
|
127
271
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Fragment } from "react";
|
|
2
|
-
import {
|
|
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
|
|
24
|
+
const richEntities = enrichFallbackUrlEntities(text, entities);
|
|
25
|
+
const segments = collectTweetSegments(richEntities);
|
|
22
26
|
let cursor = 0;
|
|
23
27
|
|
|
24
28
|
return (
|