birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -1,138 +1,32 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { Search } from "lucide-react";
3
- import { useEffect, useMemo, useState } from "react";
4
- import { TimelineCard } from "#/components/TimelineCard";
5
- import type {
6
- QueryEnvelope,
7
- QueryResponse,
8
- ReplyFilter,
9
- TimelineItem,
10
- } from "#/lib/types";
11
- import {
12
- cx,
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,
26
- } from "#/lib/ui";
2
+ import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
3
+ import type { QueryEnvelope } from "#/lib/types";
27
4
 
28
5
  export const Route = createFileRoute("/")({
29
6
  component: HomeRoute,
30
7
  });
31
8
 
32
- const TABS: Array<{ value: ReplyFilter; label: string }> = [
33
- { value: "all", label: "All" },
34
- { value: "unreplied", label: "Unreplied" },
35
- { value: "replied", label: "Replied" },
36
- ];
9
+ function homeSubtitle(meta: QueryEnvelope | null) {
10
+ if (!meta) return "Loading local context...";
11
+ return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
12
+ }
37
13
 
38
14
  function HomeRoute() {
39
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
40
- const [items, setItems] = useState<TimelineItem[]>([]);
41
- const [replyFilter, setReplyFilter] = useState<ReplyFilter>("all");
42
- const [search, setSearch] = useState("");
43
- const [refreshTick, setRefreshTick] = useState(0);
44
-
45
- useEffect(() => {
46
- fetch("/api/status")
47
- .then((response) => response.json())
48
- .then((data: QueryEnvelope) => setMeta(data));
49
- }, []);
50
-
51
- useEffect(() => {
52
- const url = new URL("/api/query", window.location.origin);
53
- url.searchParams.set("resource", "home");
54
- url.searchParams.set("replyFilter", replyFilter);
55
- url.searchParams.set("refresh", String(refreshTick));
56
- if (search.trim()) {
57
- url.searchParams.set("search", search.trim());
58
- }
59
-
60
- fetch(url)
61
- .then((response) => response.json())
62
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
63
- }, [refreshTick, replyFilter, search]);
64
-
65
- const subtitle = useMemo(() => {
66
- if (!meta) return "Loading local context...";
67
- return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
68
- }, [meta]);
69
-
70
- async function replyToTweet(tweetId: string) {
71
- const text = window.prompt("Reply text");
72
- if (!text?.trim()) return;
73
-
74
- await fetch("/api/action", {
75
- method: "POST",
76
- headers: { "content-type": "application/json" },
77
- body: JSON.stringify({
78
- kind: "replyTweet",
79
- accountId: "acct_primary",
80
- tweetId,
81
- text,
82
- }),
83
- });
84
-
85
- setRefreshTick((value) => value + 1);
86
- }
87
-
88
15
  return (
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>
95
- </div>
96
- </div>
97
- <div className="px-4 pb-3">
98
- <label className={searchFieldShellClass}>
99
- <Search className={searchFieldIconClass} strokeWidth={2} />
100
- <input
101
- className={searchFieldInputClass}
102
- onChange={(event) => setSearch(event.target.value)}
103
- placeholder="Search local timeline"
104
- value={search}
105
- />
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
- </>
16
+ <TimelineRouteFrame
17
+ emptyDetail="Try a different filter or sync the timeline again."
18
+ emptyLabel="No posts in this view"
19
+ errorFallback="Timeline unavailable"
20
+ errorTitle="Could not load posts"
21
+ initialReplyFilter="all"
22
+ loadingDetail="Reading the local timeline store"
23
+ loadingLabel="Loading posts"
24
+ resource="home"
25
+ searchPlaceholder="Search local timeline"
26
+ subtitle={homeSubtitle}
27
+ syncKind="timeline"
28
+ syncLabel="Sync timeline"
29
+ title="Home"
30
+ />
137
31
  );
138
32
  }
@@ -11,6 +11,12 @@ import {
11
11
  } from "lucide-react";
12
12
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
13
  import { AvatarChip } from "#/components/AvatarChip";
14
+ import {
15
+ FeedEmpty,
16
+ FeedError,
17
+ FeedLoading,
18
+ LinkSkeletonRows,
19
+ } from "#/components/FeedState";
14
20
  import { ProfilePreview } from "#/components/ProfilePreview";
15
21
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
16
22
  import type {
@@ -26,7 +32,6 @@ import type {
26
32
  } from "#/lib/types";
27
33
  import {
28
34
  cx,
29
- emptyStateClass,
30
35
  pageHeaderClass,
31
36
  pageHeaderRowClass,
32
37
  pageSubtitleClass,
@@ -649,6 +654,7 @@ function LinksRoute() {
649
654
  const cacheRef = useRef(new Map<string, LinkInsightResponse>());
650
655
  const inFlightRef = useRef(new Set<string>());
651
656
  const mountedRef = useRef(true);
657
+ const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
652
658
  const [, bumpCacheVersion] = useState(0);
653
659
  const currentCacheKey = insightCacheKey(
654
660
  kind,
@@ -658,6 +664,8 @@ function LinksRoute() {
658
664
  refreshTick,
659
665
  );
660
666
  const data = cacheRef.current.get(currentCacheKey) ?? null;
667
+ const error = errorByKey[currentCacheKey] ?? null;
668
+ const loading = !data && !error;
661
669
 
662
670
  useEffect(() => {
663
671
  return () => {
@@ -672,6 +680,11 @@ function LinksRoute() {
672
680
  return;
673
681
  }
674
682
  inFlightRef.current.add(key);
683
+ setErrorByKey((current) => {
684
+ const rest = { ...current };
685
+ delete rest[key];
686
+ return rest;
687
+ });
675
688
  fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
676
689
  .then((response) => response.json())
677
690
  .then((response: LinkInsightResponse) => {
@@ -684,7 +697,17 @@ function LinksRoute() {
684
697
  if (error instanceof DOMException && error.name === "AbortError") {
685
698
  return;
686
699
  }
700
+ if (!mountedRef.current) {
701
+ return;
702
+ }
687
703
  console.warn("Link insights failed", error);
704
+ setErrorByKey((current) => ({
705
+ ...current,
706
+ [key]:
707
+ error instanceof Error
708
+ ? error.message
709
+ : "Link insights unavailable",
710
+ }));
688
711
  })
689
712
  .finally(() => {
690
713
  inFlightRef.current.delete(key);
@@ -865,10 +888,32 @@ function LinksRoute() {
865
888
  </header>
866
889
 
867
890
  <section className="flex flex-col">
868
- {items.length === 0 ? (
869
- <div className={emptyStateClass}>
870
- {data ? "No links in this window." : "Loading links..."}
871
- </div>
891
+ {loading ? (
892
+ <FeedLoading
893
+ detail="Ranking URLs and collecting local discussion"
894
+ label="Loading links"
895
+ >
896
+ <LinkSkeletonRows />
897
+ </FeedLoading>
898
+ ) : error ? (
899
+ <FeedError
900
+ action={
901
+ <button
902
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
903
+ onClick={() => setRefreshTick((value) => value + 1)}
904
+ type="button"
905
+ >
906
+ Retry
907
+ </button>
908
+ }
909
+ message={error}
910
+ title="Could not load links"
911
+ />
912
+ ) : items.length === 0 ? (
913
+ <FeedEmpty
914
+ detail="Try a different range, source, or search term."
915
+ label="No links in this window"
916
+ />
872
917
  ) : null}
873
918
  {items.map((item, index) => (
874
919
  <LinkInsightRow
@@ -1,138 +1,32 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { Search } from "lucide-react";
3
- import { useEffect, useMemo, useState } from "react";
4
- import { TimelineCard } from "#/components/TimelineCard";
5
- import type {
6
- QueryEnvelope,
7
- QueryResponse,
8
- ReplyFilter,
9
- TimelineItem,
10
- } from "#/lib/types";
11
- import {
12
- cx,
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,
26
- } from "#/lib/ui";
2
+ import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
3
+ import type { QueryEnvelope } from "#/lib/types";
27
4
 
28
5
  export const Route = createFileRoute("/mentions")({
29
6
  component: MentionsRoute,
30
7
  });
31
8
 
32
- const TABS: Array<{ value: ReplyFilter; label: string }> = [
33
- { value: "all", label: "All" },
34
- { value: "unreplied", label: "Unreplied" },
35
- { value: "replied", label: "Replied" },
36
- ];
9
+ function mentionsSubtitle(meta: QueryEnvelope | null) {
10
+ if (!meta) return "Loading mentions...";
11
+ return `${String(meta.stats.mentions)} mention/reply items in local store`;
12
+ }
37
13
 
38
14
  function MentionsRoute() {
39
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
40
- const [items, setItems] = useState<TimelineItem[]>([]);
41
- const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
42
- const [search, setSearch] = useState("");
43
- const [refreshTick, setRefreshTick] = useState(0);
44
-
45
- useEffect(() => {
46
- fetch("/api/status")
47
- .then((response) => response.json())
48
- .then((data: QueryEnvelope) => setMeta(data));
49
- }, []);
50
-
51
- useEffect(() => {
52
- const url = new URL("/api/query", window.location.origin);
53
- url.searchParams.set("resource", "mentions");
54
- url.searchParams.set("replyFilter", replyFilter);
55
- url.searchParams.set("refresh", String(refreshTick));
56
- if (search.trim()) {
57
- url.searchParams.set("search", search.trim());
58
- }
59
-
60
- fetch(url)
61
- .then((response) => response.json())
62
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
63
- }, [refreshTick, replyFilter, search]);
64
-
65
- const subtitle = useMemo(() => {
66
- if (!meta) return "Loading mentions...";
67
- return `${String(meta.stats.mentions)} mention/reply items in local store`;
68
- }, [meta]);
69
-
70
- async function replyToTweet(tweetId: string) {
71
- const text = window.prompt("Reply text");
72
- if (!text?.trim()) return;
73
-
74
- await fetch("/api/action", {
75
- method: "POST",
76
- headers: { "content-type": "application/json" },
77
- body: JSON.stringify({
78
- kind: "replyTweet",
79
- accountId: "acct_primary",
80
- tweetId,
81
- text,
82
- }),
83
- });
84
-
85
- setRefreshTick((value) => value + 1);
86
- }
87
-
88
15
  return (
89
- <>
90
- <header className={pageHeaderClass}>
91
- <div className={pageHeaderRowClass}>
92
- <div className="flex min-w-0 flex-col">
93
- <h1 className={pageTitleClass}>Mentions</h1>
94
- <p className={pageSubtitleClass}>{subtitle}</p>
95
- </div>
96
- </div>
97
- <div className="px-4 pb-3">
98
- <label className={searchFieldShellClass}>
99
- <Search className={searchFieldIconClass} strokeWidth={2} />
100
- <input
101
- className={searchFieldInputClass}
102
- onChange={(event) => setSearch(event.target.value)}
103
- placeholder="Search mentions"
104
- value={search}
105
- />
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 mentions in view.</div>
131
- ) : null}
132
- {items.map((item) => (
133
- <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
134
- ))}
135
- </section>
136
- </>
16
+ <TimelineRouteFrame
17
+ emptyDetail="Try All, search less narrowly, or sync mentions."
18
+ emptyLabel="No mentions in this view"
19
+ errorFallback="Mentions unavailable"
20
+ errorTitle="Could not load mentions"
21
+ initialReplyFilter="unreplied"
22
+ loadingDetail="Checking local mentions and reply context"
23
+ loadingLabel="Loading mentions"
24
+ resource="mentions"
25
+ searchPlaceholder="Search mentions"
26
+ subtitle={mentionsSubtitle}
27
+ syncKind="mentions"
28
+ syncLabel="Sync mentions"
29
+ title="Mentions"
30
+ />
137
31
  );
138
32
  }