birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -10,6 +10,14 @@ type TweetSegment =
10
10
  | ({ kind: "url" } & TweetUrlEntity)
11
11
  | ({ kind: "hashtag" } & TweetHashtagEntity);
12
12
 
13
+ type UrlExpansion = Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
14
+ Partial<
15
+ Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
16
+ >;
17
+
18
+ const RAW_URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g;
19
+ const TRAILING_URL_PUNCTUATION = /[),.;:!?]+$/;
20
+
13
21
  const MARKDOWN_ESCAPE_CHARACTERS = new Set([
14
22
  "\\",
15
23
  "`",
@@ -38,6 +46,95 @@ function escapeMarkdown(text: string) {
38
46
  .join("");
39
47
  }
40
48
 
49
+ export function displayUrlForLink(url: string) {
50
+ try {
51
+ const parsed = new URL(url);
52
+ const host = parsed.hostname.replace(/^www\./, "");
53
+ const suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
54
+ return suffix === "/" ? host : `${host}${suffix}`;
55
+ } catch {
56
+ return url;
57
+ }
58
+ }
59
+
60
+ function spansOverlap(
61
+ leftStart: number,
62
+ leftEnd: number,
63
+ rightStart: number,
64
+ rightEnd: number,
65
+ ) {
66
+ return leftStart < rightEnd && rightStart < leftEnd;
67
+ }
68
+
69
+ export function enrichFallbackUrlEntities(
70
+ text: string,
71
+ entities: TweetEntities,
72
+ resolveExpansion?: (rawUrl: string) => UrlExpansion | null | undefined,
73
+ ): TweetEntities {
74
+ const existingUrls = entities.urls ?? [];
75
+ const enrichedExistingUrls = existingUrls.map((entry) => {
76
+ const expansion = resolveExpansion?.(entry.url);
77
+ if (!expansion) return entry;
78
+ const expandedUrl = expansion.expandedUrl || entry.expandedUrl;
79
+ return {
80
+ ...entry,
81
+ expandedUrl,
82
+ displayUrl: expansion.displayUrl || displayUrlForLink(expandedUrl),
83
+ ...(expansion.title ? { title: expansion.title } : {}),
84
+ ...(expansion && "description" in expansion
85
+ ? { description: expansion.description ?? null }
86
+ : {}),
87
+ ...(expansion.imageUrl ? { imageUrl: expansion.imageUrl } : {}),
88
+ ...(expansion.siteName ? { siteName: expansion.siteName } : {}),
89
+ };
90
+ });
91
+ const fallbackUrls: TweetUrlEntity[] = [];
92
+
93
+ for (const match of text.matchAll(RAW_URL_PATTERN)) {
94
+ const rawMatch = match[0];
95
+ const url = rawMatch.replace(TRAILING_URL_PUNCTUATION, "");
96
+ const start = match.index ?? 0;
97
+ const end = start + url.length;
98
+ if (!url) {
99
+ continue;
100
+ }
101
+ if (
102
+ enrichedExistingUrls.some((entry) =>
103
+ spansOverlap(start, end, entry.start, entry.end),
104
+ )
105
+ ) {
106
+ continue;
107
+ }
108
+
109
+ const expansion = resolveExpansion?.(url);
110
+ const expandedUrl = expansion?.expandedUrl || url;
111
+ fallbackUrls.push({
112
+ url,
113
+ expandedUrl,
114
+ displayUrl: expansion?.displayUrl || displayUrlForLink(expandedUrl),
115
+ start,
116
+ end,
117
+ ...(expansion?.title ? { title: expansion.title } : {}),
118
+ ...(expansion && "description" in expansion
119
+ ? { description: expansion.description ?? null }
120
+ : {}),
121
+ ...(expansion?.imageUrl ? { imageUrl: expansion.imageUrl } : {}),
122
+ ...(expansion?.siteName ? { siteName: expansion.siteName } : {}),
123
+ });
124
+ }
125
+
126
+ if (fallbackUrls.length === 0) {
127
+ return { ...entities, urls: enrichedExistingUrls };
128
+ }
129
+
130
+ return {
131
+ ...entities,
132
+ urls: [...enrichedExistingUrls, ...fallbackUrls].sort(
133
+ (left, right) => left.start - right.start,
134
+ ),
135
+ };
136
+ }
137
+
41
138
  export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
42
139
  return [
43
140
  ...(entities.mentions?.map((entry) => ({
package/src/lib/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ResourceKind = "home" | "mentions" | "dms";
1
+ export type ResourceKind = "home" | "mentions" | "authored" | "dms";
2
2
  export type InboxKind = "mixed" | "mentions" | "dms";
3
3
 
4
4
  export type ReplyFilter = "all" | "replied" | "unreplied";
@@ -88,6 +88,8 @@ export interface TweetUrlEntity {
88
88
  end: number;
89
89
  title?: string;
90
90
  description?: string | null;
91
+ imageUrl?: string | null;
92
+ siteName?: string | null;
91
93
  }
92
94
 
93
95
  export interface TweetHashtagEntity {
@@ -109,17 +111,29 @@ export interface TweetMediaItem {
109
111
  width?: number;
110
112
  height?: number;
111
113
  thumbnailUrl?: string;
114
+ durationMs?: number;
115
+ variants?: Array<{
116
+ url: string;
117
+ contentType?: string;
118
+ bitRate?: number;
119
+ }>;
112
120
  }
113
121
 
114
122
  export interface EmbeddedTweet {
115
123
  id: string;
116
124
  text: string;
117
125
  createdAt: string;
126
+ replyToId?: string | null;
118
127
  author: ProfileRecord;
119
128
  entities: TweetEntities;
120
129
  media: TweetMediaItem[];
121
130
  }
122
131
 
132
+ export interface TweetConversationResponse {
133
+ anchorId: string;
134
+ items: EmbeddedTweet[];
135
+ }
136
+
123
137
  export interface BlockItem {
124
138
  accountId: string;
125
139
  accountHandle: string;
@@ -143,10 +157,11 @@ export interface TimelineItem {
143
157
  id: string;
144
158
  accountId: string;
145
159
  accountHandle: string;
146
- kind: "home" | "mention" | "like" | "bookmark";
160
+ kind: "home" | "mention" | "authored" | "like" | "bookmark";
147
161
  text: string;
148
162
  searchSnippet?: string;
149
163
  createdAt: string;
164
+ replyToId?: string | null;
150
165
  isReplied: boolean;
151
166
  likeCount: number;
152
167
  mediaCount: number;
@@ -203,6 +218,8 @@ export interface LinkIndexItem {
203
218
  expandedHandle?: string | null;
204
219
  title?: string | null;
205
220
  description?: string | null;
221
+ imageUrl?: string | null;
222
+ siteName?: string | null;
206
223
  error?: string | null;
207
224
  source: string;
208
225
  updatedAt: string;
@@ -217,6 +234,86 @@ export interface LinkSearchItem {
217
234
  linkedTweet?: TimelineItem | null;
218
235
  }
219
236
 
237
+ export type LinkInsightKind = "links" | "videos";
238
+ export type LinkInsightRange = "today" | "week" | "month" | "year" | "all";
239
+ export type LinkInsightSort = "rank" | "recent" | "comments";
240
+ export type LinkInsightSource = "all" | "tweet" | "dm";
241
+
242
+ export interface LinkInsightMention {
243
+ id: string;
244
+ sourceKind: "dm" | "tweet";
245
+ sourceId: string;
246
+ sourceUrl?: string | null;
247
+ sourceLabel: string;
248
+ shortUrl: string;
249
+ conversationId?: string | null;
250
+ createdAt: string;
251
+ text: string;
252
+ rawText: string;
253
+ commentText: string;
254
+ sharedContentText?: string | null;
255
+ hasComment: boolean;
256
+ isPureShare: boolean;
257
+ timelineTweetId?: string | null;
258
+ contentTweetId?: string | null;
259
+ contentTweetUrl?: string | null;
260
+ contentAuthor?: ProfileRecord | null;
261
+ media: TweetMediaItem[];
262
+ direction?: string | null;
263
+ accountHandle?: string | null;
264
+ sharedBy?: ProfileRecord | null;
265
+ participant?: ProfileRecord | null;
266
+ }
267
+
268
+ export interface LinkInsightItem {
269
+ id: string;
270
+ kind: LinkInsightKind;
271
+ url: string;
272
+ canonicalKey: string;
273
+ displayUrl: string;
274
+ host: string;
275
+ title?: string | null;
276
+ description?: string | null;
277
+ shareCount: number;
278
+ uniqueSharers: number;
279
+ totalInfluence: number;
280
+ mentionCount: number;
281
+ commentCount: number;
282
+ pureShareCount: number;
283
+ hiddenMentionCount: number;
284
+ firstSeenAt: string;
285
+ lastSeenAt: string;
286
+ topSharer?: ProfileRecord | null;
287
+ sharers: ProfileRecord[];
288
+ mentions: LinkInsightMention[];
289
+ }
290
+
291
+ export interface LinkInsightQuery {
292
+ kind?: LinkInsightKind;
293
+ range?: LinkInsightRange;
294
+ sort?: LinkInsightSort;
295
+ source?: LinkInsightSource;
296
+ since?: string;
297
+ until?: string;
298
+ limit?: number;
299
+ commentsLimit?: number;
300
+ now?: Date;
301
+ }
302
+
303
+ export interface LinkInsightResponse {
304
+ kind: LinkInsightKind;
305
+ range: LinkInsightRange;
306
+ sort: LinkInsightSort;
307
+ source: LinkInsightSource;
308
+ since: string | null;
309
+ until: string | null;
310
+ items: LinkInsightItem[];
311
+ stats: {
312
+ occurrences: number;
313
+ groups: number;
314
+ };
315
+ }
316
+
220
317
  export interface DmSearchMatchItem {
221
318
  message: DmMessageItem;
222
319
  before: DmMessageItem[];
@@ -361,6 +458,8 @@ export interface XurlPublicMetrics {
361
458
  impression_count?: number;
362
459
  followers_count?: number;
363
460
  following_count?: number;
461
+ tweet_count?: number;
462
+ listed_count?: number;
364
463
  }
365
464
 
366
465
  export interface XurlMentionUser {
@@ -377,6 +476,7 @@ export interface XurlMentionUser {
377
476
  affiliation?: Record<string, unknown>;
378
477
  public_metrics?: XurlPublicMetrics;
379
478
  created_at?: string;
479
+ protected?: boolean;
380
480
  }
381
481
 
382
482
  export interface XurlMentionData {
@@ -385,6 +485,8 @@ export interface XurlMentionData {
385
485
  text: string;
386
486
  created_at: string;
387
487
  conversation_id?: string;
488
+ in_reply_to_user_id?: string;
489
+ attachments?: XurlTweetAttachments;
388
490
  entities?: Record<string, unknown>;
389
491
  referenced_tweets?: XurlReferencedTweet[];
390
492
  public_metrics?: XurlPublicMetrics;
@@ -398,9 +500,12 @@ export interface XurlReferencedTweet {
398
500
 
399
501
  export interface XurlUserTweet {
400
502
  id: string;
503
+ author_id?: string;
401
504
  text: string;
402
505
  created_at: string;
403
506
  conversation_id?: string;
507
+ attachments?: XurlTweetAttachments;
508
+ entities?: Record<string, unknown>;
404
509
  referenced_tweets?: XurlReferencedTweet[];
405
510
  public_metrics?: XurlPublicMetrics;
406
511
  edit_history_tweet_ids?: string[];
@@ -412,12 +517,50 @@ export interface XurlTweetData {
412
517
  text: string;
413
518
  created_at: string;
414
519
  conversation_id?: string;
520
+ in_reply_to_user_id?: string;
521
+ attachments?: XurlTweetAttachments;
415
522
  entities?: Record<string, unknown>;
416
523
  referenced_tweets?: XurlReferencedTweet[];
417
524
  public_metrics?: XurlPublicMetrics;
418
525
  edit_history_tweet_ids?: string[];
419
526
  }
420
527
 
528
+ export interface XurlTweetAttachments {
529
+ media_keys?: string[];
530
+ poll_ids?: string[];
531
+ }
532
+
533
+ export interface XurlMediaItem {
534
+ media_key: string;
535
+ type: "photo" | "video" | "animated_gif" | string;
536
+ url?: string;
537
+ preview_image_url?: string;
538
+ duration_ms?: number;
539
+ width?: number;
540
+ height?: number;
541
+ alt_text?: string;
542
+ public_metrics?: XurlPublicMetrics;
543
+ variants?: Array<{
544
+ url: string;
545
+ content_type: string;
546
+ bit_rate?: number;
547
+ }>;
548
+ }
549
+
550
+ export type XurlMedia = XurlMediaItem;
551
+
552
+ export interface XurlTweetIncludes {
553
+ users?: XurlMentionUser[];
554
+ tweets?: XurlTweetData[];
555
+ media?: XurlMedia[];
556
+ }
557
+
558
+ export interface XurlUserTweetsResponse {
559
+ items: XurlUserTweet[];
560
+ nextToken: string | null;
561
+ includes?: XurlTweetIncludes;
562
+ }
563
+
421
564
  export interface ProfileReplyItem {
422
565
  id: string;
423
566
  text: string;
@@ -447,6 +590,7 @@ export interface XurlMentionsResponse {
447
590
  data: XurlMentionData[];
448
591
  includes?: {
449
592
  users?: XurlMentionUser[];
593
+ media?: XurlMediaItem[];
450
594
  };
451
595
  meta?: Record<string, unknown>;
452
596
  }
@@ -455,6 +599,45 @@ export interface XurlTweetsResponse {
455
599
  data: XurlTweetData[];
456
600
  includes?: {
457
601
  users?: XurlMentionUser[];
602
+ media?: XurlMediaItem[];
458
603
  };
459
604
  meta?: Record<string, unknown>;
460
605
  }
606
+
607
+ export type FollowDirection = "followers" | "following";
608
+
609
+ export interface XurlFollowUsersResponse {
610
+ data: XurlMentionUser[];
611
+ meta?: Record<string, unknown>;
612
+ }
613
+
614
+ export interface FollowGraphProfile {
615
+ id: string;
616
+ externalUserId: string;
617
+ handle: string;
618
+ displayName: string;
619
+ bio: string;
620
+ followersCount: number;
621
+ publicMetrics: XurlPublicMetrics;
622
+ avatarUrl?: string;
623
+ }
624
+
625
+ export type FollowEventKind = "started" | "ended";
626
+
627
+ export interface FollowGraphEvent {
628
+ eventAt: string;
629
+ direction: FollowDirection;
630
+ kind: FollowEventKind;
631
+ snapshotId: string;
632
+ profile: FollowGraphProfile;
633
+ }
634
+
635
+ export interface FollowGraphSummary {
636
+ accountId: string;
637
+ followers: number;
638
+ following: number;
639
+ mutuals: number;
640
+ nonMutualFollowing: number;
641
+ lastCompleteSnapshots: Partial<Record<FollowDirection, string>>;
642
+ lastIncompleteSnapshots: Partial<Record<FollowDirection, string>>;
643
+ }