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.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +4 -0
- package/src/components/EmbeddedTweetCard.tsx +4 -0
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/MarkdownViewer.tsx +57 -99
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/TimelineCard.tsx +18 -2
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +149 -47
- package/src/lib/api-client.ts +117 -2
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/authored-live.ts +2 -62
- package/src/lib/bird.ts +32 -1
- package/src/lib/client-cache.ts +109 -0
- package/src/lib/db.ts +57 -4
- package/src/lib/mention-threads-live.ts +2 -1
- package/src/lib/mentions-live.ts +2 -46
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +7 -6
- package/src/lib/sqlite.ts +5 -2
- package/src/lib/timeline-collections-live.ts +2 -1
- package/src/lib/timeline-live.ts +2 -1
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-search-live.ts +2 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +0 -13
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +71 -6
- package/vite.config.ts +0 -2
|
@@ -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
|
|
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={
|
|
131
|
-
className={cx(profilePreviewClass,
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
|
|
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>(
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|