birdclaw 0.7.0 → 0.8.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 +29 -0
- package/README.md +7 -2
- package/package.json +25 -31
- package/scripts/build-docs-site.mjs +39 -12
- package/src/cli.ts +19 -0
- package/src/components/ConversationThread.tsx +5 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +5 -4
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +3 -2
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +6 -4
- package/src/lib/link-insights.ts +2 -0
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +280 -45
- package/src/lib/present.ts +99 -1
- package/src/lib/queries.ts +61 -0
- package/src/routes/api/period-digest.tsx +38 -70
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/data-sources.tsx +3 -1
- package/src/routes/links.tsx +4 -3
- package/src/routes/today.tsx +67 -11
package/src/lib/present.ts
CHANGED
|
@@ -2,13 +2,111 @@ export function formatCompactNumber(value: number) {
|
|
|
2
2
|
return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
const SECOND_MS = 1_000;
|
|
6
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
7
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
8
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
9
|
+
|
|
10
|
+
const timeFormatter = new Intl.DateTimeFormat("en", {
|
|
11
|
+
hour: "numeric",
|
|
12
|
+
minute: "2-digit",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const weekdayFormatter = new Intl.DateTimeFormat("en", {
|
|
16
|
+
weekday: "long",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const sameYearDateFormatter = new Intl.DateTimeFormat("en", {
|
|
20
|
+
month: "short",
|
|
21
|
+
day: "numeric",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const otherYearDateFormatter = new Intl.DateTimeFormat("en", {
|
|
25
|
+
year: "numeric",
|
|
26
|
+
month: "short",
|
|
27
|
+
day: "numeric",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const exactTimestampFormatter = new Intl.DateTimeFormat("en", {
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "long",
|
|
33
|
+
day: "numeric",
|
|
34
|
+
hour: "numeric",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
timeZoneName: "short",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function parseTimestamp(value: string) {
|
|
41
|
+
const date = new Date(value);
|
|
42
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function localCalendarDay(date: Date) {
|
|
46
|
+
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatCalendarTimestamp(date: Date, now: Date) {
|
|
50
|
+
const time = timeFormatter.format(date);
|
|
51
|
+
const dayDifference = localCalendarDay(now) - localCalendarDay(date);
|
|
52
|
+
|
|
53
|
+
if (dayDifference === 1) {
|
|
54
|
+
return `Yesterday at ${time}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (dayDifference >= 2 && dayDifference <= 6) {
|
|
58
|
+
return `${weekdayFormatter.format(date)} at ${time}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dateFormatter =
|
|
62
|
+
date.getFullYear() === now.getFullYear()
|
|
63
|
+
? sameYearDateFormatter
|
|
64
|
+
: otherYearDateFormatter;
|
|
65
|
+
return `${dateFormatter.format(date)} at ${time}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
5
68
|
export function formatShortTimestamp(value: string) {
|
|
69
|
+
const date = parseTimestamp(value);
|
|
70
|
+
if (!date) return value;
|
|
71
|
+
|
|
6
72
|
return new Intl.DateTimeFormat("en", {
|
|
7
73
|
hour: "numeric",
|
|
8
74
|
minute: "2-digit",
|
|
9
75
|
month: "short",
|
|
10
76
|
day: "numeric",
|
|
11
|
-
}).format(
|
|
77
|
+
}).format(date);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatExactTimestamp(value: string) {
|
|
81
|
+
const date = parseTimestamp(value);
|
|
82
|
+
return date ? exactTimestampFormatter.format(date) : value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function formatSmartTimestamp(value: string, nowValue = Date.now()) {
|
|
86
|
+
const date = parseTimestamp(value);
|
|
87
|
+
const now = new Date(nowValue);
|
|
88
|
+
if (!date || Number.isNaN(now.getTime())) return value;
|
|
89
|
+
|
|
90
|
+
const elapsed = now.getTime() - date.getTime();
|
|
91
|
+
if (elapsed >= -45 * SECOND_MS && elapsed < 45 * SECOND_MS) {
|
|
92
|
+
return "just now";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (elapsed < 0) {
|
|
96
|
+
return formatCalendarTimestamp(date, now);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (elapsed < HOUR_MS) {
|
|
100
|
+
const minutes = Math.max(1, Math.floor(elapsed / MINUTE_MS));
|
|
101
|
+
return `${minutes} min ago`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (elapsed < DAY_MS) {
|
|
105
|
+
const hours = Math.max(1, Math.floor(elapsed / HOUR_MS));
|
|
106
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return formatCalendarTimestamp(date, now);
|
|
12
110
|
}
|
|
13
111
|
|
|
14
112
|
export function getInitials(value: string) {
|
package/src/lib/queries.ts
CHANGED
|
@@ -1286,6 +1286,67 @@ function getTweetById(
|
|
|
1286
1286
|
);
|
|
1287
1287
|
}
|
|
1288
1288
|
|
|
1289
|
+
export function getTweetsByIds(
|
|
1290
|
+
tweetIds: string[],
|
|
1291
|
+
accountId?: string,
|
|
1292
|
+
): EmbeddedTweet[] {
|
|
1293
|
+
const db = getNativeDb();
|
|
1294
|
+
const scopedAccountId =
|
|
1295
|
+
accountId && accountId !== "all" ? accountId : undefined;
|
|
1296
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1297
|
+
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1298
|
+
const resolveProfileByHandle = (handle: string) =>
|
|
1299
|
+
getProfileByHandle(db, profileByHandleCache, handle);
|
|
1300
|
+
const seen = new Set<string>();
|
|
1301
|
+
const tweets: EmbeddedTweet[] = [];
|
|
1302
|
+
|
|
1303
|
+
for (const tweetId of tweetIds) {
|
|
1304
|
+
const normalized = tweetId.trim().replace(/^tweet_/, "");
|
|
1305
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
1306
|
+
seen.add(normalized);
|
|
1307
|
+
if (
|
|
1308
|
+
scopedAccountId &&
|
|
1309
|
+
!db
|
|
1310
|
+
.prepare(
|
|
1311
|
+
`
|
|
1312
|
+
select 1
|
|
1313
|
+
from tweets tweet
|
|
1314
|
+
where tweet.id = ?
|
|
1315
|
+
and (
|
|
1316
|
+
tweet.account_id = ?
|
|
1317
|
+
or exists (
|
|
1318
|
+
select 1
|
|
1319
|
+
from tweet_account_edges edge
|
|
1320
|
+
where edge.account_id = ?
|
|
1321
|
+
and edge.tweet_id = tweet.id
|
|
1322
|
+
)
|
|
1323
|
+
or exists (
|
|
1324
|
+
select 1
|
|
1325
|
+
from tweet_collections collection
|
|
1326
|
+
where collection.account_id = ?
|
|
1327
|
+
and collection.tweet_id = tweet.id
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
limit 1
|
|
1331
|
+
`,
|
|
1332
|
+
)
|
|
1333
|
+
.get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
|
|
1334
|
+
) {
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
const tweet = getTweetById(
|
|
1338
|
+
db,
|
|
1339
|
+
urlExpansionCache,
|
|
1340
|
+
normalized,
|
|
1341
|
+
resolveProfileByHandle,
|
|
1342
|
+
scopedAccountId,
|
|
1343
|
+
);
|
|
1344
|
+
if (tweet) tweets.push(tweet);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
return tweets;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1289
1350
|
function listTweetDescendants(
|
|
1290
1351
|
db: Database,
|
|
1291
1352
|
urlExpansionCache: UrlExpansionCache,
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
-
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
4
|
import {
|
|
5
|
+
jsonResponse,
|
|
6
6
|
parseBoundedInteger,
|
|
7
7
|
runRouteEffect,
|
|
8
8
|
sensitiveRequestErrorResponse,
|
|
9
9
|
} from "#/lib/http-effect";
|
|
10
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
10
11
|
import {
|
|
12
|
+
normalizeDigestLanguage,
|
|
11
13
|
streamPeriodDigestEffect,
|
|
12
14
|
type PeriodDigestOptions,
|
|
13
15
|
type PeriodDigestStreamEvent,
|
|
14
16
|
} from "#/lib/period-digest";
|
|
15
17
|
|
|
16
|
-
const encoder = new TextEncoder();
|
|
17
|
-
|
|
18
18
|
function parseBoolean(value: string | null) {
|
|
19
19
|
return value === "true" || value === "1" || value === "yes";
|
|
20
20
|
}
|
|
@@ -28,6 +28,9 @@ function parseOptions(url: URL): PeriodDigestOptions {
|
|
|
28
28
|
includeDms: parseBoolean(url.searchParams.get("includeDms")),
|
|
29
29
|
refresh: parseBoolean(url.searchParams.get("refresh")),
|
|
30
30
|
model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
|
|
31
|
+
language: normalizeDigestLanguage(
|
|
32
|
+
url.searchParams.get("language") ?? undefined,
|
|
33
|
+
),
|
|
31
34
|
maxTweets: parseBoundedInteger(url.searchParams.get("maxTweets"), {
|
|
32
35
|
max: 5_000,
|
|
33
36
|
}),
|
|
@@ -47,85 +50,50 @@ function parseOptions(url: URL): PeriodDigestOptions {
|
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
function encodeEvent(event: PeriodDigestStreamEvent) {
|
|
51
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
53
|
export const Route = createFileRoute("/api/period-digest")({
|
|
55
54
|
server: {
|
|
56
55
|
handlers: {
|
|
57
56
|
GET: ({ request }) =>
|
|
58
57
|
runRouteEffect(
|
|
59
|
-
Effect.
|
|
58
|
+
Effect.sync(() => {
|
|
60
59
|
const denied = sensitiveRequestErrorResponse(request);
|
|
61
60
|
if (denied) return denied;
|
|
62
61
|
|
|
63
|
-
yield* maybeAutoUpdateBackupEffect();
|
|
64
62
|
const url = new URL(request.url);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
let options: PeriodDigestOptions;
|
|
64
|
+
try {
|
|
65
|
+
options = parseOptions(url);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return jsonResponse(
|
|
68
|
+
{
|
|
69
|
+
ok: false,
|
|
70
|
+
error: error instanceof Error ? error.message : String(error),
|
|
72
71
|
},
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
closed = true;
|
|
84
|
-
controller.close();
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
const onAbort = () => close();
|
|
88
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
89
|
-
once: true,
|
|
90
|
-
});
|
|
91
|
-
abortDigest = close;
|
|
92
|
-
const enqueue = (event: PeriodDigestStreamEvent) => {
|
|
93
|
-
if (closed) return;
|
|
94
|
-
try {
|
|
95
|
-
controller.enqueue(encodeEvent(event));
|
|
96
|
-
} catch {
|
|
97
|
-
close();
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
runEffectBackground(
|
|
102
|
-
streamPeriodDigestEffect(
|
|
103
|
-
{ ...options, signal: abortController.signal },
|
|
104
|
-
{ onEvent: enqueue },
|
|
105
|
-
),
|
|
106
|
-
{
|
|
107
|
-
onSuccess: closeController,
|
|
108
|
-
onFailure: (error) => {
|
|
109
|
-
enqueue({
|
|
110
|
-
type: "error",
|
|
111
|
-
error:
|
|
112
|
-
error instanceof Error
|
|
113
|
-
? error.message
|
|
114
|
-
: "Digest failed",
|
|
115
|
-
});
|
|
116
|
-
closeController();
|
|
117
|
-
},
|
|
118
|
-
},
|
|
119
|
-
);
|
|
72
|
+
{ status: 400 },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return createEffectNdjsonResponse<PeriodDigestStreamEvent>({
|
|
76
|
+
request,
|
|
77
|
+
initialEvents: [
|
|
78
|
+
{
|
|
79
|
+
type: "status",
|
|
80
|
+
label: "Preparing local archive",
|
|
81
|
+
detail: "Checking for backup updates.",
|
|
120
82
|
},
|
|
83
|
+
],
|
|
84
|
+
run: ({ signal, emit }) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
yield* maybeAutoUpdateBackupEffect();
|
|
87
|
+
return yield* streamPeriodDigestEffect(
|
|
88
|
+
{ ...options, signal },
|
|
89
|
+
{ onEvent: emit },
|
|
90
|
+
);
|
|
91
|
+
}),
|
|
92
|
+
errorEvent: (error) => ({
|
|
93
|
+
type: "error",
|
|
94
|
+
error: error instanceof Error ? error.message : "Digest failed",
|
|
121
95
|
}),
|
|
122
|
-
|
|
123
|
-
headers: {
|
|
124
|
-
"cache-control": "no-store",
|
|
125
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
);
|
|
96
|
+
});
|
|
129
97
|
}),
|
|
130
98
|
),
|
|
131
99
|
},
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
-
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
4
|
import {
|
|
6
5
|
parseBoundedInteger,
|
|
7
6
|
runRouteEffect,
|
|
8
7
|
sensitiveRequestErrorResponse,
|
|
9
8
|
} from "#/lib/http-effect";
|
|
9
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
10
10
|
import {
|
|
11
11
|
streamProfileAnalysisEffect,
|
|
12
12
|
type ProfileAnalysisOptions,
|
|
13
13
|
type ProfileAnalysisStreamEvent,
|
|
14
14
|
} from "#/lib/profile-analysis";
|
|
15
15
|
|
|
16
|
-
const encoder = new TextEncoder();
|
|
17
|
-
|
|
18
16
|
function parseBoolean(value: string | null) {
|
|
19
17
|
return value === "true" || value === "1" || value === "yes";
|
|
20
18
|
}
|
|
@@ -57,10 +55,6 @@ function parseOptions(url: URL): ProfileAnalysisOptions {
|
|
|
57
55
|
};
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
function encodeEvent(event: ProfileAnalysisStreamEvent) {
|
|
61
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
58
|
export const Route = createFileRoute("/api/profile-analysis")({
|
|
65
59
|
server: {
|
|
66
60
|
handlers: {
|
|
@@ -72,79 +66,30 @@ export const Route = createFileRoute("/api/profile-analysis")({
|
|
|
72
66
|
|
|
73
67
|
const url = new URL(request.url);
|
|
74
68
|
const options = parseOptions(url);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
return createEffectNdjsonResponse<ProfileAnalysisStreamEvent>({
|
|
70
|
+
request,
|
|
71
|
+
initialEvents: [
|
|
72
|
+
{
|
|
73
|
+
type: "status",
|
|
74
|
+
label: "Starting profile analysis",
|
|
81
75
|
},
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const closeController = () => {
|
|
90
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
91
|
-
if (!closed) {
|
|
92
|
-
closed = true;
|
|
93
|
-
controller.close();
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
const onAbort = () => close();
|
|
97
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
98
|
-
once: true,
|
|
99
|
-
});
|
|
100
|
-
abortAnalysis = close;
|
|
101
|
-
const enqueue = (event: ProfileAnalysisStreamEvent) => {
|
|
102
|
-
if (closed) return;
|
|
103
|
-
try {
|
|
104
|
-
controller.enqueue(encodeEvent(event));
|
|
105
|
-
} catch {
|
|
106
|
-
close();
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
enqueue({
|
|
110
|
-
type: "status",
|
|
111
|
-
label: "Starting profile analysis",
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
runEffectBackground(
|
|
115
|
-
Effect.gen(function* () {
|
|
116
|
-
yield* maybeAutoUpdateBackupEffect();
|
|
117
|
-
return yield* streamProfileAnalysisEffect(
|
|
118
|
-
{
|
|
119
|
-
...options,
|
|
120
|
-
signal: abortController.signal,
|
|
121
|
-
},
|
|
122
|
-
{ onEvent: enqueue },
|
|
123
|
-
);
|
|
124
|
-
}),
|
|
125
|
-
{
|
|
126
|
-
onSuccess: closeController,
|
|
127
|
-
onFailure: (error) => {
|
|
128
|
-
enqueue({
|
|
129
|
-
type: "error",
|
|
130
|
-
error:
|
|
131
|
-
error instanceof Error
|
|
132
|
-
? error.message
|
|
133
|
-
: "Profile analysis failed",
|
|
134
|
-
});
|
|
135
|
-
closeController();
|
|
136
|
-
},
|
|
137
|
-
},
|
|
76
|
+
],
|
|
77
|
+
run: ({ signal, emit }) =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
yield* maybeAutoUpdateBackupEffect();
|
|
80
|
+
return yield* streamProfileAnalysisEffect(
|
|
81
|
+
{ ...options, signal },
|
|
82
|
+
{ onEvent: emit },
|
|
138
83
|
);
|
|
139
|
-
},
|
|
84
|
+
}),
|
|
85
|
+
errorEvent: (error) => ({
|
|
86
|
+
type: "error",
|
|
87
|
+
error:
|
|
88
|
+
error instanceof Error
|
|
89
|
+
? error.message
|
|
90
|
+
: "Profile analysis failed",
|
|
140
91
|
}),
|
|
141
|
-
|
|
142
|
-
headers: {
|
|
143
|
-
"cache-control": "no-store",
|
|
144
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
);
|
|
92
|
+
});
|
|
148
93
|
}),
|
|
149
94
|
),
|
|
150
95
|
},
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
-
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
4
|
import {
|
|
6
5
|
parseBoundedInteger,
|
|
7
6
|
runRouteEffect,
|
|
8
7
|
sensitiveRequestErrorResponse,
|
|
9
8
|
} from "#/lib/http-effect";
|
|
9
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
10
10
|
import {
|
|
11
11
|
streamSearchDiscussionEffect,
|
|
12
12
|
type SearchDiscussionOptions,
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
} from "#/lib/search-discussion";
|
|
16
16
|
import type { TweetSearchMode } from "#/lib/tweet-search-live";
|
|
17
17
|
|
|
18
|
-
const encoder = new TextEncoder();
|
|
19
18
|
const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
|
|
20
19
|
const MAX_DISCUSSION_SEARCH_PAGES = 200;
|
|
21
20
|
|
|
@@ -73,10 +72,6 @@ function parseOptions(url: URL): SearchDiscussionOptions {
|
|
|
73
72
|
};
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
function encodeEvent(event: SearchDiscussionStreamEvent) {
|
|
77
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
75
|
export const Route = createFileRoute("/api/search-discussion")({
|
|
81
76
|
server: {
|
|
82
77
|
handlers: {
|
|
@@ -88,80 +83,29 @@ export const Route = createFileRoute("/api/search-discussion")({
|
|
|
88
83
|
|
|
89
84
|
const url = new URL(request.url);
|
|
90
85
|
const options = parseOptions(url);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const abortController = new AbortController();
|
|
100
|
-
let closed = false;
|
|
101
|
-
const close = () => {
|
|
102
|
-
closed = true;
|
|
103
|
-
abortController.abort();
|
|
104
|
-
};
|
|
105
|
-
const closeController = () => {
|
|
106
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
107
|
-
if (!closed) {
|
|
108
|
-
closed = true;
|
|
109
|
-
controller.close();
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
const onAbort = () => close();
|
|
113
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
114
|
-
once: true,
|
|
115
|
-
});
|
|
116
|
-
abortDiscussion = close;
|
|
117
|
-
const enqueue = (event: SearchDiscussionStreamEvent) => {
|
|
118
|
-
if (closed) return;
|
|
119
|
-
try {
|
|
120
|
-
controller.enqueue(encodeEvent(event));
|
|
121
|
-
} catch {
|
|
122
|
-
close();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
runEffectBackground(
|
|
127
|
-
maybeAutoUpdateBackupEffect().pipe(
|
|
128
|
-
Effect.flatMap(() => {
|
|
129
|
-
if (closed || abortController.signal.aborted) {
|
|
130
|
-
return Effect.succeed(undefined);
|
|
131
|
-
}
|
|
132
|
-
return streamSearchDiscussionEffect(
|
|
86
|
+
return createEffectNdjsonResponse<SearchDiscussionStreamEvent>({
|
|
87
|
+
request,
|
|
88
|
+
run: ({ signal, emit }) =>
|
|
89
|
+
maybeAutoUpdateBackupEffect().pipe(
|
|
90
|
+
Effect.flatMap(() =>
|
|
91
|
+
signal.aborted
|
|
92
|
+
? Effect.succeed(undefined)
|
|
93
|
+
: streamSearchDiscussionEffect(
|
|
133
94
|
{
|
|
134
95
|
...options,
|
|
135
|
-
signal
|
|
96
|
+
signal,
|
|
136
97
|
prefetchAvatars: true,
|
|
137
98
|
},
|
|
138
|
-
{ onEvent:
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
type: "error",
|
|
147
|
-
error:
|
|
148
|
-
error instanceof Error
|
|
149
|
-
? error.message
|
|
150
|
-
: "Discussion failed",
|
|
151
|
-
});
|
|
152
|
-
closeController();
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
},
|
|
99
|
+
{ onEvent: emit },
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
errorEvent: (error) => ({
|
|
104
|
+
type: "error",
|
|
105
|
+
error:
|
|
106
|
+
error instanceof Error ? error.message : "Discussion failed",
|
|
157
107
|
}),
|
|
158
|
-
|
|
159
|
-
headers: {
|
|
160
|
-
"cache-control": "no-store",
|
|
161
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
);
|
|
108
|
+
});
|
|
165
109
|
}),
|
|
166
110
|
),
|
|
167
111
|
},
|
|
@@ -169,7 +169,9 @@ function CapabilityRow({
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function DataSourcesRoute() {
|
|
172
|
-
const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(
|
|
172
|
+
const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(
|
|
173
|
+
null,
|
|
174
|
+
);
|
|
173
175
|
const [error, setError] = useState<string | null>(null);
|
|
174
176
|
const [loading, setLoading] = useState(true);
|
|
175
177
|
const sourcesByKind = useMemo(
|
package/src/routes/links.tsx
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
LinkSkeletonRows,
|
|
19
19
|
} from "#/components/FeedState";
|
|
20
20
|
import { ProfilePreview } from "#/components/ProfilePreview";
|
|
21
|
-
import {
|
|
21
|
+
import { SmartTimestamp } from "#/components/SmartTimestamp";
|
|
22
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
22
23
|
import type {
|
|
23
24
|
LinkInsightItem,
|
|
24
25
|
LinkInsightKind,
|
|
@@ -393,7 +394,7 @@ function MentionCard({
|
|
|
393
394
|
rel="noreferrer"
|
|
394
395
|
target="_blank"
|
|
395
396
|
>
|
|
396
|
-
{
|
|
397
|
+
<SmartTimestamp value={mention.createdAt} />
|
|
397
398
|
</a>
|
|
398
399
|
</div>
|
|
399
400
|
<p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
|
|
@@ -633,7 +634,7 @@ function LinkInsightRow({
|
|
|
633
634
|
<span>/</span>
|
|
634
635
|
<SharerStrip item={item} />
|
|
635
636
|
<span>/</span>
|
|
636
|
-
<
|
|
637
|
+
<SmartTimestamp value={item.lastSeenAt} />
|
|
637
638
|
</div>
|
|
638
639
|
<VideoPreview item={item} />
|
|
639
640
|
</div>
|