birdclaw 0.8.1 → 0.8.3
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 +25 -0
- package/package.json +2 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -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/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +7 -715
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +41 -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/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +142 -170
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -100
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +77 -182
- package/src/lib/backup.ts +335 -424
- package/src/lib/bird.ts +32 -1
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +141 -334
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -176
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -225
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +37 -111
- package/src/lib/queries.ts +6 -2380
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +53 -14
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -256
- package/src/lib/timeline-live.ts +86 -235
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -166
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +11 -21
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -2
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +168 -167
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -329
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/vite.config.ts +0 -2
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type * as GeoJSON from "geojson";
|
|
2
|
+
import Supercluster from "supercluster";
|
|
3
|
+
import type { NetworkMapKind, NetworkMapResponse } from "#/lib/network-map";
|
|
4
|
+
|
|
5
|
+
export type ReactMapboxModule = typeof import("react-map-gl/mapbox");
|
|
6
|
+
export type MapFeature = NetworkMapResponse["features"][number];
|
|
7
|
+
export type MapBounds = [number, number, number, number];
|
|
8
|
+
|
|
9
|
+
export interface ClusterPointProperties {
|
|
10
|
+
featureIndex: number;
|
|
11
|
+
handle: string;
|
|
12
|
+
name: string;
|
|
13
|
+
avatarUrl: string | null;
|
|
14
|
+
relationship: MapFeature["properties"]["relationship"];
|
|
15
|
+
followersCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ClusterAggregateProperties {
|
|
19
|
+
followers: number;
|
|
20
|
+
following: number;
|
|
21
|
+
mutual: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ClusterFeatureProperties extends ClusterAggregateProperties {
|
|
25
|
+
cluster: true;
|
|
26
|
+
cluster_id: number;
|
|
27
|
+
point_count: number;
|
|
28
|
+
point_count_abbreviated: string | number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ClusterPointFeature = GeoJSON.Feature<
|
|
32
|
+
GeoJSON.Point,
|
|
33
|
+
ClusterPointProperties
|
|
34
|
+
>;
|
|
35
|
+
export type ClusterFeature = GeoJSON.Feature<
|
|
36
|
+
GeoJSON.Point,
|
|
37
|
+
ClusterFeatureProperties
|
|
38
|
+
>;
|
|
39
|
+
export type ClusterResult = ClusterPointFeature | ClusterFeature;
|
|
40
|
+
export type MapViewport = { bounds: MapBounds; zoom: number };
|
|
41
|
+
export type MapTarget = {
|
|
42
|
+
getBounds: () => {
|
|
43
|
+
getWest: () => number;
|
|
44
|
+
getSouth: () => number;
|
|
45
|
+
getEast: () => number;
|
|
46
|
+
getNorth: () => number;
|
|
47
|
+
};
|
|
48
|
+
getZoom: () => number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type SelectedOverlay =
|
|
52
|
+
| { kind: "profile"; feature: MapFeature }
|
|
53
|
+
| {
|
|
54
|
+
kind: "cluster";
|
|
55
|
+
coordinates: [number, number];
|
|
56
|
+
count: number;
|
|
57
|
+
features: MapFeature[];
|
|
58
|
+
stats: ClusterAggregateProperties;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const CLUSTER_LEAF_SAMPLE_SIZE = 48;
|
|
62
|
+
|
|
63
|
+
export const MAP_TYPES: Array<{ value: NetworkMapKind; label: string }> = [
|
|
64
|
+
{ value: "all", label: "All" },
|
|
65
|
+
{ value: "followers", label: "Followers" },
|
|
66
|
+
{ value: "following", label: "Following" },
|
|
67
|
+
{ value: "mutual", label: "Mutual" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export const WORLD_BOUNDS: MapBounds = [-180, -85, 180, 85];
|
|
71
|
+
export const WORLD_VIEWPORT: MapViewport = { bounds: WORLD_BOUNDS, zoom: 1.15 };
|
|
72
|
+
|
|
73
|
+
export async function fetchMap(
|
|
74
|
+
type: NetworkMapKind,
|
|
75
|
+
refresh: boolean,
|
|
76
|
+
accountId?: string,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
) {
|
|
79
|
+
const url = new URL("/api/network-map", window.location.origin);
|
|
80
|
+
url.searchParams.set("type", type);
|
|
81
|
+
url.searchParams.set("limit", "50000");
|
|
82
|
+
url.searchParams.set("geocodeLimit", refresh ? "80" : "12");
|
|
83
|
+
if (accountId) url.searchParams.set("account", accountId);
|
|
84
|
+
if (refresh) url.searchParams.set("refresh", "true");
|
|
85
|
+
const response = await fetch(url, { signal });
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(`Map request failed (${String(response.status)})`);
|
|
88
|
+
}
|
|
89
|
+
return (await response.json()) as NetworkMapResponse;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatNumber(value: number) {
|
|
93
|
+
return new Intl.NumberFormat().format(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatRelationship(
|
|
97
|
+
value: MapFeature["properties"]["relationship"],
|
|
98
|
+
) {
|
|
99
|
+
if (value === "mutual") return "mutual";
|
|
100
|
+
if (value === "following") return "following";
|
|
101
|
+
return "follower";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function relationshipColor(
|
|
105
|
+
relationship: MapFeature["properties"]["relationship"],
|
|
106
|
+
) {
|
|
107
|
+
if (relationship === "mutual") return "#22c55e";
|
|
108
|
+
if (relationship === "following") return "#f59e0b";
|
|
109
|
+
return "#1d9bf0";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function avatarInitial(feature: MapFeature) {
|
|
113
|
+
return (feature.properties.name || feature.properties.handle || "?")
|
|
114
|
+
.slice(0, 1)
|
|
115
|
+
.toUpperCase();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function avatarPath(feature: MapFeature) {
|
|
119
|
+
if (!feature.properties.avatarUrl) return null;
|
|
120
|
+
const query = new URLSearchParams({
|
|
121
|
+
profileId: feature.properties.profileId,
|
|
122
|
+
v: feature.properties.avatarUrl,
|
|
123
|
+
});
|
|
124
|
+
return `/api/avatar?${query.toString()}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function clusterGradient(stats: ClusterAggregateProperties) {
|
|
128
|
+
const total = Math.max(1, stats.followers + stats.following + stats.mutual);
|
|
129
|
+
const mutual = (stats.mutual / total) * 100;
|
|
130
|
+
const following = mutual + (stats.following / total) * 100;
|
|
131
|
+
return `conic-gradient(#22c55e 0 ${mutual}%, #f59e0b ${mutual}% ${following}%, #1d9bf0 ${following}% 100%)`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function buildClusterIndex(features: MapFeature[]) {
|
|
135
|
+
const points: ClusterPointFeature[] = features.map(
|
|
136
|
+
(feature, featureIndex) => ({
|
|
137
|
+
type: "Feature",
|
|
138
|
+
geometry: feature.geometry,
|
|
139
|
+
properties: {
|
|
140
|
+
featureIndex,
|
|
141
|
+
handle: feature.properties.handle,
|
|
142
|
+
name: feature.properties.name,
|
|
143
|
+
avatarUrl: feature.properties.avatarUrl,
|
|
144
|
+
relationship: feature.properties.relationship,
|
|
145
|
+
followersCount: feature.properties.followersCount,
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
return new Supercluster<ClusterPointProperties, ClusterAggregateProperties>({
|
|
150
|
+
maxZoom: 18,
|
|
151
|
+
minPoints: 2,
|
|
152
|
+
radius: 64,
|
|
153
|
+
map: (props) => ({
|
|
154
|
+
followers: props.relationship === "followers" ? 1 : 0,
|
|
155
|
+
following: props.relationship === "following" ? 1 : 0,
|
|
156
|
+
mutual: props.relationship === "mutual" ? 1 : 0,
|
|
157
|
+
}),
|
|
158
|
+
reduce: (accumulated, props) => {
|
|
159
|
+
accumulated.followers += props.followers;
|
|
160
|
+
accumulated.following += props.following;
|
|
161
|
+
accumulated.mutual += props.mutual;
|
|
162
|
+
},
|
|
163
|
+
}).load(points);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isCluster(item: ClusterResult): item is ClusterFeature {
|
|
167
|
+
return "cluster" in item.properties && item.properties.cluster === true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function compareClusterFeatures(a: MapFeature, b: MapFeature) {
|
|
171
|
+
return (
|
|
172
|
+
b.properties.followersCount - a.properties.followersCount ||
|
|
173
|
+
a.properties.handle.localeCompare(b.properties.handle)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getClusterDisplayAnchor(
|
|
178
|
+
features: MapFeature[],
|
|
179
|
+
fallback: [number, number],
|
|
180
|
+
): [number, number] {
|
|
181
|
+
const buckets = new Map<
|
|
182
|
+
string,
|
|
183
|
+
{ coordinates: [number, number]; count: number; followers: number }
|
|
184
|
+
>();
|
|
185
|
+
for (const feature of features) {
|
|
186
|
+
const [lng, lat] = feature.geometry.coordinates;
|
|
187
|
+
const key = `${lng.toFixed(4)},${lat.toFixed(4)}`;
|
|
188
|
+
const existing = buckets.get(key);
|
|
189
|
+
if (existing) {
|
|
190
|
+
existing.count += 1;
|
|
191
|
+
existing.followers += feature.properties.followersCount;
|
|
192
|
+
} else {
|
|
193
|
+
buckets.set(key, {
|
|
194
|
+
coordinates: [lng, lat],
|
|
195
|
+
count: 1,
|
|
196
|
+
followers: feature.properties.followersCount,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const best = [...buckets.values()].sort(
|
|
201
|
+
(a, b) => b.count - a.count || b.followers - a.followers,
|
|
202
|
+
)[0];
|
|
203
|
+
return best?.coordinates ?? fallback;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function readViewport(target: unknown): MapViewport | null {
|
|
207
|
+
if (!target || typeof target !== "object") return null;
|
|
208
|
+
const map = target as Partial<MapTarget>;
|
|
209
|
+
if (
|
|
210
|
+
typeof map.getBounds !== "function" ||
|
|
211
|
+
typeof map.getZoom !== "function"
|
|
212
|
+
) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const bounds = map.getBounds();
|
|
216
|
+
return {
|
|
217
|
+
bounds: [
|
|
218
|
+
bounds.getWest(),
|
|
219
|
+
bounds.getSouth(),
|
|
220
|
+
bounds.getEast(),
|
|
221
|
+
bounds.getNorth(),
|
|
222
|
+
],
|
|
223
|
+
zoom: map.getZoom(),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function boundsContainFeature(bounds: MapBounds, feature: MapFeature) {
|
|
228
|
+
const [west, south, east, north] = bounds;
|
|
229
|
+
const [lng, lat] = feature.geometry.coordinates;
|
|
230
|
+
const normalizedWest = ((west + 540) % 360) - 180;
|
|
231
|
+
const normalizedEast = ((east + 540) % 360) - 180;
|
|
232
|
+
const inLatitude = lat >= Math.max(-85, south) && lat <= Math.min(85, north);
|
|
233
|
+
const inLongitude =
|
|
234
|
+
east - west >= 360
|
|
235
|
+
? true
|
|
236
|
+
: normalizedWest <= normalizedEast
|
|
237
|
+
? lng >= normalizedWest && lng <= normalizedEast
|
|
238
|
+
: lng >= normalizedWest || lng <= normalizedEast;
|
|
239
|
+
return inLatitude && inLongitude;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function featureMatchesSearch(feature: MapFeature, search: string) {
|
|
243
|
+
const needle = search.trim().toLowerCase();
|
|
244
|
+
if (!needle) return true;
|
|
245
|
+
return [
|
|
246
|
+
feature.properties.name,
|
|
247
|
+
feature.properties.handle,
|
|
248
|
+
feature.properties.location,
|
|
249
|
+
feature.properties.resolvedLocation ?? "",
|
|
250
|
+
formatRelationship(feature.properties.relationship),
|
|
251
|
+
]
|
|
252
|
+
.join(" ")
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.includes(needle);
|
|
255
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from "
|
|
1
|
+
import {
|
|
2
|
+
useInfiniteQuery,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQuery,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
8
|
import {
|
|
9
9
|
fetchQueryEnvelope,
|
|
10
10
|
fetchQueryResponse,
|
|
11
11
|
postAction,
|
|
12
12
|
} from "#/lib/api-client";
|
|
13
|
+
import { queryKeys } from "#/lib/query-client";
|
|
14
|
+
import type { ReplyFilter, ResourceKind, TimelineItem } from "#/lib/types";
|
|
13
15
|
import { useSelectedAccountId } from "./account-selection";
|
|
16
|
+
import { useDebouncedValue } from "./useDebouncedValue";
|
|
14
17
|
|
|
15
18
|
const PAGE_SIZE = 50;
|
|
19
|
+
const TIMELINE_STALE_TIME_MS = 5 * 60_000;
|
|
16
20
|
|
|
17
21
|
interface UseTimelineRouteDataOptions {
|
|
18
22
|
resource: Exclude<ResourceKind, "dms">;
|
|
@@ -23,6 +27,49 @@ interface UseTimelineRouteDataOptions {
|
|
|
23
27
|
bookmarkedOnly?: boolean;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
interface TimelinePageParam {
|
|
31
|
+
until: string;
|
|
32
|
+
untilId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildTimelineQueryUrl({
|
|
36
|
+
resource,
|
|
37
|
+
search,
|
|
38
|
+
replyFilter,
|
|
39
|
+
likedOnly,
|
|
40
|
+
bookmarkedOnly,
|
|
41
|
+
selectedAccountId,
|
|
42
|
+
pageParam,
|
|
43
|
+
}: {
|
|
44
|
+
resource: Exclude<ResourceKind, "dms">;
|
|
45
|
+
search: string;
|
|
46
|
+
replyFilter?: ReplyFilter;
|
|
47
|
+
likedOnly: boolean;
|
|
48
|
+
bookmarkedOnly: boolean;
|
|
49
|
+
selectedAccountId?: string;
|
|
50
|
+
pageParam?: TimelinePageParam;
|
|
51
|
+
}) {
|
|
52
|
+
const params = new URLSearchParams({
|
|
53
|
+
resource,
|
|
54
|
+
limit: String(PAGE_SIZE),
|
|
55
|
+
});
|
|
56
|
+
if (selectedAccountId) params.set("account", selectedAccountId);
|
|
57
|
+
if (replyFilter) params.set("replyFilter", replyFilter);
|
|
58
|
+
if (likedOnly) params.set("liked", "true");
|
|
59
|
+
if (bookmarkedOnly) params.set("bookmarked", "true");
|
|
60
|
+
if (search.trim()) params.set("search", search.trim());
|
|
61
|
+
if (pageParam) {
|
|
62
|
+
params.set("until", pageParam.until);
|
|
63
|
+
params.set("untilId", pageParam.untilId);
|
|
64
|
+
}
|
|
65
|
+
params.sort();
|
|
66
|
+
const base =
|
|
67
|
+
typeof window === "undefined"
|
|
68
|
+
? "http://birdclaw.local"
|
|
69
|
+
: window.location.origin;
|
|
70
|
+
return new URL(`/api/query?${params.toString()}`, base).toString();
|
|
71
|
+
}
|
|
72
|
+
|
|
26
73
|
export function useTimelineRouteData({
|
|
27
74
|
resource,
|
|
28
75
|
search,
|
|
@@ -31,191 +78,116 @@ export function useTimelineRouteData({
|
|
|
31
78
|
likedOnly = false,
|
|
32
79
|
bookmarkedOnly = false,
|
|
33
80
|
}: UseTimelineRouteDataOptions) {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const [hasMore, setHasMore] = useState(false);
|
|
41
|
-
const [loadingMore, setLoadingMore] = useState(false);
|
|
81
|
+
const queryClient = useQueryClient();
|
|
82
|
+
const statusQuery = useQuery({
|
|
83
|
+
queryKey: queryKeys.status,
|
|
84
|
+
queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
|
|
85
|
+
});
|
|
86
|
+
const meta = statusQuery.data ?? null;
|
|
42
87
|
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
generationRef.current += 1;
|
|
91
|
-
loadMoreControllerRef.current?.abort();
|
|
92
|
-
const controller = new AbortController();
|
|
93
|
-
let active = true;
|
|
94
|
-
setError(null);
|
|
95
|
-
setLoading(true);
|
|
96
|
-
setLoadingMore(false);
|
|
97
|
-
fetchQueryResponse(buildQueryUrl(), { signal: controller.signal })
|
|
98
|
-
.then((data) => {
|
|
99
|
-
if (!active) return;
|
|
100
|
-
const next = data.items as TimelineItem[];
|
|
101
|
-
setItems(next);
|
|
102
|
-
setHasMore(next.length >= PAGE_SIZE);
|
|
103
|
-
})
|
|
104
|
-
.catch((fetchError: unknown) => {
|
|
105
|
-
if (
|
|
106
|
-
fetchError instanceof DOMException &&
|
|
107
|
-
fetchError.name === "AbortError"
|
|
108
|
-
) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (!active) return;
|
|
112
|
-
setError(
|
|
113
|
-
fetchError instanceof Error ? fetchError.message : errorFallback,
|
|
114
|
-
);
|
|
115
|
-
setItems([]);
|
|
116
|
-
setHasMore(false);
|
|
117
|
-
})
|
|
118
|
-
.finally(() => {
|
|
119
|
-
if (active) {
|
|
120
|
-
setLoading(false);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
return () => {
|
|
125
|
-
active = false;
|
|
126
|
-
controller.abort();
|
|
127
|
-
};
|
|
128
|
-
}, [
|
|
129
|
-
bookmarkedOnly,
|
|
130
|
-
errorFallback,
|
|
131
|
-
likedOnly,
|
|
132
|
-
refreshTick,
|
|
133
|
-
replyFilter,
|
|
134
|
-
resource,
|
|
135
|
-
search,
|
|
136
|
-
selectedAccountId,
|
|
137
|
-
]);
|
|
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);
|
|
88
|
+
const debouncedSearch = useDebouncedValue(search, 180);
|
|
89
|
+
const timelineQueryKey = [
|
|
90
|
+
...queryKeys.timelines,
|
|
91
|
+
{
|
|
92
|
+
resource,
|
|
93
|
+
search: debouncedSearch,
|
|
94
|
+
replyFilter: replyFilter ?? "all",
|
|
95
|
+
likedOnly,
|
|
96
|
+
bookmarkedOnly,
|
|
97
|
+
selectedAccountId: selectedAccountId ?? null,
|
|
98
|
+
},
|
|
99
|
+
] as const;
|
|
100
|
+
const timelineQuery = useInfiniteQuery({
|
|
101
|
+
queryKey: timelineQueryKey,
|
|
102
|
+
initialPageParam: undefined as TimelinePageParam | undefined,
|
|
103
|
+
queryFn: ({ pageParam, signal }) =>
|
|
104
|
+
fetchQueryResponse(
|
|
105
|
+
buildTimelineQueryUrl({
|
|
106
|
+
resource,
|
|
107
|
+
search: debouncedSearch,
|
|
108
|
+
replyFilter,
|
|
109
|
+
likedOnly,
|
|
110
|
+
bookmarkedOnly,
|
|
111
|
+
selectedAccountId,
|
|
112
|
+
pageParam,
|
|
113
|
+
}),
|
|
114
|
+
{ signal },
|
|
115
|
+
),
|
|
116
|
+
getNextPageParam: (lastPage) => {
|
|
117
|
+
const items = lastPage.items as TimelineItem[];
|
|
118
|
+
const lastItem = items.at(-1);
|
|
119
|
+
return items.length >= PAGE_SIZE && lastItem
|
|
120
|
+
? { until: lastItem.createdAt, untilId: lastItem.id }
|
|
121
|
+
: undefined;
|
|
122
|
+
},
|
|
123
|
+
staleTime: TIMELINE_STALE_TIME_MS,
|
|
124
|
+
});
|
|
125
|
+
const items = useMemo(() => {
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const merged: TimelineItem[] = [];
|
|
128
|
+
for (const page of timelineQuery.data?.pages ?? []) {
|
|
129
|
+
for (const item of page.items as TimelineItem[]) {
|
|
130
|
+
if (seen.has(item.id)) continue;
|
|
131
|
+
seen.add(item.id);
|
|
132
|
+
merged.push(item);
|
|
173
133
|
}
|
|
174
134
|
}
|
|
175
|
-
|
|
135
|
+
return merged;
|
|
136
|
+
}, [timelineQuery.data]);
|
|
137
|
+
const replyMutation = useMutation({
|
|
138
|
+
mutationFn: ({ tweetId, text }: { tweetId: string; text: string }) =>
|
|
139
|
+
postAction({
|
|
140
|
+
kind: "replyTweet",
|
|
141
|
+
accountId: selectedAccountId ?? "acct_primary",
|
|
142
|
+
tweetId,
|
|
143
|
+
text,
|
|
144
|
+
}),
|
|
145
|
+
onSuccess: () =>
|
|
146
|
+
queryClient.invalidateQueries({ queryKey: timelineQueryKey }),
|
|
147
|
+
});
|
|
176
148
|
|
|
177
149
|
function retry() {
|
|
178
|
-
|
|
150
|
+
void timelineQuery.refetch();
|
|
179
151
|
}
|
|
180
152
|
|
|
181
153
|
function refreshLocalView() {
|
|
182
|
-
|
|
183
|
-
|
|
154
|
+
void Promise.all([
|
|
155
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.timelines }),
|
|
156
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
|
157
|
+
]);
|
|
184
158
|
}
|
|
185
159
|
|
|
186
160
|
async function replyToTweet(tweetId: string) {
|
|
187
161
|
const text = window.prompt("Reply text");
|
|
188
162
|
if (!text?.trim()) return;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
kind: "replyTweet",
|
|
194
|
-
accountId: selectedAccountId ?? "acct_primary",
|
|
195
|
-
tweetId,
|
|
196
|
-
text,
|
|
163
|
+
await replyMutation
|
|
164
|
+
.mutateAsync({ tweetId, text: text.trim() })
|
|
165
|
+
.catch(() => {
|
|
166
|
+
// The mutation error is exposed below for the route frame.
|
|
197
167
|
});
|
|
198
|
-
|
|
199
|
-
retry();
|
|
200
|
-
} catch (replyError) {
|
|
201
|
-
setReplyError(
|
|
202
|
-
replyError instanceof Error ? replyError.message : "Reply failed",
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
168
|
}
|
|
206
169
|
|
|
170
|
+
const queryError = timelineQuery.error;
|
|
207
171
|
return {
|
|
208
172
|
meta,
|
|
209
173
|
items,
|
|
210
|
-
loading,
|
|
211
|
-
error
|
|
212
|
-
|
|
174
|
+
loading: timelineQuery.isPending,
|
|
175
|
+
error: queryError
|
|
176
|
+
? queryError instanceof Error
|
|
177
|
+
? queryError.message
|
|
178
|
+
: errorFallback
|
|
179
|
+
: null,
|
|
180
|
+
replyError: replyMutation.error
|
|
181
|
+
? replyMutation.error instanceof Error
|
|
182
|
+
? replyMutation.error.message
|
|
183
|
+
: "Reply failed"
|
|
184
|
+
: null,
|
|
213
185
|
retry,
|
|
214
186
|
refreshLocalView,
|
|
215
187
|
replyToTweet,
|
|
216
188
|
selectedAccountId,
|
|
217
|
-
hasMore,
|
|
218
|
-
loadingMore,
|
|
219
|
-
loadMore,
|
|
189
|
+
hasMore: timelineQuery.hasNextPage,
|
|
190
|
+
loadingMore: timelineQuery.isFetchingNextPage,
|
|
191
|
+
loadMore: () => timelineQuery.fetchNextPage().then(() => undefined),
|
|
220
192
|
};
|
|
221
193
|
}
|