birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  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 +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,51 +1,77 @@
1
1
  import { RefreshCw } from "lucide-react";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
3
  import { postSync } from "#/lib/api-client";
4
4
  import type { AccountRecord } from "#/lib/types";
5
5
  import { cx, selectFieldClass } from "#/lib/ui";
6
- import type { WebSyncKind, WebSyncResponse } from "#/lib/web-sync";
6
+ import type {
7
+ WebSyncKind,
8
+ WebSyncOptions,
9
+ WebSyncResponse,
10
+ } from "#/lib/web-sync";
11
+ import {
12
+ defaultAccountId as getDefaultAccountId,
13
+ setStoredAccountId,
14
+ useSelectedAccountId,
15
+ } from "./account-selection";
7
16
 
8
17
  interface SyncNowButtonProps {
9
18
  kind: WebSyncKind;
10
19
  label: string;
11
20
  accounts?: AccountRecord[];
12
21
  onSynced: (result: WebSyncResponse) => void;
22
+ showAccountPicker?: boolean;
23
+ syncOptions?: WebSyncOptions;
13
24
  }
14
25
 
15
26
  export function SyncNowButton({
16
27
  kind,
17
28
  label,
18
- accounts = [],
29
+ accounts,
19
30
  onSynced,
31
+ showAccountPicker = false,
32
+ syncOptions,
20
33
  }: SyncNowButtonProps) {
21
34
  const [syncing, setSyncing] = useState(false);
22
35
  const [message, setMessage] = useState<string | null>(null);
23
36
  const [error, setError] = useState<string | null>(null);
24
- const [selectedAccountId, setSelectedAccountId] = useState<
25
- string | undefined
26
- >();
37
+ const accountList = accounts ?? [];
38
+ const globalAccountId = useSelectedAccountId(accounts);
27
39
  const defaultAccountId = useMemo(
28
- () => accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id,
40
+ () => getDefaultAccountId(accounts),
29
41
  [accounts],
30
42
  );
31
- const accountId = selectedAccountId ?? defaultAccountId;
43
+ const accountId = globalAccountId ?? defaultAccountId;
44
+ const accountAwareSync = kind !== "dms";
45
+ const waitingForAccount =
46
+ accountAwareSync &&
47
+ accounts === undefined &&
48
+ (showAccountPicker || kind !== "timeline");
49
+ const birdOnlyWrongAccount =
50
+ !accountAwareSync &&
51
+ accountId !== undefined &&
52
+ defaultAccountId !== undefined &&
53
+ accountId !== defaultAccountId;
54
+ const disabled = syncing || waitingForAccount || birdOnlyWrongAccount;
55
+ const statusMessage = birdOnlyWrongAccount
56
+ ? "Switch to default to sync"
57
+ : waitingForAccount
58
+ ? "Loading account"
59
+ : (error ?? message ?? "");
32
60
 
33
- useEffect(() => {
34
- if (!accounts.length) {
35
- setSelectedAccountId(undefined);
36
- return;
37
- }
38
- if (!accountId || !accounts.some((account) => account.id === accountId)) {
39
- setSelectedAccountId(defaultAccountId);
40
- }
41
- }, [accountId, accounts, defaultAccountId]);
61
+ function selectAccount(accountId: string) {
62
+ setStoredAccountId(accountId);
63
+ }
42
64
 
43
65
  async function syncNow() {
44
66
  setSyncing(true);
45
67
  setError(null);
46
68
  setMessage(null);
47
69
  try {
48
- const data = await postSync(kind, accountId);
70
+ const data = await postSync(
71
+ kind,
72
+ accountAwareSync ? accountId : undefined,
73
+ syncOptions,
74
+ );
49
75
  if (!data.ok) throw new Error(data.summary);
50
76
  setMessage(data.summary);
51
77
  onSynced(data);
@@ -58,15 +84,15 @@ export function SyncNowButton({
58
84
 
59
85
  return (
60
86
  <div className="flex shrink-0 items-center gap-2">
61
- {accounts.length > 1 ? (
87
+ {showAccountPicker && accountAwareSync && accountList.length > 1 ? (
62
88
  <select
63
89
  aria-label="Sync account"
64
90
  className={cx(selectFieldClass, "h-9 w-[132px]")}
65
91
  disabled={syncing}
66
- onChange={(event) => setSelectedAccountId(event.target.value)}
92
+ onChange={(event) => selectAccount(event.target.value)}
67
93
  value={accountId ?? ""}
68
94
  >
69
- {accounts.map((account) => (
95
+ {accountList.map((account) => (
70
96
  <option key={account.id} value={account.id}>
71
97
  {account.handle}
72
98
  </option>
@@ -76,11 +102,20 @@ export function SyncNowButton({
76
102
  <button
77
103
  type="button"
78
104
  className={cx(
79
- "inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-semibold text-[var(--ink)] transition-[background,border-color,color,transform] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] hover:bg-[var(--accent-soft)] hover:text-[var(--accent)] active:scale-[0.98] disabled:cursor-wait disabled:opacity-65",
105
+ "inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-semibold text-[var(--ink)] transition-[background,border-color,color,transform] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] hover:bg-[var(--accent-soft)] hover:text-[var(--accent)] active:scale-[0.98] disabled:opacity-65",
80
106
  syncing && "text-[var(--ink-soft)]",
107
+ birdOnlyWrongAccount
108
+ ? "disabled:cursor-not-allowed"
109
+ : "disabled:cursor-wait",
81
110
  )}
82
- aria-label={syncing ? `${label}: syncing` : label}
83
- disabled={syncing}
111
+ aria-label={
112
+ birdOnlyWrongAccount
113
+ ? `${label}: default account only`
114
+ : syncing
115
+ ? `${label}: syncing`
116
+ : label
117
+ }
118
+ disabled={disabled}
84
119
  onClick={syncNow}
85
120
  >
86
121
  <RefreshCw
@@ -98,7 +133,7 @@ export function SyncNowButton({
98
133
  )}
99
134
  role="status"
100
135
  >
101
- {error ?? message ?? ""}
136
+ {statusMessage}
102
137
  </span>
103
138
  </div>
104
139
  );
@@ -17,59 +17,64 @@ const THEME_OPTIONS = [
17
17
  label: string;
18
18
  }>;
19
19
 
20
- export function ThemeSlider() {
21
- const { isReady, theme, setTheme } = useTheme();
22
-
23
- return (
24
- <fieldset
25
- className="theme-slider-shell m-0 flex justify-center border-0 px-2 py-1 min-[1100px]:justify-start min-[1100px]:px-3"
26
- aria-label="Theme selector"
27
- >
28
- <div className="theme-slider flex flex-col items-center gap-1.5 min-[1100px]:flex-row">
29
- {THEME_OPTIONS.map((option, index) => {
30
- const Icon = option.icon;
31
- const isActive = option.key === theme || (index === 0 && !theme);
20
+ function themeIndex(theme: ThemeValue) {
21
+ return THEME_OPTIONS.findIndex((option) => option.key === theme);
22
+ }
32
23
 
33
- const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
34
- if (isActive) return;
24
+ export function ThemeSlider({ compact = false }: { compact?: boolean }) {
25
+ const { isReady, theme, setTheme } = useTheme();
26
+ const activeIndex = Math.max(themeIndex(theme), 0);
27
+ const activeOption = THEME_OPTIONS[activeIndex];
28
+ const nextOption = THEME_OPTIONS[(activeIndex + 1) % THEME_OPTIONS.length];
29
+ const Icon = activeOption.icon;
35
30
 
36
- const context: ThemeTransitionContext = {
37
- element: event.currentTarget,
38
- pointerClientX: event.clientX,
39
- pointerClientY: event.clientY,
40
- };
31
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
32
+ const context: ThemeTransitionContext = {
33
+ element: event.currentTarget,
34
+ pointerClientX: event.clientX,
35
+ pointerClientY: event.clientY,
36
+ };
41
37
 
42
- startThemeTransition({
43
- nextTheme: option.key,
44
- currentTheme: theme,
45
- setTheme,
46
- context,
47
- });
48
- };
38
+ startThemeTransition({
39
+ nextTheme: nextOption.key,
40
+ currentTheme: theme,
41
+ setTheme,
42
+ context,
43
+ });
44
+ };
49
45
 
50
- return (
51
- <button
52
- key={option.key}
53
- type="button"
54
- className={cx(
55
- "theme-slider-button inline-flex size-9 items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[background,color,transform] duration-150 hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
56
- isActive &&
57
- "theme-slider-button-active bg-[var(--bg-active)] text-[var(--ink)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_72%,transparent)]",
58
- )}
59
- onClick={handleClick}
60
- aria-label={option.label}
61
- aria-pressed={isActive}
62
- data-testid={`theme-${option.key}`}
63
- disabled={!isReady}
64
- >
65
- <Icon
66
- className="theme-slider-icon size-[17px]"
67
- strokeWidth={isActive ? 2.1 : 1.8}
68
- />
69
- </button>
70
- );
71
- })}
72
- </div>
73
- </fieldset>
46
+ return (
47
+ <div
48
+ className={cx(
49
+ "theme-toggle-shell flex justify-center",
50
+ compact
51
+ ? "shrink-0"
52
+ : "px-1 py-1 min-[1100px]:justify-start min-[1100px]:px-2",
53
+ )}
54
+ title={`${activeOption.label}; click for ${nextOption.label}`}
55
+ >
56
+ <button
57
+ type="button"
58
+ className={cx(
59
+ "theme-toggle-button inline-flex items-center justify-center rounded-full border bg-transparent text-[var(--ink-soft)] transition-[background,border-color,color,transform,box-shadow] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_38%,var(--line))] hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
60
+ compact
61
+ ? "size-8 border-transparent"
62
+ : "size-11 border-[var(--line)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_44%,transparent)]",
63
+ )}
64
+ onClick={handleClick}
65
+ aria-label={`Theme: ${activeOption.label}. Switch to ${nextOption.label}.`}
66
+ data-testid="theme-toggle"
67
+ disabled={!isReady}
68
+ >
69
+ <Icon
70
+ className={cx(
71
+ "theme-toggle-icon",
72
+ compact ? "size-[17px]" : "size-[19px]",
73
+ )}
74
+ strokeWidth={2}
75
+ aria-hidden="true"
76
+ />
77
+ </button>
78
+ </div>
74
79
  );
75
80
  }