birdclaw 0.8.1 → 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.
@@ -1,10 +1,4 @@
1
- import {
2
- Fragment,
3
- type ReactNode,
4
- useLayoutEffect,
5
- useRef,
6
- useState,
7
- } from "react";
1
+ import { Fragment, type ReactNode } from "react";
8
2
  import { formatCompactNumber } from "#/lib/present";
9
3
  import {
10
4
  collectTweetSegmentsForText,
@@ -25,25 +19,7 @@ import {
25
19
  } from "#/lib/ui";
26
20
  import { safeHttpUrl } from "#/lib/url-safety";
27
21
  import { AvatarChip } from "./AvatarChip";
28
-
29
- type VerticalBounds = { top: number; bottom: number };
30
-
31
- function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
32
- let top = 0;
33
- let bottom = window.innerHeight;
34
- for (
35
- let current = element.parentElement;
36
- current;
37
- current = current.parentElement
38
- ) {
39
- const style = window.getComputedStyle(current);
40
- if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
41
- const rect = current.getBoundingClientRect();
42
- top = Math.max(top, rect.top);
43
- bottom = Math.min(bottom, rect.bottom);
44
- }
45
- return { top, bottom };
46
- }
22
+ import { useFloatingPreview } from "./FloatingPreview";
47
23
 
48
24
  function ProfilePreviewBio({ profile }: { profile: ProfileRecord }) {
49
25
  const segments = collectTweetSegmentsForText(
@@ -98,74 +74,56 @@ export function ProfilePreview({
98
74
  children: ReactNode;
99
75
  className?: string;
100
76
  }) {
101
- const [placeAbove, setPlaceAbove] = useState(false);
102
- const shellRef = useRef<HTMLSpanElement | null>(null);
103
- const cardRef = useRef<HTMLSpanElement | null>(null);
104
-
105
- function updatePlacement() {
106
- const shell = shellRef.current;
107
- if (!shell) return;
108
- const shellRect = shell.getBoundingClientRect();
109
- const card = cardRef.current;
110
- const cardRect = card?.getBoundingClientRect();
111
- const cardHeight = Math.max(
112
- card?.offsetHeight ?? 0,
113
- cardRect?.height ?? 0,
114
- 180,
115
- );
116
- const bounds = nearestVerticalClipBounds(shell);
117
- const belowSpace = bounds.bottom - shellRect.bottom;
118
- const aboveSpace = shellRect.top - bounds.top;
119
- setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
120
- }
121
-
122
- useLayoutEffect(() => {
123
- updatePlacement();
124
- const frame = window.requestAnimationFrame(updatePlacement);
125
- return () => window.cancelAnimationFrame(frame);
126
- }, []);
77
+ const preview = useFloatingPreview();
127
78
 
128
79
  return (
129
80
  <span
130
- ref={shellRef}
131
- className={cx(profilePreviewClass, "group", className)}
132
- onFocus={updatePlacement}
133
- onPointerEnter={updatePlacement}
81
+ ref={preview.referenceRef}
82
+ className={cx(profilePreviewClass, className)}
83
+ {...preview.referenceProps}
134
84
  >
135
85
  <a
86
+ aria-controls={preview.open ? preview.floatingId : undefined}
87
+ aria-expanded={preview.open}
136
88
  className={profilePreviewTriggerClass}
137
89
  href={`/profiles/${encodeURIComponent(profile.handle)}`}
138
90
  >
139
91
  {children}
140
92
  </a>
141
- <span
142
- ref={cardRef}
143
- className={cx(
144
- profilePreviewCardClass,
145
- placeAbove
146
- ? "bottom-[calc(100%+8px)] -translate-y-1 group-hover:translate-y-0 group-focus-within:translate-y-0"
147
- : "top-[calc(100%+8px)] translate-y-1 group-hover:translate-y-0 group-focus-within:translate-y-0",
148
- )}
149
- >
150
- <span className={profilePreviewHeaderClass}>
151
- <AvatarChip
152
- avatarUrl={profile.avatarUrl}
153
- hue={profile.avatarHue}
154
- name={profile.displayName}
155
- profileId={profile.id}
156
- />
157
- <span className="flex min-w-0 flex-col">
158
- <span className={profilePreviewNameClass}>
159
- {profile.displayName}
93
+ {preview.open ? (
94
+ <span
95
+ aria-label={`${profile.displayName} profile preview`}
96
+ id={preview.floatingId}
97
+ ref={preview.floatingRef}
98
+ className={profilePreviewCardClass}
99
+ role="group"
100
+ style={preview.floatingStyle}
101
+ {...preview.floatingProps}
102
+ >
103
+ <span className="grid gap-2" data-floating-preview-content>
104
+ <span className={profilePreviewHeaderClass}>
105
+ <AvatarChip
106
+ avatarUrl={profile.avatarUrl}
107
+ hue={profile.avatarHue}
108
+ name={profile.displayName}
109
+ profileId={profile.id}
110
+ />
111
+ <span className="flex min-w-0 flex-col">
112
+ <span className={profilePreviewNameClass}>
113
+ {profile.displayName}
114
+ </span>
115
+ <span className={profilePreviewHandleClass}>
116
+ @{profile.handle}
117
+ </span>
118
+ </span>
119
+ </span>
120
+ {profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
121
+ <span className={profilePreviewMetaClass}>
122
+ {formatCompactNumber(profile.followersCount)} followers
160
123
  </span>
161
- <span className={profilePreviewHandleClass}>@{profile.handle}</span>
162
124
  </span>
163
125
  </span>
164
- {profile.bio ? <ProfilePreviewBio profile={profile} /> : null}
165
- <span className={profilePreviewMetaClass}>
166
- {formatCompactNumber(profile.followersCount)} followers
167
- </span>
168
- </span>
126
+ ) : null}
169
127
  </span>
170
128
  );
171
129
  }
@@ -9,7 +9,10 @@ import {
9
9
  UserSearch,
10
10
  } from "lucide-react";
11
11
  import { formatCompactNumber } from "#/lib/present";
12
- import { normalizeTweetUrlEntityRangeForText } from "#/lib/tweet-render";
12
+ import {
13
+ isTweetArticleUrlEntity,
14
+ normalizeTweetUrlEntityRangeForText,
15
+ } from "#/lib/tweet-render";
13
16
  import type {
14
17
  TimelineItem,
15
18
  TweetEntities,
@@ -42,6 +45,7 @@ import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
42
45
  import { LinkPreviewCard } from "./LinkPreviewCard";
43
46
  import { ProfilePreview } from "./ProfilePreview";
44
47
  import { SmartTimestamp } from "./SmartTimestamp";
48
+ import { TweetArticleCard } from "./TweetArticleCard";
45
49
  import { TweetMediaGrid } from "./TweetMediaGrid";
46
50
  import { TweetRichText } from "./TweetRichText";
47
51
 
@@ -213,6 +217,9 @@ function getVisibleUrlCards(
213
217
  ) {
214
218
  return (entities.urls ?? []).filter((entry) => {
215
219
  if (isUnresolvedShortUrlEntity(entry)) return false;
220
+ if (entities.article && isTweetArticleUrlEntity(entry, entities.article)) {
221
+ return false;
222
+ }
216
223
  if (!quotedTweetId) return true;
217
224
  return !entry.expandedUrl.includes(quotedTweetId);
218
225
  });
@@ -277,7 +284,10 @@ export function TimelineCard({
277
284
 
278
285
  return (
279
286
  <article
280
- className={cx(feedRowClass, "cursor-pointer")}
287
+ className={cx(
288
+ feedRowClass,
289
+ "cursor-pointer [content-visibility:auto] [contain-intrinsic-size:auto_280px]",
290
+ )}
281
291
  data-perf="timeline-card"
282
292
  onFocus={conversation.prefetch}
283
293
  onMouseEnter={conversation.prefetch}
@@ -363,6 +373,9 @@ export function TimelineCard({
363
373
  text={displayTweet.text}
364
374
  />
365
375
  <TweetMediaGrid items={displayTweet.media} />
376
+ {displayTweet.entities.article ? (
377
+ <TweetArticleCard article={displayTweet.entities.article} />
378
+ ) : null}
366
379
  {visibleUrlCards.map((entry, index) => (
367
380
  <LinkPreviewCard
368
381
  key={`${entry.expandedUrl}-${String(index)}`}
@@ -380,6 +393,9 @@ export function TimelineCard({
380
393
  text={item.text}
381
394
  />
382
395
  <TweetMediaGrid items={item.media} />
396
+ {item.entities.article ? (
397
+ <TweetArticleCard article={item.entities.article} />
398
+ ) : null}
383
399
  {item.replyToTweet ? (
384
400
  <div className={embeddedCardClass}>
385
401
  <EmbeddedTweetCard
@@ -0,0 +1,66 @@
1
+ import { BookOpen, ExternalLink } from "lucide-react";
2
+ import type { TweetArticle } from "#/lib/types";
3
+ import {
4
+ linkPreviewCardClass,
5
+ linkPreviewDescClass,
6
+ linkPreviewHostClass,
7
+ linkPreviewTitleClass,
8
+ } from "#/lib/ui";
9
+ import { safeHttpUrl } from "#/lib/url-safety";
10
+
11
+ function safeArticleImageUrl(value: string | undefined) {
12
+ const url = safeHttpUrl(value);
13
+ if (!url) return null;
14
+ try {
15
+ return new URL(url).hostname === "pbs.twimg.com" ? url : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function TweetArticleCard({ article }: { article: TweetArticle }) {
22
+ const href = safeHttpUrl(article.url);
23
+ if (!href) return null;
24
+ const imageUrl = safeArticleImageUrl(article.coverImageUrl);
25
+
26
+ return (
27
+ <a
28
+ aria-label={`Read article: ${article.title}`}
29
+ className={linkPreviewCardClass}
30
+ data-perf="tweet-article-card"
31
+ href={href}
32
+ rel="noreferrer"
33
+ target="_blank"
34
+ >
35
+ <div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
36
+ <div className="flex min-w-0 items-center gap-2">
37
+ <BookOpen
38
+ aria-hidden="true"
39
+ className="size-3.5 shrink-0 text-[var(--ink-soft)]"
40
+ strokeWidth={1.8}
41
+ />
42
+ <span className={linkPreviewHostClass}>Article on X</span>
43
+ <ExternalLink
44
+ aria-hidden="true"
45
+ className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
46
+ strokeWidth={1.8}
47
+ />
48
+ </div>
49
+ <span className={linkPreviewTitleClass}>{article.title}</span>
50
+ {article.previewText ? (
51
+ <span className={linkPreviewDescClass}>{article.previewText}</span>
52
+ ) : null}
53
+ </div>
54
+ {imageUrl ? (
55
+ <div className="flex aspect-[1.45] w-40 shrink-0 overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
56
+ <img
57
+ alt=""
58
+ className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
59
+ loading="lazy"
60
+ src={imageUrl}
61
+ />
62
+ </div>
63
+ ) : null}
64
+ </a>
65
+ );
66
+ }
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
3
3
  import {
4
4
  collectTweetSegmentsForText,
5
5
  enrichFallbackUrlEntities,
6
+ isTweetArticleUrlEntity,
6
7
  normalizeTweetUrlEntityRangeForText,
7
8
  } from "#/lib/tweet-render";
8
9
  import type { TweetEntities } from "#/lib/types";
@@ -19,6 +20,14 @@ function rangeKey(range: { start: number; end: number }) {
19
20
  return `${range.start}:${range.end}`;
20
21
  }
21
22
 
23
+ function isShortUrl(value: string) {
24
+ try {
25
+ return new URL(value).hostname.replace(/^www\./, "") === "t.co";
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
22
31
  export function TweetRichText({
23
32
  text,
24
33
  entities,
@@ -37,6 +46,25 @@ export function TweetRichText({
37
46
  const richEntities = enrichFallbackUrlEntities(text, entities);
38
47
  const segments = collectTweetSegmentsForText(text, richEntities);
39
48
  const hiddenRawRangeKeys = new Set(hiddenUrlRanges.map(rangeKey));
49
+ const article = entities.article;
50
+ if (article) {
51
+ const urlEntries = richEntities.urls ?? [];
52
+ const articleUrlEntries = urlEntries.filter((entry) =>
53
+ isTweetArticleUrlEntity(entry, article),
54
+ );
55
+ const onlyUrlEntry = urlEntries[0];
56
+ if (
57
+ articleUrlEntries.length === 0 &&
58
+ urlEntries.length === 1 &&
59
+ onlyUrlEntry &&
60
+ (isShortUrl(onlyUrlEntry.url) || isShortUrl(onlyUrlEntry.expandedUrl))
61
+ ) {
62
+ articleUrlEntries.push(onlyUrlEntry);
63
+ }
64
+ for (const entry of articleUrlEntries) {
65
+ hiddenRawRangeKeys.add(rangeKey(entry));
66
+ }
67
+ }
40
68
  const hiddenRangeKeys = new Set(hiddenRawRangeKeys);
41
69
  for (const entry of richEntities.urls ?? []) {
42
70
  if (!hiddenRawRangeKeys.has(rangeKey(entry))) continue;
@@ -46,10 +74,13 @@ export function TweetRichText({
46
74
  }
47
75
  const Wrapper = as;
48
76
  let cursor = 0;
77
+ const hideArticleTitle =
78
+ entities.article && text.trim() === entities.article.title.trim();
79
+ const visibleSegments = hideArticleTitle ? [] : segments;
49
80
 
50
81
  return (
51
82
  <Wrapper className={className === "body-copy" ? bodyCopyClass : className}>
52
- {segments.map((segment, index) => {
83
+ {visibleSegments.map((segment, index) => {
53
84
  if (
54
85
  segment.start < cursor ||
55
86
  segment.end <= segment.start ||
@@ -122,7 +153,7 @@ export function TweetRichText({
122
153
  </Fragment>
123
154
  );
124
155
  })}
125
- {text.slice(cursor)}
156
+ {hideArticleTitle ? null : text.slice(cursor)}
126
157
  </Wrapper>
127
158
  );
128
159
  }
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebouncedValue<T>(value: T, delayMs: number) {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timeout = window.setTimeout(() => setDebouncedValue(value), delayMs);
8
+ return () => window.clearTimeout(timeout);
9
+ }, [delayMs, value]);
10
+
11
+ return debouncedValue;
12
+ }
@@ -6,13 +6,30 @@ import type {
6
6
  TimelineItem,
7
7
  } from "#/lib/types";
8
8
  import {
9
+ fetchCachedQueryResponse,
9
10
  fetchQueryEnvelope,
10
- fetchQueryResponse,
11
+ invalidateCachedQueryResponse,
12
+ invalidateCachedQueryResponses,
11
13
  postAction,
14
+ readCachedQueryEnvelope,
12
15
  } from "#/lib/api-client";
16
+ import {
17
+ deleteClientCache,
18
+ deleteClientCacheByPrefix,
19
+ readClientCache,
20
+ writeClientCache,
21
+ } from "#/lib/client-cache";
13
22
  import { useSelectedAccountId } from "./account-selection";
23
+ import { useDebouncedValue } from "./useDebouncedValue";
14
24
 
15
25
  const PAGE_SIZE = 50;
26
+ const TIMELINE_VIEW_CACHE_PREFIX = "timeline-view:";
27
+ const TIMELINE_VIEW_CACHE_MAX_AGE_MS = 5 * 60_000;
28
+
29
+ interface TimelineViewSnapshot {
30
+ items: TimelineItem[];
31
+ hasMore: boolean;
32
+ }
16
33
 
17
34
  interface UseTimelineRouteDataOptions {
18
35
  resource: Exclude<ResourceKind, "dms">;
@@ -23,6 +40,53 @@ interface UseTimelineRouteDataOptions {
23
40
  bookmarkedOnly?: boolean;
24
41
  }
25
42
 
43
+ function buildTimelineQueryUrl({
44
+ resource,
45
+ search,
46
+ replyFilter,
47
+ likedOnly,
48
+ bookmarkedOnly,
49
+ selectedAccountId,
50
+ refreshTick,
51
+ until,
52
+ untilId,
53
+ }: {
54
+ resource: Exclude<ResourceKind, "dms">;
55
+ search: string;
56
+ replyFilter?: ReplyFilter;
57
+ likedOnly: boolean;
58
+ bookmarkedOnly: boolean;
59
+ selectedAccountId?: string;
60
+ refreshTick: number;
61
+ until?: string;
62
+ untilId?: string;
63
+ }) {
64
+ const params = new URLSearchParams({
65
+ resource,
66
+ limit: String(PAGE_SIZE),
67
+ });
68
+ if (selectedAccountId) params.set("account", selectedAccountId);
69
+ params.set("refresh", String(refreshTick));
70
+ if (replyFilter) params.set("replyFilter", replyFilter);
71
+ if (likedOnly) params.set("liked", "true");
72
+ if (bookmarkedOnly) params.set("bookmarked", "true");
73
+ if (search.trim()) params.set("search", search.trim());
74
+ if (until) params.set("until", until);
75
+ if (untilId) params.set("untilId", untilId);
76
+ params.sort();
77
+ const base =
78
+ typeof window === "undefined"
79
+ ? "http://birdclaw.local"
80
+ : window.location.origin;
81
+ return new URL(`/api/query?${params.toString()}`, base).toString();
82
+ }
83
+
84
+ function timelineViewCacheKey(requestUrl: string) {
85
+ const url = new URL(requestUrl, "http://birdclaw.local");
86
+ url.searchParams.delete("refresh");
87
+ return `${TIMELINE_VIEW_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
88
+ }
89
+
26
90
  export function useTimelineRouteData({
27
91
  resource,
28
92
  search,
@@ -31,75 +95,98 @@ export function useTimelineRouteData({
31
95
  likedOnly = false,
32
96
  bookmarkedOnly = false,
33
97
  }: UseTimelineRouteDataOptions) {
34
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
35
- const [items, setItems] = useState<TimelineItem[]>([]);
36
- const [loading, setLoading] = useState(true);
98
+ const [meta, setMeta] = useState<QueryEnvelope | null>(
99
+ () => readCachedQueryEnvelope() ?? null,
100
+ );
101
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
102
+ const debouncedSearch = useDebouncedValue(search, 180);
103
+ const initialRequestUrl = buildTimelineQueryUrl({
104
+ resource,
105
+ search: debouncedSearch,
106
+ replyFilter,
107
+ likedOnly,
108
+ bookmarkedOnly,
109
+ selectedAccountId,
110
+ refreshTick: 0,
111
+ });
112
+ const initialSnapshot = useRef(
113
+ readClientCache<TimelineViewSnapshot>(
114
+ timelineViewCacheKey(initialRequestUrl),
115
+ TIMELINE_VIEW_CACHE_MAX_AGE_MS,
116
+ ),
117
+ );
118
+ const [items, setItems] = useState<TimelineItem[]>(
119
+ () => initialSnapshot.current?.items ?? [],
120
+ );
121
+ const [loading, setLoading] = useState(() => !initialSnapshot.current);
37
122
  const [error, setError] = useState<string | null>(null);
38
123
  const [replyError, setReplyError] = useState<string | null>(null);
39
124
  const [refreshTick, setRefreshTick] = useState(0);
40
- const [hasMore, setHasMore] = useState(false);
125
+ const [hasMore, setHasMore] = useState(
126
+ () => initialSnapshot.current?.hasMore ?? false,
127
+ );
41
128
  const [loadingMore, setLoadingMore] = useState(false);
42
- const selectedAccountId = useSelectedAccountId(meta?.accounts);
43
129
  // Bumped whenever the active filters change. A `loadMore` request that
44
130
  // resolves against a stale generation is discarded so its older page is
45
131
  // never appended to a freshly loaded feed.
46
132
  const generationRef = useRef(0);
47
133
  const loadMoreControllerRef = useRef<AbortController | null>(null);
134
+ const activeRequestUrlRef = useRef(initialRequestUrl);
48
135
 
49
- async function loadStatus() {
50
- setMeta(await fetchQueryEnvelope());
136
+ async function loadStatus(force = false) {
137
+ setMeta(await fetchQueryEnvelope(undefined, { force }));
51
138
  }
52
139
 
53
140
  useEffect(() => {
54
141
  void loadStatus();
55
142
  }, []);
56
143
 
57
- // Build the /api/query URL for the current filters. Passing the last item's
58
- // (createdAt, id) requests the next (older) page via the server's keyset
59
- // cursor, which is deterministic across duplicate timestamps.
60
- function buildQueryUrl(until?: string, untilId?: string) {
61
- const url = new URL("/api/query", window.location.origin);
62
- url.searchParams.set("resource", resource);
63
- url.searchParams.set("refresh", String(refreshTick));
64
- url.searchParams.set("limit", String(PAGE_SIZE));
65
- if (selectedAccountId) {
66
- url.searchParams.set("account", selectedAccountId);
67
- }
68
- if (replyFilter) {
69
- url.searchParams.set("replyFilter", replyFilter);
70
- }
71
- if (likedOnly) {
72
- url.searchParams.set("liked", "true");
73
- }
74
- if (bookmarkedOnly) {
75
- url.searchParams.set("bookmarked", "true");
76
- }
77
- if (search.trim()) {
78
- url.searchParams.set("search", search.trim());
79
- }
80
- if (until) {
81
- url.searchParams.set("until", until);
82
- }
83
- if (untilId) {
84
- url.searchParams.set("untilId", untilId);
85
- }
86
- return url;
87
- }
88
-
89
144
  useEffect(() => {
90
145
  generationRef.current += 1;
91
146
  loadMoreControllerRef.current?.abort();
92
147
  const controller = new AbortController();
93
148
  let active = true;
149
+ const requestUrl = buildTimelineQueryUrl({
150
+ resource,
151
+ search: debouncedSearch,
152
+ replyFilter,
153
+ likedOnly,
154
+ bookmarkedOnly,
155
+ selectedAccountId,
156
+ refreshTick,
157
+ });
158
+ const viewCacheKey = timelineViewCacheKey(requestUrl);
159
+ activeRequestUrlRef.current = requestUrl;
94
160
  setError(null);
95
- setLoading(true);
96
161
  setLoadingMore(false);
97
- fetchQueryResponse(buildQueryUrl(), { signal: controller.signal })
162
+ const cached = readClientCache<TimelineViewSnapshot>(
163
+ viewCacheKey,
164
+ TIMELINE_VIEW_CACHE_MAX_AGE_MS,
165
+ );
166
+ if (cached) {
167
+ setItems(cached.items);
168
+ setHasMore(cached.hasMore);
169
+ setLoading(false);
170
+ return () => {
171
+ active = false;
172
+ controller.abort();
173
+ };
174
+ }
175
+
176
+ setItems([]);
177
+ setHasMore(false);
178
+ setLoading(true);
179
+ fetchCachedQueryResponse(requestUrl, { signal: controller.signal })
98
180
  .then((data) => {
99
181
  if (!active) return;
100
182
  const next = data.items as TimelineItem[];
101
183
  setItems(next);
102
- setHasMore(next.length >= PAGE_SIZE);
184
+ const nextHasMore = next.length >= PAGE_SIZE;
185
+ setHasMore(nextHasMore);
186
+ writeClientCache(viewCacheKey, {
187
+ items: next,
188
+ hasMore: nextHasMore,
189
+ });
103
190
  })
104
191
  .catch((fetchError: unknown) => {
105
192
  if (
@@ -127,12 +214,12 @@ export function useTimelineRouteData({
127
214
  };
128
215
  }, [
129
216
  bookmarkedOnly,
217
+ debouncedSearch,
130
218
  errorFallback,
131
219
  likedOnly,
132
220
  refreshTick,
133
221
  replyFilter,
134
222
  resource,
135
- search,
136
223
  selectedAccountId,
137
224
  ]);
138
225
 
@@ -147,7 +234,13 @@ export function useTimelineRouteData({
147
234
  loadMoreControllerRef.current = controller;
148
235
  setLoadingMore(true);
149
236
  try {
150
- const data = await fetchQueryResponse(buildQueryUrl(until, untilId), {
237
+ const nextPageUrl = new URL(
238
+ activeRequestUrlRef.current,
239
+ window.location.origin,
240
+ );
241
+ nextPageUrl.searchParams.set("until", until);
242
+ nextPageUrl.searchParams.set("untilId", untilId);
243
+ const data = await fetchCachedQueryResponse(nextPageUrl, {
151
244
  signal: controller.signal,
152
245
  });
153
246
  // Discard if the filters changed (new generation) while in flight.
@@ -155,7 +248,12 @@ export function useTimelineRouteData({
155
248
  const page = data.items as TimelineItem[];
156
249
  setItems((prev) => {
157
250
  const seen = new Set(prev.map((item) => item.id));
158
- return [...prev, ...page.filter((item) => !seen.has(item.id))];
251
+ const next = [...prev, ...page.filter((item) => !seen.has(item.id))];
252
+ writeClientCache(timelineViewCacheKey(activeRequestUrlRef.current), {
253
+ items: next,
254
+ hasMore: page.length >= PAGE_SIZE,
255
+ });
256
+ return next;
159
257
  });
160
258
  setHasMore(page.length >= PAGE_SIZE);
161
259
  } catch (loadError) {
@@ -175,12 +273,16 @@ export function useTimelineRouteData({
175
273
  }
176
274
 
177
275
  function retry() {
276
+ invalidateCachedQueryResponse(activeRequestUrlRef.current);
277
+ deleteClientCache(timelineViewCacheKey(activeRequestUrlRef.current));
178
278
  setRefreshTick((value) => value + 1);
179
279
  }
180
280
 
181
281
  function refreshLocalView() {
282
+ invalidateCachedQueryResponses();
283
+ deleteClientCacheByPrefix(TIMELINE_VIEW_CACHE_PREFIX);
182
284
  setRefreshTick((value) => value + 1);
183
- void loadStatus();
285
+ void loadStatus(true);
184
286
  }
185
287
 
186
288
  async function replyToTweet(tweetId: string) {