birdclaw 0.5.0 → 0.5.1
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 +20 -0
- package/README.md +5 -0
- package/bin/birdclaw.mjs +50 -11
- package/package.json +7 -6
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +46 -1
- package/src/components/AppNav.tsx +3 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +24 -9
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +157 -61
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +13 -2
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +18 -3
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +2 -0
- package/src/lib/queries.ts +93 -28
- package/src/lib/ui.ts +11 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/dms.tsx +100 -27
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
QueryEnvelope,
|
|
4
|
+
ReplyFilter,
|
|
5
|
+
ResourceKind,
|
|
6
|
+
TimelineItem,
|
|
7
|
+
} from "#/lib/types";
|
|
8
|
+
import {
|
|
9
|
+
fetchQueryEnvelope,
|
|
10
|
+
fetchQueryResponse,
|
|
11
|
+
postAction,
|
|
12
|
+
} from "#/lib/api-client";
|
|
13
|
+
|
|
14
|
+
interface UseTimelineRouteDataOptions {
|
|
15
|
+
resource: Exclude<ResourceKind, "dms">;
|
|
16
|
+
search: string;
|
|
17
|
+
errorFallback: string;
|
|
18
|
+
replyFilter?: ReplyFilter;
|
|
19
|
+
likedOnly?: boolean;
|
|
20
|
+
bookmarkedOnly?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTimelineRouteData({
|
|
24
|
+
resource,
|
|
25
|
+
search,
|
|
26
|
+
errorFallback,
|
|
27
|
+
replyFilter,
|
|
28
|
+
likedOnly = false,
|
|
29
|
+
bookmarkedOnly = false,
|
|
30
|
+
}: UseTimelineRouteDataOptions) {
|
|
31
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
32
|
+
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
33
|
+
const [loading, setLoading] = useState(true);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
36
|
+
|
|
37
|
+
async function loadStatus() {
|
|
38
|
+
setMeta(await fetchQueryEnvelope());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
void loadStatus();
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const url = new URL("/api/query", window.location.origin);
|
|
47
|
+
url.searchParams.set("resource", resource);
|
|
48
|
+
url.searchParams.set("refresh", String(refreshTick));
|
|
49
|
+
if (replyFilter) {
|
|
50
|
+
url.searchParams.set("replyFilter", replyFilter);
|
|
51
|
+
}
|
|
52
|
+
if (likedOnly) {
|
|
53
|
+
url.searchParams.set("liked", "true");
|
|
54
|
+
}
|
|
55
|
+
if (bookmarkedOnly) {
|
|
56
|
+
url.searchParams.set("bookmarked", "true");
|
|
57
|
+
}
|
|
58
|
+
if (search.trim()) {
|
|
59
|
+
url.searchParams.set("search", search.trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
let active = true;
|
|
64
|
+
setError(null);
|
|
65
|
+
setLoading(true);
|
|
66
|
+
fetchQueryResponse(url, { signal: controller.signal })
|
|
67
|
+
.then((data) => {
|
|
68
|
+
if (active) {
|
|
69
|
+
setItems(data.items as TimelineItem[]);
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.catch((fetchError: unknown) => {
|
|
73
|
+
if (
|
|
74
|
+
fetchError instanceof DOMException &&
|
|
75
|
+
fetchError.name === "AbortError"
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!active) return;
|
|
80
|
+
setError(
|
|
81
|
+
fetchError instanceof Error ? fetchError.message : errorFallback,
|
|
82
|
+
);
|
|
83
|
+
setItems([]);
|
|
84
|
+
})
|
|
85
|
+
.finally(() => {
|
|
86
|
+
if (active) {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
active = false;
|
|
93
|
+
controller.abort();
|
|
94
|
+
};
|
|
95
|
+
}, [
|
|
96
|
+
bookmarkedOnly,
|
|
97
|
+
errorFallback,
|
|
98
|
+
likedOnly,
|
|
99
|
+
refreshTick,
|
|
100
|
+
replyFilter,
|
|
101
|
+
resource,
|
|
102
|
+
search,
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function retry() {
|
|
106
|
+
setRefreshTick((value) => value + 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function refreshLocalView() {
|
|
110
|
+
setRefreshTick((value) => value + 1);
|
|
111
|
+
void loadStatus();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function replyToTweet(tweetId: string) {
|
|
115
|
+
const text = window.prompt("Reply text");
|
|
116
|
+
if (!text?.trim()) return;
|
|
117
|
+
|
|
118
|
+
await postAction({
|
|
119
|
+
kind: "replyTweet",
|
|
120
|
+
accountId: "acct_primary",
|
|
121
|
+
tweetId,
|
|
122
|
+
text,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
retry();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
meta,
|
|
130
|
+
items,
|
|
131
|
+
loading,
|
|
132
|
+
error,
|
|
133
|
+
retry,
|
|
134
|
+
refreshLocalView,
|
|
135
|
+
replyToTweet,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
DmConversationItem,
|
|
4
|
+
DmMessageItem,
|
|
5
|
+
QueryEnvelope,
|
|
6
|
+
QueryResponse,
|
|
7
|
+
TimelineItem,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import type {
|
|
10
|
+
WebSyncJobSnapshot,
|
|
11
|
+
WebSyncKind,
|
|
12
|
+
WebSyncResponse,
|
|
13
|
+
} from "./web-sync";
|
|
14
|
+
|
|
15
|
+
const jsonRecordSchema = z.object({}).passthrough();
|
|
16
|
+
const resourceKindSchema = z.enum(["home", "mentions", "authored", "dms"]);
|
|
17
|
+
const webSyncKindSchema = z.enum([
|
|
18
|
+
"timeline",
|
|
19
|
+
"mentions",
|
|
20
|
+
"likes",
|
|
21
|
+
"bookmarks",
|
|
22
|
+
"dms",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const queryEnvelopeSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
accounts: z.array(jsonRecordSchema),
|
|
28
|
+
archives: z.array(jsonRecordSchema),
|
|
29
|
+
transport: z
|
|
30
|
+
.object({
|
|
31
|
+
statusText: z.string(),
|
|
32
|
+
})
|
|
33
|
+
.passthrough(),
|
|
34
|
+
stats: z.object({
|
|
35
|
+
home: z.number(),
|
|
36
|
+
mentions: z.number(),
|
|
37
|
+
dms: z.number(),
|
|
38
|
+
needsReply: z.number(),
|
|
39
|
+
inbox: z.number(),
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
.transform((value) => value as unknown as QueryEnvelope);
|
|
43
|
+
|
|
44
|
+
const queryResponseSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
resource: resourceKindSchema,
|
|
47
|
+
items: z.array(jsonRecordSchema),
|
|
48
|
+
selectedConversation: z
|
|
49
|
+
.object({
|
|
50
|
+
conversation: jsonRecordSchema,
|
|
51
|
+
messages: z.array(jsonRecordSchema),
|
|
52
|
+
})
|
|
53
|
+
.nullish(),
|
|
54
|
+
})
|
|
55
|
+
.transform(
|
|
56
|
+
(value) =>
|
|
57
|
+
({
|
|
58
|
+
...value,
|
|
59
|
+
items: value.items as unknown as Array<
|
|
60
|
+
TimelineItem | DmConversationItem
|
|
61
|
+
>,
|
|
62
|
+
selectedConversation: value.selectedConversation
|
|
63
|
+
? {
|
|
64
|
+
conversation: value.selectedConversation
|
|
65
|
+
.conversation as unknown as DmConversationItem,
|
|
66
|
+
messages: value.selectedConversation
|
|
67
|
+
.messages as unknown as DmMessageItem[],
|
|
68
|
+
}
|
|
69
|
+
: value.selectedConversation,
|
|
70
|
+
}) as QueryResponse,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const webSyncResponseSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
ok: z.boolean(),
|
|
76
|
+
kind: webSyncKindSchema,
|
|
77
|
+
accountId: z.string().optional(),
|
|
78
|
+
summary: z.string(),
|
|
79
|
+
steps: z.array(jsonRecordSchema),
|
|
80
|
+
startedAt: z.string().optional(),
|
|
81
|
+
finishedAt: z.string().optional(),
|
|
82
|
+
inProgress: z.boolean().optional(),
|
|
83
|
+
backup: z.unknown().optional(),
|
|
84
|
+
error: z.string().optional(),
|
|
85
|
+
})
|
|
86
|
+
.transform((value) => value as unknown as WebSyncResponse);
|
|
87
|
+
|
|
88
|
+
const webSyncJobSchema = z
|
|
89
|
+
.object({
|
|
90
|
+
id: z.string(),
|
|
91
|
+
kind: webSyncKindSchema,
|
|
92
|
+
accountId: z.string().optional(),
|
|
93
|
+
status: z.enum(["running", "succeeded", "failed"]),
|
|
94
|
+
startedAt: z.string(),
|
|
95
|
+
finishedAt: z.string().optional(),
|
|
96
|
+
summary: z.string(),
|
|
97
|
+
inProgress: z.boolean(),
|
|
98
|
+
result: webSyncResponseSchema.optional(),
|
|
99
|
+
error: z.string().optional(),
|
|
100
|
+
})
|
|
101
|
+
.transform((value) => value as unknown as WebSyncJobSnapshot);
|
|
102
|
+
|
|
103
|
+
const actionResponseSchema = jsonRecordSchema;
|
|
104
|
+
const SYNC_POLL_INTERVAL_MS = 500;
|
|
105
|
+
|
|
106
|
+
export class ApiFetchError extends Error {
|
|
107
|
+
constructor(
|
|
108
|
+
message: string,
|
|
109
|
+
readonly status?: number,
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = "ApiFetchError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function responseMessage(data: unknown, fallback: string) {
|
|
117
|
+
if (data && typeof data === "object") {
|
|
118
|
+
const record = data as {
|
|
119
|
+
message?: unknown;
|
|
120
|
+
error?: unknown;
|
|
121
|
+
summary?: unknown;
|
|
122
|
+
};
|
|
123
|
+
if (typeof record.message === "string") return record.message;
|
|
124
|
+
if (typeof record.error === "string") return record.error;
|
|
125
|
+
if (typeof record.summary === "string") return record.summary;
|
|
126
|
+
}
|
|
127
|
+
return fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readJson(response: Response) {
|
|
131
|
+
try {
|
|
132
|
+
return await response.json();
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function fetchJson<T>(
|
|
139
|
+
input: RequestInfo | URL,
|
|
140
|
+
init: RequestInit | undefined,
|
|
141
|
+
schema: z.ZodType<T>,
|
|
142
|
+
fallbackMessage: string,
|
|
143
|
+
): Promise<T> {
|
|
144
|
+
const response = await fetch(input, init);
|
|
145
|
+
const data = await readJson(response);
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new ApiFetchError(
|
|
148
|
+
responseMessage(data, fallbackMessage),
|
|
149
|
+
response.status,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = schema.safeParse(data);
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
throw new ApiFetchError(fallbackMessage);
|
|
156
|
+
}
|
|
157
|
+
return parsed.data;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function fetchQueryEnvelope(init?: RequestInit) {
|
|
161
|
+
return fetchJson(
|
|
162
|
+
"/api/status",
|
|
163
|
+
init,
|
|
164
|
+
queryEnvelopeSchema,
|
|
165
|
+
"Status unavailable",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function fetchQueryResponse(
|
|
170
|
+
input: RequestInfo | URL,
|
|
171
|
+
init?: RequestInit,
|
|
172
|
+
) {
|
|
173
|
+
return fetchJson(input, init, queryResponseSchema, "Query unavailable");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function postAction(body: Record<string, unknown>) {
|
|
177
|
+
return fetchJson(
|
|
178
|
+
"/api/action",
|
|
179
|
+
{
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/json" },
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
},
|
|
184
|
+
actionResponseSchema,
|
|
185
|
+
"Action failed",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function postSync(kind: WebSyncKind, accountId?: string) {
|
|
190
|
+
return fetchJson(
|
|
191
|
+
"/api/sync",
|
|
192
|
+
{
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "content-type": "application/json" },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
kind,
|
|
197
|
+
...(accountId ? { accountId } : {}),
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
webSyncJobSchema,
|
|
201
|
+
"Sync failed",
|
|
202
|
+
).then(waitForWebSyncJob);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function fetchSyncJob(id: string) {
|
|
206
|
+
const url = new URL("/api/sync", window.location.origin);
|
|
207
|
+
url.searchParams.set("id", id);
|
|
208
|
+
return fetchJson(url, undefined, webSyncJobSchema, "Sync status unavailable");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function delay(ms: number) {
|
|
212
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function waitForWebSyncJob(job: WebSyncJobSnapshot) {
|
|
216
|
+
let current = job;
|
|
217
|
+
while (current.inProgress) {
|
|
218
|
+
await delay(SYNC_POLL_INTERVAL_MS);
|
|
219
|
+
current = await fetchSyncJob(current.id);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!current.result) {
|
|
223
|
+
throw new ApiFetchError(current.error ?? current.summary);
|
|
224
|
+
}
|
|
225
|
+
if (!current.result.ok) {
|
|
226
|
+
throw new ApiFetchError(current.result.error ?? current.result.summary);
|
|
227
|
+
}
|
|
228
|
+
return current.result;
|
|
229
|
+
}
|
|
@@ -96,27 +96,31 @@ export async function findArchives(): Promise<ArchiveCandidate[]> {
|
|
|
96
96
|
'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
|
|
97
97
|
];
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
paths.map((filePath) => getCandidate(filePath))
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
99
|
+
const spotlightCandidates = await Promise.all(
|
|
100
|
+
queries.map(async (query) => {
|
|
101
|
+
try {
|
|
102
|
+
const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
|
|
103
|
+
timeout: 5000,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const paths = stdout
|
|
107
|
+
.split("\n")
|
|
108
|
+
.map((item) => item.trim())
|
|
109
|
+
.filter((item) => item.length > 0 && item.endsWith(".zip"));
|
|
110
|
+
|
|
111
|
+
return Promise.all(paths.map((filePath) => getCandidate(filePath)));
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort only.
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
for (const candidates of spotlightCandidates) {
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (candidate) {
|
|
122
|
+
found.set(candidate.path, candidate);
|
|
117
123
|
}
|
|
118
|
-
} catch {
|
|
119
|
-
// Best-effort only.
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
|
|
@@ -218,7 +218,12 @@ function toFiniteNumber(value: unknown) {
|
|
|
218
218
|
|
|
219
219
|
function extractTweetEntities(tweet: Record<string, unknown>) {
|
|
220
220
|
const entities = asRecord(tweet.entities);
|
|
221
|
-
const
|
|
221
|
+
const urlEntries = [
|
|
222
|
+
...asArray<Record<string, unknown>>(entities?.urls),
|
|
223
|
+
...asArray<Record<string, unknown>>(entities?.media),
|
|
224
|
+
];
|
|
225
|
+
const seenUrls = new Set<string>();
|
|
226
|
+
const urls = urlEntries
|
|
222
227
|
.map((entry) => ({
|
|
223
228
|
url: String(entry.url ?? ""),
|
|
224
229
|
expandedUrl: String(
|
|
@@ -243,7 +248,11 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
|
|
|
243
248
|
? entry.imageUrl
|
|
244
249
|
: typeof entry.thumbnail_url === "string"
|
|
245
250
|
? entry.thumbnail_url
|
|
246
|
-
:
|
|
251
|
+
: typeof entry.media_url_https === "string"
|
|
252
|
+
? entry.media_url_https
|
|
253
|
+
: typeof entry.media_url === "string"
|
|
254
|
+
? entry.media_url
|
|
255
|
+
: undefined,
|
|
247
256
|
siteName:
|
|
248
257
|
typeof entry.site_name === "string"
|
|
249
258
|
? entry.site_name
|
|
@@ -251,7 +260,13 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
|
|
|
251
260
|
? entry.siteName
|
|
252
261
|
: undefined,
|
|
253
262
|
}))
|
|
254
|
-
.filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0)
|
|
263
|
+
.filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0)
|
|
264
|
+
.filter((entry) => {
|
|
265
|
+
const key = `${entry.start}:${entry.end}:${entry.url}:${entry.expandedUrl}`;
|
|
266
|
+
if (seenUrls.has(key)) return false;
|
|
267
|
+
seenUrls.add(key);
|
|
268
|
+
return true;
|
|
269
|
+
});
|
|
255
270
|
const mentions = asArray<Record<string, unknown>>(entities?.user_mentions)
|
|
256
271
|
.map((entry) => ({
|
|
257
272
|
username: String(entry.screen_name ?? ""),
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { EmbeddedTweet } from "#/lib/types";
|
|
4
|
+
|
|
5
|
+
type ConversationStatus = "idle" | "loading" | "ready" | "error";
|
|
6
|
+
|
|
7
|
+
interface ConversationRecord {
|
|
8
|
+
error: string | null;
|
|
9
|
+
items: EmbeddedTweet[];
|
|
10
|
+
status: ConversationStatus;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ConversationSurfaceSnapshot {
|
|
14
|
+
expandedTweetId: string | null;
|
|
15
|
+
records: ReadonlyMap<string, ConversationRecord>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Listener = () => void;
|
|
19
|
+
|
|
20
|
+
const emptyRecord: ConversationRecord = {
|
|
21
|
+
error: null,
|
|
22
|
+
items: [],
|
|
23
|
+
status: "idle",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let snapshot: ConversationSurfaceSnapshot = {
|
|
27
|
+
expandedTweetId: null,
|
|
28
|
+
records: new Map(),
|
|
29
|
+
};
|
|
30
|
+
const listeners = new Set<Listener>();
|
|
31
|
+
const inFlight = new Set<string>();
|
|
32
|
+
let activeScopes = 0;
|
|
33
|
+
let generation = 0;
|
|
34
|
+
|
|
35
|
+
function emit() {
|
|
36
|
+
for (const listener of listeners) {
|
|
37
|
+
listener();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setSnapshot(next: ConversationSurfaceSnapshot) {
|
|
42
|
+
snapshot = next;
|
|
43
|
+
emit();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function updateRecord(tweetId: string, record: ConversationRecord) {
|
|
47
|
+
const records = new Map(snapshot.records);
|
|
48
|
+
records.set(tweetId, record);
|
|
49
|
+
setSnapshot({ ...snapshot, records });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function subscribe(listener: Listener) {
|
|
53
|
+
listeners.add(listener);
|
|
54
|
+
return () => {
|
|
55
|
+
listeners.delete(listener);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getSnapshot() {
|
|
60
|
+
return snapshot;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadConversation(tweetId: string) {
|
|
64
|
+
const current = snapshot.records.get(tweetId);
|
|
65
|
+
if (current?.status === "ready" || inFlight.has(tweetId)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const loadGeneration = generation;
|
|
70
|
+
inFlight.add(tweetId);
|
|
71
|
+
updateRecord(tweetId, {
|
|
72
|
+
error: null,
|
|
73
|
+
items: current?.items ?? [],
|
|
74
|
+
status: "loading",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(
|
|
79
|
+
`/api/conversation?tweetId=${encodeURIComponent(tweetId)}`,
|
|
80
|
+
);
|
|
81
|
+
const data = (await response.json()) as {
|
|
82
|
+
error?: string;
|
|
83
|
+
items?: EmbeddedTweet[];
|
|
84
|
+
ok?: boolean;
|
|
85
|
+
};
|
|
86
|
+
if (!response.ok || data.ok === false) {
|
|
87
|
+
throw new Error(data.error ?? "Conversation unavailable");
|
|
88
|
+
}
|
|
89
|
+
if (loadGeneration !== generation) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
updateRecord(tweetId, {
|
|
93
|
+
error: null,
|
|
94
|
+
items: (data.items ?? []).filter(Boolean),
|
|
95
|
+
status: "ready",
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (loadGeneration !== generation) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
updateRecord(tweetId, {
|
|
102
|
+
error:
|
|
103
|
+
error instanceof Error ? error.message : "Conversation unavailable",
|
|
104
|
+
items: [],
|
|
105
|
+
status: "error",
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
inFlight.delete(tweetId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function retainConversationSurfaceScope() {
|
|
113
|
+
activeScopes += 1;
|
|
114
|
+
return () => {
|
|
115
|
+
activeScopes = Math.max(0, activeScopes - 1);
|
|
116
|
+
if (activeScopes === 0) {
|
|
117
|
+
resetConversationSurface();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resetConversationSurface() {
|
|
123
|
+
generation += 1;
|
|
124
|
+
inFlight.clear();
|
|
125
|
+
setSnapshot({
|
|
126
|
+
expandedTweetId: null,
|
|
127
|
+
records: new Map(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ConversationSurfaceScope({
|
|
132
|
+
children,
|
|
133
|
+
}: {
|
|
134
|
+
children: ReactNode;
|
|
135
|
+
}) {
|
|
136
|
+
useEffect(() => retainConversationSurfaceScope(), []);
|
|
137
|
+
return children;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function useConversationSurface(tweetId: string) {
|
|
141
|
+
const currentSnapshot = useSyncExternalStore(
|
|
142
|
+
subscribe,
|
|
143
|
+
getSnapshot,
|
|
144
|
+
getSnapshot,
|
|
145
|
+
);
|
|
146
|
+
const record = currentSnapshot.records.get(tweetId) ?? emptyRecord;
|
|
147
|
+
const isOpen = currentSnapshot.expandedTweetId === tweetId;
|
|
148
|
+
|
|
149
|
+
const toggle = useCallback(() => {
|
|
150
|
+
const nextExpanded = snapshot.expandedTweetId === tweetId ? null : tweetId;
|
|
151
|
+
setSnapshot({ ...snapshot, expandedTweetId: nextExpanded });
|
|
152
|
+
if (nextExpanded) {
|
|
153
|
+
void loadConversation(tweetId);
|
|
154
|
+
}
|
|
155
|
+
}, [tweetId]);
|
|
156
|
+
|
|
157
|
+
const prefetch = useCallback(() => {
|
|
158
|
+
const current = snapshot.records.get(tweetId);
|
|
159
|
+
if (current?.status === "ready" || current?.status === "loading") {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
void loadConversation(tweetId);
|
|
163
|
+
}, [tweetId]);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
error: record.error,
|
|
167
|
+
isOpen,
|
|
168
|
+
items: record.items,
|
|
169
|
+
loading: record.status === "loading",
|
|
170
|
+
prefetch,
|
|
171
|
+
status: record.status,
|
|
172
|
+
toggle,
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/lib/db.ts
CHANGED
|
@@ -574,11 +574,13 @@ const BASE_SCHEMA_SQL = `
|
|
|
574
574
|
|
|
575
575
|
const INDEX_SQL = `
|
|
576
576
|
create index if not exists idx_tweets_kind_created on tweets(kind, created_at desc);
|
|
577
|
+
create index if not exists idx_tweets_created on tweets(created_at desc);
|
|
577
578
|
create index if not exists idx_tweets_account_created on tweets(account_id, created_at desc);
|
|
578
579
|
create index if not exists idx_tweets_quoted on tweets(quoted_tweet_id);
|
|
579
580
|
create index if not exists idx_tweet_collections_kind_account on tweet_collections(kind, account_id, collected_at desc, tweet_id);
|
|
580
581
|
create index if not exists idx_tweet_collections_tweet on tweet_collections(tweet_id);
|
|
581
582
|
create index if not exists idx_tweet_account_edges_kind_account on tweet_account_edges(kind, account_id, last_seen_at desc, tweet_id);
|
|
583
|
+
create index if not exists idx_tweet_account_edges_kind_tweet on tweet_account_edges(kind, tweet_id, account_id);
|
|
582
584
|
create index if not exists idx_tweet_account_edges_tweet on tweet_account_edges(tweet_id);
|
|
583
585
|
create index if not exists idx_dm_conversations_account on dm_conversations(account_id, last_message_at desc);
|
|
584
586
|
create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);
|