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.
- package/CHANGELOG.md +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import type { TweetMediaItem } from "#/lib/types";
|
|
4
|
-
import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
4
|
+
import { cx, tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
5
5
|
|
|
6
6
|
export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
7
7
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
@@ -16,47 +16,83 @@ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
|
16
16
|
selectedItem?.type === "video" || selectedItem?.type === "gif"
|
|
17
17
|
? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
|
|
18
18
|
: null;
|
|
19
|
+
const singleImage =
|
|
20
|
+
visibleItems.length === 1 && visibleItems[0]?.type === "image"
|
|
21
|
+
? visibleItems[0]
|
|
22
|
+
: null;
|
|
19
23
|
|
|
20
24
|
return (
|
|
21
25
|
<>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
loading="lazy"
|
|
46
|
-
src={item.thumbnailUrl ?? item.url}
|
|
47
|
-
/>
|
|
48
|
-
) : (
|
|
49
|
-
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
50
|
-
{item.type === "video"
|
|
51
|
-
? "Video"
|
|
52
|
-
: item.type === "gif"
|
|
53
|
-
? "GIF"
|
|
54
|
-
: "Media"}
|
|
55
|
-
</span>
|
|
26
|
+
{singleImage ? (
|
|
27
|
+
<button
|
|
28
|
+
aria-label="Open tweet media 1"
|
|
29
|
+
className={cx(
|
|
30
|
+
"tweet-media-single mt-2 max-w-full overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-active)] p-0 text-left",
|
|
31
|
+
singleImage.width && singleImage.height
|
|
32
|
+
? "block"
|
|
33
|
+
: "inline-block align-top",
|
|
34
|
+
)}
|
|
35
|
+
onClick={(event) => {
|
|
36
|
+
event.stopPropagation();
|
|
37
|
+
setSelectedIndex(0);
|
|
38
|
+
}}
|
|
39
|
+
style={singleImageStyle(singleImage)}
|
|
40
|
+
type="button"
|
|
41
|
+
>
|
|
42
|
+
<img
|
|
43
|
+
alt={singleImage.altText ?? "Tweet media 1"}
|
|
44
|
+
className={cx(
|
|
45
|
+
"tweet-media-image block max-h-[720px] max-w-full",
|
|
46
|
+
singleImage.width && singleImage.height
|
|
47
|
+
? "size-full object-cover"
|
|
48
|
+
: "h-auto w-auto object-contain",
|
|
56
49
|
)}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
height={singleImage.height}
|
|
51
|
+
loading="lazy"
|
|
52
|
+
src={singleImage.thumbnailUrl ?? singleImage.url}
|
|
53
|
+
width={singleImage.width}
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
) : (
|
|
57
|
+
<div className={tweetMediaGridClass(Math.min(items.length, 4))}>
|
|
58
|
+
{visibleItems.map((item, index) => (
|
|
59
|
+
<button
|
|
60
|
+
key={item.url + String(index)}
|
|
61
|
+
aria-label={`Open tweet media ${String(index + 1)}`}
|
|
62
|
+
className={tweetMediaTileClass(index, Math.min(items.length, 4))}
|
|
63
|
+
onClick={(event) => {
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
setSelectedIndex(index);
|
|
66
|
+
}}
|
|
67
|
+
style={
|
|
68
|
+
visibleItems.length === 1 && item.width && item.height
|
|
69
|
+
? {
|
|
70
|
+
aspectRatio: `${String(item.width)} / ${String(item.height)}`,
|
|
71
|
+
}
|
|
72
|
+
: undefined
|
|
73
|
+
}
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
{item.type === "image" ? (
|
|
77
|
+
<img
|
|
78
|
+
alt={item.altText ?? `Tweet media ${String(index + 1)}`}
|
|
79
|
+
className="tweet-media-image block size-full object-contain"
|
|
80
|
+
loading="lazy"
|
|
81
|
+
src={item.thumbnailUrl ?? item.url}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
85
|
+
{item.type === "video"
|
|
86
|
+
? "Video"
|
|
87
|
+
: item.type === "gif"
|
|
88
|
+
? "GIF"
|
|
89
|
+
: "Media"}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
60
96
|
{selectedItem ? (
|
|
61
97
|
<div
|
|
62
98
|
aria-modal="true"
|
|
@@ -125,6 +161,19 @@ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
|
125
161
|
);
|
|
126
162
|
}
|
|
127
163
|
|
|
164
|
+
function singleImageStyle(item: TweetMediaItem) {
|
|
165
|
+
if (!item.width || !item.height) return undefined;
|
|
166
|
+
const maxHeight = 720;
|
|
167
|
+
const width = Math.min(
|
|
168
|
+
item.width,
|
|
169
|
+
Math.round((item.width / item.height) * maxHeight),
|
|
170
|
+
);
|
|
171
|
+
return {
|
|
172
|
+
aspectRatio: `${String(item.width)} / ${String(item.height)}`,
|
|
173
|
+
width: `${String(width)}px`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
function playableVideoUrl(url: string) {
|
|
129
178
|
try {
|
|
130
179
|
const parsed = new URL(url);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Fragment } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
collectTweetSegmentsForText,
|
|
5
5
|
enrichFallbackUrlEntities,
|
|
6
|
+
normalizeTweetUrlEntityRangeForText,
|
|
6
7
|
} from "#/lib/tweet-render";
|
|
7
8
|
import type { TweetEntities } from "#/lib/types";
|
|
8
9
|
import {
|
|
@@ -11,25 +12,43 @@ import {
|
|
|
11
12
|
tweetLinkClass,
|
|
12
13
|
tweetMentionClass,
|
|
13
14
|
} from "#/lib/ui";
|
|
15
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
14
16
|
import { ProfilePreview } from "./ProfilePreview";
|
|
15
17
|
|
|
18
|
+
function rangeKey(range: { start: number; end: number }) {
|
|
19
|
+
return `${range.start}:${range.end}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
export function TweetRichText({
|
|
17
23
|
text,
|
|
18
24
|
entities,
|
|
19
25
|
className = "body-copy",
|
|
20
26
|
hiddenUrlRanges = [],
|
|
27
|
+
urlLabel = "display",
|
|
28
|
+
as = "p",
|
|
21
29
|
}: {
|
|
22
30
|
text: string;
|
|
23
31
|
entities: TweetEntities;
|
|
24
32
|
className?: string;
|
|
25
33
|
hiddenUrlRanges?: Array<{ start: number; end: number }>;
|
|
34
|
+
urlLabel?: "display" | "expanded";
|
|
35
|
+
as?: "p" | "span";
|
|
26
36
|
}) {
|
|
27
37
|
const richEntities = enrichFallbackUrlEntities(text, entities);
|
|
28
|
-
const segments =
|
|
38
|
+
const segments = collectTweetSegmentsForText(text, richEntities);
|
|
39
|
+
const hiddenRawRangeKeys = new Set(hiddenUrlRanges.map(rangeKey));
|
|
40
|
+
const hiddenRangeKeys = new Set(hiddenRawRangeKeys);
|
|
41
|
+
for (const entry of richEntities.urls ?? []) {
|
|
42
|
+
if (!hiddenRawRangeKeys.has(rangeKey(entry))) continue;
|
|
43
|
+
hiddenRangeKeys.add(
|
|
44
|
+
rangeKey(normalizeTweetUrlEntityRangeForText(text, entry)),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const Wrapper = as;
|
|
29
48
|
let cursor = 0;
|
|
30
49
|
|
|
31
50
|
return (
|
|
32
|
-
<
|
|
51
|
+
<Wrapper className={className === "body-copy" ? bodyCopyClass : className}>
|
|
33
52
|
{segments.map((segment, index) => {
|
|
34
53
|
if (
|
|
35
54
|
segment.start < cursor ||
|
|
@@ -47,13 +66,7 @@ export function TweetRichText({
|
|
|
47
66
|
{text.slice(segment.start, segment.end)}
|
|
48
67
|
</Fragment>
|
|
49
68
|
);
|
|
50
|
-
if (
|
|
51
|
-
segment.kind === "url" &&
|
|
52
|
-
hiddenUrlRanges.some(
|
|
53
|
-
(range) =>
|
|
54
|
-
range.start === segment.start && range.end === segment.end,
|
|
55
|
-
)
|
|
56
|
-
) {
|
|
69
|
+
if (segment.kind === "url" && hiddenRangeKeys.has(rangeKey(segment))) {
|
|
57
70
|
node = null;
|
|
58
71
|
} else if (segment.kind === "mention" && segment.profile) {
|
|
59
72
|
node = (
|
|
@@ -64,18 +77,33 @@ export function TweetRichText({
|
|
|
64
77
|
<span className={tweetMentionClass}>@{segment.username}</span>
|
|
65
78
|
</ProfilePreview>
|
|
66
79
|
);
|
|
67
|
-
} else if (segment.kind === "
|
|
80
|
+
} else if (segment.kind === "mention") {
|
|
68
81
|
node = (
|
|
69
82
|
<a
|
|
70
83
|
key={`segment-${String(index)}`}
|
|
71
|
-
className={
|
|
72
|
-
href={segment.
|
|
73
|
-
rel="noreferrer"
|
|
74
|
-
target="_blank"
|
|
84
|
+
className={tweetMentionClass}
|
|
85
|
+
href={`/profiles/${encodeURIComponent(segment.username)}`}
|
|
75
86
|
>
|
|
76
|
-
{segment.
|
|
87
|
+
@{segment.username}
|
|
77
88
|
</a>
|
|
78
89
|
);
|
|
90
|
+
} else if (segment.kind === "url") {
|
|
91
|
+
const href = safeHttpUrl(segment.expandedUrl);
|
|
92
|
+
if (href) {
|
|
93
|
+
node = (
|
|
94
|
+
<a
|
|
95
|
+
key={`segment-${String(index)}`}
|
|
96
|
+
className={tweetLinkClass}
|
|
97
|
+
href={href}
|
|
98
|
+
rel="noreferrer"
|
|
99
|
+
target="_blank"
|
|
100
|
+
>
|
|
101
|
+
{urlLabel === "expanded"
|
|
102
|
+
? segment.expandedUrl
|
|
103
|
+
: segment.displayUrl}
|
|
104
|
+
</a>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
79
107
|
} else if (segment.kind === "hashtag") {
|
|
80
108
|
node = (
|
|
81
109
|
<span
|
|
@@ -95,6 +123,6 @@ export function TweetRichText({
|
|
|
95
123
|
);
|
|
96
124
|
})}
|
|
97
125
|
{text.slice(cursor)}
|
|
98
|
-
</
|
|
126
|
+
</Wrapper>
|
|
99
127
|
);
|
|
100
128
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { AccountRecord } from "#/lib/types";
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = "birdclaw:selected-account-id";
|
|
5
|
+
const CHANGE_EVENT = "birdclaw-account-change";
|
|
6
|
+
|
|
7
|
+
export function defaultAccountId(accounts: AccountRecord[] | undefined) {
|
|
8
|
+
if (!accounts?.length) return undefined;
|
|
9
|
+
return accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readStoredAccountId() {
|
|
13
|
+
if (typeof window === "undefined") return undefined;
|
|
14
|
+
return window.localStorage.getItem(STORAGE_KEY) ?? undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setStoredAccountId(accountId: string) {
|
|
18
|
+
if (typeof window === "undefined") return;
|
|
19
|
+
window.localStorage.setItem(STORAGE_KEY, accountId);
|
|
20
|
+
window.dispatchEvent(
|
|
21
|
+
new CustomEvent(CHANGE_EVENT, {
|
|
22
|
+
detail: { accountId },
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useSelectedAccountId(accounts: AccountRecord[] | undefined) {
|
|
28
|
+
const fallbackAccountId = useMemo(
|
|
29
|
+
() => defaultAccountId(accounts),
|
|
30
|
+
[accounts],
|
|
31
|
+
);
|
|
32
|
+
const [selectedAccountId, setSelectedAccountId] = useState<
|
|
33
|
+
string | undefined
|
|
34
|
+
>(() => readStoredAccountId());
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const onChange = (event: Event) => {
|
|
38
|
+
const custom = event as CustomEvent<{ accountId?: string }>;
|
|
39
|
+
setSelectedAccountId(custom.detail?.accountId ?? readStoredAccountId());
|
|
40
|
+
};
|
|
41
|
+
const onStorage = () => setSelectedAccountId(readStoredAccountId());
|
|
42
|
+
window.addEventListener(CHANGE_EVENT, onChange);
|
|
43
|
+
window.addEventListener("storage", onStorage);
|
|
44
|
+
return () => {
|
|
45
|
+
window.removeEventListener(CHANGE_EVENT, onChange);
|
|
46
|
+
window.removeEventListener("storage", onStorage);
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!accounts?.length || !fallbackAccountId) return;
|
|
52
|
+
const current = readStoredAccountId();
|
|
53
|
+
const valid = current && accounts.some((account) => account.id === current);
|
|
54
|
+
if (!valid) {
|
|
55
|
+
setStoredAccountId(fallbackAccountId);
|
|
56
|
+
setSelectedAccountId(fallbackAccountId);
|
|
57
|
+
}
|
|
58
|
+
}, [accounts, fallbackAccountId]);
|
|
59
|
+
|
|
60
|
+
return selectedAccountId &&
|
|
61
|
+
accounts?.some((account) => account.id === selectedAccountId)
|
|
62
|
+
? selectedAccountId
|
|
63
|
+
: fallbackAccountId;
|
|
64
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import type {
|
|
3
3
|
QueryEnvelope,
|
|
4
4
|
ReplyFilter,
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
fetchQueryResponse,
|
|
11
11
|
postAction,
|
|
12
12
|
} from "#/lib/api-client";
|
|
13
|
+
import { useSelectedAccountId } from "./account-selection";
|
|
14
|
+
|
|
15
|
+
const PAGE_SIZE = 50;
|
|
13
16
|
|
|
14
17
|
interface UseTimelineRouteDataOptions {
|
|
15
18
|
resource: Exclude<ResourceKind, "dms">;
|
|
@@ -32,7 +35,16 @@ export function useTimelineRouteData({
|
|
|
32
35
|
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
33
36
|
const [loading, setLoading] = useState(true);
|
|
34
37
|
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
const [replyError, setReplyError] = useState<string | null>(null);
|
|
35
39
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
40
|
+
const [hasMore, setHasMore] = useState(false);
|
|
41
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
42
|
+
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
43
|
+
// Bumped whenever the active filters change. A `loadMore` request that
|
|
44
|
+
// resolves against a stale generation is discarded so its older page is
|
|
45
|
+
// never appended to a freshly loaded feed.
|
|
46
|
+
const generationRef = useRef(0);
|
|
47
|
+
const loadMoreControllerRef = useRef<AbortController | null>(null);
|
|
36
48
|
|
|
37
49
|
async function loadStatus() {
|
|
38
50
|
setMeta(await fetchQueryEnvelope());
|
|
@@ -42,10 +54,17 @@ export function useTimelineRouteData({
|
|
|
42
54
|
void loadStatus();
|
|
43
55
|
}, []);
|
|
44
56
|
|
|
45
|
-
|
|
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) {
|
|
46
61
|
const url = new URL("/api/query", window.location.origin);
|
|
47
62
|
url.searchParams.set("resource", resource);
|
|
48
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
|
+
}
|
|
49
68
|
if (replyFilter) {
|
|
50
69
|
url.searchParams.set("replyFilter", replyFilter);
|
|
51
70
|
}
|
|
@@ -58,16 +77,29 @@ export function useTimelineRouteData({
|
|
|
58
77
|
if (search.trim()) {
|
|
59
78
|
url.searchParams.set("search", search.trim());
|
|
60
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
|
+
}
|
|
61
88
|
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
generationRef.current += 1;
|
|
91
|
+
loadMoreControllerRef.current?.abort();
|
|
62
92
|
const controller = new AbortController();
|
|
63
93
|
let active = true;
|
|
64
94
|
setError(null);
|
|
65
95
|
setLoading(true);
|
|
66
|
-
|
|
96
|
+
setLoadingMore(false);
|
|
97
|
+
fetchQueryResponse(buildQueryUrl(), { signal: controller.signal })
|
|
67
98
|
.then((data) => {
|
|
68
|
-
if (active)
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
if (!active) return;
|
|
100
|
+
const next = data.items as TimelineItem[];
|
|
101
|
+
setItems(next);
|
|
102
|
+
setHasMore(next.length >= PAGE_SIZE);
|
|
71
103
|
})
|
|
72
104
|
.catch((fetchError: unknown) => {
|
|
73
105
|
if (
|
|
@@ -81,6 +113,7 @@ export function useTimelineRouteData({
|
|
|
81
113
|
fetchError instanceof Error ? fetchError.message : errorFallback,
|
|
82
114
|
);
|
|
83
115
|
setItems([]);
|
|
116
|
+
setHasMore(false);
|
|
84
117
|
})
|
|
85
118
|
.finally(() => {
|
|
86
119
|
if (active) {
|
|
@@ -100,8 +133,47 @@ export function useTimelineRouteData({
|
|
|
100
133
|
replyFilter,
|
|
101
134
|
resource,
|
|
102
135
|
search,
|
|
136
|
+
selectedAccountId,
|
|
103
137
|
]);
|
|
104
138
|
|
|
139
|
+
async function loadMore() {
|
|
140
|
+
if (loading || loadingMore || !hasMore || items.length === 0) return;
|
|
141
|
+
const lastItem = items[items.length - 1];
|
|
142
|
+
const until = lastItem?.createdAt;
|
|
143
|
+
const untilId = lastItem?.id;
|
|
144
|
+
if (!until || !untilId) return;
|
|
145
|
+
const generation = generationRef.current;
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
loadMoreControllerRef.current = controller;
|
|
148
|
+
setLoadingMore(true);
|
|
149
|
+
try {
|
|
150
|
+
const data = await fetchQueryResponse(buildQueryUrl(until, untilId), {
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
});
|
|
153
|
+
// Discard if the filters changed (new generation) while in flight.
|
|
154
|
+
if (generation !== generationRef.current) return;
|
|
155
|
+
const page = data.items as TimelineItem[];
|
|
156
|
+
setItems((prev) => {
|
|
157
|
+
const seen = new Set(prev.map((item) => item.id));
|
|
158
|
+
return [...prev, ...page.filter((item) => !seen.has(item.id))];
|
|
159
|
+
});
|
|
160
|
+
setHasMore(page.length >= PAGE_SIZE);
|
|
161
|
+
} catch (loadError) {
|
|
162
|
+
if (
|
|
163
|
+
loadError instanceof DOMException &&
|
|
164
|
+
loadError.name === "AbortError"
|
|
165
|
+
) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (generation !== generationRef.current) return;
|
|
169
|
+
setError(loadError instanceof Error ? loadError.message : errorFallback);
|
|
170
|
+
} finally {
|
|
171
|
+
if (generation === generationRef.current) {
|
|
172
|
+
setLoadingMore(false);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
105
177
|
function retry() {
|
|
106
178
|
setRefreshTick((value) => value + 1);
|
|
107
179
|
}
|
|
@@ -115,14 +187,21 @@ export function useTimelineRouteData({
|
|
|
115
187
|
const text = window.prompt("Reply text");
|
|
116
188
|
if (!text?.trim()) return;
|
|
117
189
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
190
|
+
setReplyError(null);
|
|
191
|
+
try {
|
|
192
|
+
await postAction({
|
|
193
|
+
kind: "replyTweet",
|
|
194
|
+
accountId: selectedAccountId ?? "acct_primary",
|
|
195
|
+
tweetId,
|
|
196
|
+
text,
|
|
197
|
+
});
|
|
124
198
|
|
|
125
|
-
|
|
199
|
+
retry();
|
|
200
|
+
} catch (replyError) {
|
|
201
|
+
setReplyError(
|
|
202
|
+
replyError instanceof Error ? replyError.message : "Reply failed",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
126
205
|
}
|
|
127
206
|
|
|
128
207
|
return {
|
|
@@ -130,8 +209,13 @@ export function useTimelineRouteData({
|
|
|
130
209
|
items,
|
|
131
210
|
loading,
|
|
132
211
|
error,
|
|
212
|
+
replyError,
|
|
133
213
|
retry,
|
|
134
214
|
refreshLocalView,
|
|
135
215
|
replyToTweet,
|
|
216
|
+
selectedAccountId,
|
|
217
|
+
hasMore,
|
|
218
|
+
loadingMore,
|
|
219
|
+
loadMore,
|
|
136
220
|
};
|
|
137
221
|
}
|