birdclaw 0.8.0 → 0.8.2

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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -8
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/components/ConversationThread.tsx +9 -4
  5. package/src/components/DmWorkspace.tsx +26 -9
  6. package/src/components/EmbeddedTweetCard.tsx +9 -4
  7. package/src/components/FloatingPreview.tsx +382 -0
  8. package/src/components/InboxCard.tsx +6 -4
  9. package/src/components/MarkdownViewer.tsx +59 -100
  10. package/src/components/ProfilePreview.tsx +39 -81
  11. package/src/components/SmartTimestamp.tsx +72 -0
  12. package/src/components/TimelineCard.tsx +24 -6
  13. package/src/components/TweetArticleCard.tsx +66 -0
  14. package/src/components/TweetRichText.tsx +33 -2
  15. package/src/components/useDebouncedValue.ts +12 -0
  16. package/src/components/useTimelineRouteData.ts +149 -47
  17. package/src/lib/api-client.ts +117 -2
  18. package/src/lib/archive-finder.ts +38 -0
  19. package/src/lib/authored-live.ts +2 -62
  20. package/src/lib/bird.ts +32 -1
  21. package/src/lib/client-cache.ts +109 -0
  22. package/src/lib/db.ts +57 -4
  23. package/src/lib/mention-threads-live.ts +2 -1
  24. package/src/lib/mentions-live.ts +2 -46
  25. package/src/lib/ndjson-stream.ts +106 -0
  26. package/src/lib/period-digest.ts +184 -24
  27. package/src/lib/present.ts +99 -1
  28. package/src/lib/profile-analysis.ts +1 -1
  29. package/src/lib/queries.ts +68 -6
  30. package/src/lib/sqlite.ts +5 -2
  31. package/src/lib/timeline-collections-live.ts +2 -1
  32. package/src/lib/timeline-live.ts +2 -1
  33. package/src/lib/tweet-render.ts +78 -0
  34. package/src/lib/tweet-search-live.ts +2 -1
  35. package/src/lib/types.ts +8 -0
  36. package/src/lib/ui.ts +1 -1
  37. package/src/router.tsx +1 -1
  38. package/src/routes/__root.tsx +0 -13
  39. package/src/routes/api/period-digest.tsx +21 -69
  40. package/src/routes/api/profile-analysis.tsx +22 -77
  41. package/src/routes/api/search-discussion.tsx +19 -75
  42. package/src/routes/api/status.tsx +3 -1
  43. package/src/routes/dms.tsx +40 -22
  44. package/src/routes/links.tsx +75 -9
  45. package/src/routes/today.tsx +67 -11
  46. package/vite.config.ts +0 -2
@@ -1,20 +1,18 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
- import { runEffectBackground } from "#/lib/effect-runtime";
5
4
  import {
6
5
  parseBoundedInteger,
7
6
  runRouteEffect,
8
7
  sensitiveRequestErrorResponse,
9
8
  } from "#/lib/http-effect";
9
+ import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
10
10
  import {
11
11
  streamProfileAnalysisEffect,
12
12
  type ProfileAnalysisOptions,
13
13
  type ProfileAnalysisStreamEvent,
14
14
  } from "#/lib/profile-analysis";
15
15
 
16
- const encoder = new TextEncoder();
17
-
18
16
  function parseBoolean(value: string | null) {
19
17
  return value === "true" || value === "1" || value === "yes";
20
18
  }
@@ -57,10 +55,6 @@ function parseOptions(url: URL): ProfileAnalysisOptions {
57
55
  };
58
56
  }
59
57
 
60
- function encodeEvent(event: ProfileAnalysisStreamEvent) {
61
- return encoder.encode(`${JSON.stringify(event)}\n`);
62
- }
63
-
64
58
  export const Route = createFileRoute("/api/profile-analysis")({
65
59
  server: {
66
60
  handlers: {
@@ -72,79 +66,30 @@ export const Route = createFileRoute("/api/profile-analysis")({
72
66
 
73
67
  const url = new URL(request.url);
74
68
  const options = parseOptions(url);
75
- let abortAnalysis: (() => void) | undefined;
76
-
77
- return new Response(
78
- new ReadableStream({
79
- cancel() {
80
- abortAnalysis?.();
69
+ return createEffectNdjsonResponse<ProfileAnalysisStreamEvent>({
70
+ request,
71
+ initialEvents: [
72
+ {
73
+ type: "status",
74
+ label: "Starting profile analysis",
81
75
  },
82
- start(controller) {
83
- const abortController = new AbortController();
84
- let closed = false;
85
- const close = () => {
86
- closed = true;
87
- abortController.abort();
88
- };
89
- const closeController = () => {
90
- request.signal.removeEventListener("abort", onAbort);
91
- if (!closed) {
92
- closed = true;
93
- controller.close();
94
- }
95
- };
96
- const onAbort = () => close();
97
- request.signal.addEventListener("abort", onAbort, {
98
- once: true,
99
- });
100
- abortAnalysis = close;
101
- const enqueue = (event: ProfileAnalysisStreamEvent) => {
102
- if (closed) return;
103
- try {
104
- controller.enqueue(encodeEvent(event));
105
- } catch {
106
- close();
107
- }
108
- };
109
- enqueue({
110
- type: "status",
111
- label: "Starting profile analysis",
112
- });
113
-
114
- runEffectBackground(
115
- Effect.gen(function* () {
116
- yield* maybeAutoUpdateBackupEffect();
117
- return yield* streamProfileAnalysisEffect(
118
- {
119
- ...options,
120
- signal: abortController.signal,
121
- },
122
- { onEvent: enqueue },
123
- );
124
- }),
125
- {
126
- onSuccess: closeController,
127
- onFailure: (error) => {
128
- enqueue({
129
- type: "error",
130
- error:
131
- error instanceof Error
132
- ? error.message
133
- : "Profile analysis failed",
134
- });
135
- closeController();
136
- },
137
- },
76
+ ],
77
+ run: ({ signal, emit }) =>
78
+ Effect.gen(function* () {
79
+ yield* maybeAutoUpdateBackupEffect();
80
+ return yield* streamProfileAnalysisEffect(
81
+ { ...options, signal },
82
+ { onEvent: emit },
138
83
  );
139
- },
84
+ }),
85
+ errorEvent: (error) => ({
86
+ type: "error",
87
+ error:
88
+ error instanceof Error
89
+ ? error.message
90
+ : "Profile analysis failed",
140
91
  }),
141
- {
142
- headers: {
143
- "cache-control": "no-store",
144
- "content-type": "application/x-ndjson; charset=utf-8",
145
- },
146
- },
147
- );
92
+ });
148
93
  }),
149
94
  ),
150
95
  },
@@ -1,12 +1,12 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
- import { runEffectBackground } from "#/lib/effect-runtime";
5
4
  import {
6
5
  parseBoundedInteger,
7
6
  runRouteEffect,
8
7
  sensitiveRequestErrorResponse,
9
8
  } from "#/lib/http-effect";
9
+ import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
10
10
  import {
11
11
  streamSearchDiscussionEffect,
12
12
  type SearchDiscussionOptions,
@@ -15,7 +15,6 @@ import {
15
15
  } from "#/lib/search-discussion";
16
16
  import type { TweetSearchMode } from "#/lib/tweet-search-live";
17
17
 
18
- const encoder = new TextEncoder();
19
18
  const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
20
19
  const MAX_DISCUSSION_SEARCH_PAGES = 200;
21
20
 
@@ -73,10 +72,6 @@ function parseOptions(url: URL): SearchDiscussionOptions {
73
72
  };
74
73
  }
75
74
 
76
- function encodeEvent(event: SearchDiscussionStreamEvent) {
77
- return encoder.encode(`${JSON.stringify(event)}\n`);
78
- }
79
-
80
75
  export const Route = createFileRoute("/api/search-discussion")({
81
76
  server: {
82
77
  handlers: {
@@ -88,80 +83,29 @@ export const Route = createFileRoute("/api/search-discussion")({
88
83
 
89
84
  const url = new URL(request.url);
90
85
  const options = parseOptions(url);
91
- let abortDiscussion: (() => void) | undefined;
92
-
93
- return new Response(
94
- new ReadableStream({
95
- cancel() {
96
- abortDiscussion?.();
97
- },
98
- start(controller) {
99
- const abortController = new AbortController();
100
- let closed = false;
101
- const close = () => {
102
- closed = true;
103
- abortController.abort();
104
- };
105
- const closeController = () => {
106
- request.signal.removeEventListener("abort", onAbort);
107
- if (!closed) {
108
- closed = true;
109
- controller.close();
110
- }
111
- };
112
- const onAbort = () => close();
113
- request.signal.addEventListener("abort", onAbort, {
114
- once: true,
115
- });
116
- abortDiscussion = close;
117
- const enqueue = (event: SearchDiscussionStreamEvent) => {
118
- if (closed) return;
119
- try {
120
- controller.enqueue(encodeEvent(event));
121
- } catch {
122
- close();
123
- }
124
- };
125
-
126
- runEffectBackground(
127
- maybeAutoUpdateBackupEffect().pipe(
128
- Effect.flatMap(() => {
129
- if (closed || abortController.signal.aborted) {
130
- return Effect.succeed(undefined);
131
- }
132
- return streamSearchDiscussionEffect(
86
+ return createEffectNdjsonResponse<SearchDiscussionStreamEvent>({
87
+ request,
88
+ run: ({ signal, emit }) =>
89
+ maybeAutoUpdateBackupEffect().pipe(
90
+ Effect.flatMap(() =>
91
+ signal.aborted
92
+ ? Effect.succeed(undefined)
93
+ : streamSearchDiscussionEffect(
133
94
  {
134
95
  ...options,
135
- signal: abortController.signal,
96
+ signal,
136
97
  prefetchAvatars: true,
137
98
  },
138
- { onEvent: enqueue },
139
- );
140
- }),
141
- ),
142
- {
143
- onSuccess: closeController,
144
- onFailure: (error) => {
145
- enqueue({
146
- type: "error",
147
- error:
148
- error instanceof Error
149
- ? error.message
150
- : "Discussion failed",
151
- });
152
- closeController();
153
- },
154
- },
155
- );
156
- },
99
+ { onEvent: emit },
100
+ ),
101
+ ),
102
+ ),
103
+ errorEvent: (error) => ({
104
+ type: "error",
105
+ error:
106
+ error instanceof Error ? error.message : "Discussion failed",
157
107
  }),
158
- {
159
- headers: {
160
- "cache-control": "no-store",
161
- "content-type": "application/x-ndjson; charset=utf-8",
162
- },
163
- },
164
- );
108
+ });
165
109
  }),
166
110
  ),
167
111
  },
@@ -18,7 +18,9 @@ export const Route = createFileRoute("/api/status")({
18
18
  if (denied) return denied;
19
19
 
20
20
  yield* maybeAutoUpdateBackupEffect();
21
- return jsonResponse(yield* getQueryEnvelopeEffect());
21
+ return jsonResponse(
22
+ yield* getQueryEnvelopeEffect({ includeArchives: false }),
23
+ );
22
24
  }),
23
25
  ),
24
26
  },
@@ -6,9 +6,11 @@ import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
6
6
  import { SyncNowButton } from "#/components/SyncNowButton";
7
7
  import { useSelectedAccountId } from "#/components/account-selection";
8
8
  import {
9
+ fetchCachedQueryResponse,
9
10
  fetchQueryEnvelope,
10
- fetchQueryResponse,
11
+ invalidateCachedQueryResponses,
11
12
  postAction,
13
+ readCachedQueryResponse,
12
14
  } from "#/lib/api-client";
13
15
  import type {
14
16
  DmConversationItem,
@@ -16,6 +18,7 @@ import type {
16
18
  QueryEnvelope,
17
19
  ReplyFilter,
18
20
  } from "#/lib/types";
21
+ import { useDebouncedValue } from "#/components/useDebouncedValue";
19
22
  import {
20
23
  cx,
21
24
  pageHeaderClass,
@@ -83,9 +86,10 @@ function DmsRoute() {
83
86
  const [error, setError] = useState<string | null>(null);
84
87
  const [replyError, setReplyError] = useState<string | null>(null);
85
88
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
89
+ const debouncedSearch = useDebouncedValue(search, 180);
86
90
 
87
- async function loadStatus() {
88
- setMeta(await fetchQueryEnvelope());
91
+ async function loadStatus(force = false) {
92
+ setMeta(await fetchQueryEnvelope(undefined, { force }));
89
93
  }
90
94
 
91
95
  useEffect(() => {
@@ -113,29 +117,41 @@ function DmsRoute() {
113
117
  if (selectedConversationId) {
114
118
  url.searchParams.set("conversationId", selectedConversationId);
115
119
  }
116
- if (search.trim()) {
117
- url.searchParams.set("search", search.trim());
120
+ if (debouncedSearch.trim()) {
121
+ url.searchParams.set("search", debouncedSearch.trim());
118
122
  }
119
123
 
124
+ const applyResponse = (
125
+ data: Awaited<ReturnType<typeof fetchCachedQueryResponse>>,
126
+ ) => {
127
+ const conversations = data.items as DmConversationItem[];
128
+ const nextSelected =
129
+ data.selectedConversation?.conversation.id ?? conversations[0]?.id;
130
+ setLoadedConversationId(data.selectedConversation?.conversation.id);
131
+ setItems(conversations);
132
+ setSelectedConversationId((current) => {
133
+ if (!current) return nextSelected;
134
+ return conversations.some((conversation) => conversation.id === current)
135
+ ? current
136
+ : nextSelected;
137
+ });
138
+ setMessages(data.selectedConversation?.messages ?? []);
139
+ };
120
140
  setError(null);
141
+ const cached = readCachedQueryResponse(url);
142
+ if (cached) {
143
+ applyResponse(cached);
144
+ setLoading(false);
145
+ return () => {
146
+ active = false;
147
+ controller.abort();
148
+ };
149
+ }
121
150
  setLoading(true);
122
- fetchQueryResponse(url, { signal: controller.signal })
151
+ fetchCachedQueryResponse(url, { signal: controller.signal })
123
152
  .then((data) => {
124
153
  if (!active) return;
125
- const conversations = data.items as DmConversationItem[];
126
- const nextSelected =
127
- data.selectedConversation?.conversation.id ?? conversations[0]?.id;
128
- setLoadedConversationId(data.selectedConversation?.conversation.id);
129
- setItems(conversations);
130
- setSelectedConversationId((current) => {
131
- if (!current) return nextSelected;
132
- return conversations.some(
133
- (conversation) => conversation.id === current,
134
- )
135
- ? current
136
- : nextSelected;
137
- });
138
- setMessages(data.selectedConversation?.messages ?? []);
154
+ applyResponse(data);
139
155
  })
140
156
  .catch((fetchError: unknown) => {
141
157
  if (
@@ -170,7 +186,7 @@ function DmsRoute() {
170
186
  inboxFilter,
171
187
  refreshTick,
172
188
  replyFilter,
173
- search,
189
+ debouncedSearch,
174
190
  selectedConversationId,
175
191
  selectedAccountId,
176
192
  sort,
@@ -247,6 +263,7 @@ function DmsRoute() {
247
263
  text,
248
264
  });
249
265
 
266
+ invalidateCachedQueryResponses();
250
267
  setSelectedConversationId(conversationId);
251
268
  setRefreshTick((value) => value + 1);
252
269
  } catch (error) {
@@ -258,8 +275,9 @@ function DmsRoute() {
258
275
  }
259
276
 
260
277
  function refreshLocalView() {
278
+ invalidateCachedQueryResponses();
261
279
  setRefreshTick((value) => value + 1);
262
- void loadStatus();
280
+ void loadStatus(true);
263
281
  }
264
282
 
265
283
  return (
@@ -18,7 +18,13 @@ import {
18
18
  LinkSkeletonRows,
19
19
  } from "#/components/FeedState";
20
20
  import { ProfilePreview } from "#/components/ProfilePreview";
21
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
21
+ import { SmartTimestamp } from "#/components/SmartTimestamp";
22
+ import {
23
+ loadClientCache,
24
+ readClientCache,
25
+ writeClientCache,
26
+ } from "#/lib/client-cache";
27
+ import { formatCompactNumber } from "#/lib/present";
22
28
  import type {
23
29
  LinkInsightItem,
24
30
  LinkInsightKind,
@@ -55,6 +61,10 @@ const LINK_INSIGHTS_LIMIT = 30;
55
61
  const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
56
62
  const PROFILE_HYDRATION_LIMIT = 30;
57
63
  const PROFILE_HYDRATION_DELAY_MS = 1200;
64
+ const LINK_INSIGHTS_CACHE_PREFIX = "link-insights:";
65
+ const LINK_INSIGHTS_CACHE_MAX_AGE_MS = 5 * 60_000;
66
+ const LINK_PROFILE_HYDRATED_CACHE_PREFIX = "link-profile-hydrated:";
67
+ const hydratingLinkProfileHandles = new Set<string>();
58
68
 
59
69
  const ranges: Array<{ value: LinkInsightRange; label: string }> = [
60
70
  { value: "today", label: "Today" },
@@ -86,6 +96,19 @@ function insightCacheKey(
86
96
  return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
87
97
  }
88
98
 
99
+ function sharedInsightCacheKey(
100
+ kind: LinkInsightKind,
101
+ range: LinkInsightRange,
102
+ sort: LinkInsightSort,
103
+ source: LinkInsightSource,
104
+ ) {
105
+ return `${LINK_INSIGHTS_CACHE_PREFIX}${kind}:${range}:${sort}:${source}`;
106
+ }
107
+
108
+ function hydratedProfileCacheKey(handle: string) {
109
+ return `${LINK_PROFILE_HYDRATED_CACHE_PREFIX}${handle.toLowerCase()}`;
110
+ }
111
+
89
112
  function linkInsightsUrl(
90
113
  kind: LinkInsightKind,
91
114
  range: LinkInsightRange,
@@ -393,7 +416,7 @@ function MentionCard({
393
416
  rel="noreferrer"
394
417
  target="_blank"
395
418
  >
396
- {formatShortTimestamp(mention.createdAt)}
419
+ <SmartTimestamp value={mention.createdAt} />
397
420
  </a>
398
421
  </div>
399
422
  <p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
@@ -633,7 +656,7 @@ function LinkInsightRow({
633
656
  <span>/</span>
634
657
  <SharerStrip item={item} />
635
658
  <span>/</span>
636
- <span>{formatShortTimestamp(item.lastSeenAt)}</span>
659
+ <SmartTimestamp value={item.lastSeenAt} />
637
660
  </div>
638
661
  <VideoPreview item={item} />
639
662
  </div>
@@ -650,8 +673,16 @@ function LinksRoute() {
650
673
  const [sort, setSort] = useState<LinkInsightSort>("rank");
651
674
  const [search, setSearch] = useState("");
652
675
  const [refreshTick, setRefreshTick] = useState(0);
653
- const hydratedHandlesRef = useRef(new Set<string>());
654
- const cacheRef = useRef(new Map<string, LinkInsightResponse>());
676
+ const initialCacheKey = insightCacheKey(kind, range, sort, source, 0);
677
+ const initialSharedData = readClientCache<LinkInsightResponse>(
678
+ sharedInsightCacheKey(kind, range, sort, source),
679
+ LINK_INSIGHTS_CACHE_MAX_AGE_MS,
680
+ );
681
+ const cacheRef = useRef(
682
+ new Map<string, LinkInsightResponse>(
683
+ initialSharedData ? [[initialCacheKey, initialSharedData]] : [],
684
+ ),
685
+ );
655
686
  const inFlightRef = useRef(new Set<string>());
656
687
  const mountedRef = useRef(true);
657
688
  const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
@@ -679,14 +710,35 @@ function LinksRoute() {
679
710
  if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
680
711
  return;
681
712
  }
713
+ const sharedKey = sharedInsightCacheKey(fetchKind, range, sort, source);
714
+ if (refreshTick === 0) {
715
+ const cached = readClientCache<LinkInsightResponse>(
716
+ sharedKey,
717
+ LINK_INSIGHTS_CACHE_MAX_AGE_MS,
718
+ );
719
+ if (cached) {
720
+ cacheRef.current.set(key, cached);
721
+ bumpCacheVersion((value) => value + 1);
722
+ return;
723
+ }
724
+ }
682
725
  inFlightRef.current.add(key);
683
726
  setErrorByKey((current) => {
684
727
  const rest = { ...current };
685
728
  delete rest[key];
686
729
  return rest;
687
730
  });
688
- fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
689
- .then((response) => response.json())
731
+ loadClientCache(
732
+ sharedKey,
733
+ () =>
734
+ fetch(
735
+ linkInsightsUrl(fetchKind, range, sort, source, refreshTick),
736
+ ).then((response) => response.json() as Promise<LinkInsightResponse>),
737
+ {
738
+ force: refreshTick > 0,
739
+ maxAgeMs: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
740
+ },
741
+ )
690
742
  .then((response: LinkInsightResponse) => {
691
743
  cacheRef.current.set(key, response);
692
744
  if (mountedRef.current) {
@@ -731,7 +783,9 @@ function LinksRoute() {
731
783
 
732
784
  useEffect(() => {
733
785
  const handles = collectProfilesForHydration(data).filter(
734
- (handle) => !hydratedHandlesRef.current.has(handle.toLowerCase()),
786
+ (handle) =>
787
+ !readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
788
+ !hydratingLinkProfileHandles.has(handle.toLowerCase()),
735
789
  );
736
790
  if (handles.length === 0) {
737
791
  return;
@@ -741,19 +795,30 @@ function LinksRoute() {
741
795
  const url = new URL("/api/profile-hydrate", window.location.origin);
742
796
  url.searchParams.set("handles", handles.join(","));
743
797
  for (const handle of handles) {
744
- hydratedHandlesRef.current.add(handle.toLowerCase());
798
+ hydratingLinkProfileHandles.add(handle.toLowerCase());
745
799
  }
800
+ const finishHydration = (succeeded: boolean) => {
801
+ for (const handle of handles) {
802
+ const normalized = handle.toLowerCase();
803
+ hydratingLinkProfileHandles.delete(normalized);
804
+ if (succeeded) {
805
+ writeClientCache(hydratedProfileCacheKey(normalized), true);
806
+ }
807
+ }
808
+ };
746
809
 
747
810
  let idleId: number | null = null;
748
811
  const runHydration = () => {
749
812
  fetch(url, { signal: controller.signal })
750
813
  .then((response) => response.json())
751
814
  .then((response: { hydratedProfiles?: number }) => {
815
+ finishHydration(true);
752
816
  if ((response.hydratedProfiles ?? 0) > 0) {
753
817
  setRefreshTick((value) => value + 1);
754
818
  }
755
819
  })
756
820
  .catch((error: unknown) => {
821
+ finishHydration(false);
757
822
  if (error instanceof DOMException && error.name === "AbortError") {
758
823
  return;
759
824
  }
@@ -770,6 +835,7 @@ function LinksRoute() {
770
835
 
771
836
  return () => {
772
837
  controller.abort();
838
+ finishHydration(false);
773
839
  window.clearTimeout(timer);
774
840
  if (idleId !== null && "cancelIdleCallback" in window) {
775
841
  window.cancelIdleCallback(idleId);