birdclaw 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/package.json +5 -8
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +9 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +9 -4
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +59 -100
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +24 -6
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +149 -47
- package/src/lib/api-client.ts +117 -2
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/authored-live.ts +2 -62
- package/src/lib/bird.ts +32 -1
- package/src/lib/client-cache.ts +109 -0
- package/src/lib/db.ts +57 -4
- package/src/lib/mention-threads-live.ts +2 -1
- package/src/lib/mentions-live.ts +2 -46
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +184 -24
- package/src/lib/present.ts +99 -1
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +68 -6
- package/src/lib/sqlite.ts +5 -2
- package/src/lib/timeline-collections-live.ts +2 -1
- package/src/lib/timeline-live.ts +2 -1
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-search-live.ts +2 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +0 -13
- package/src/routes/api/period-digest.tsx +21 -69
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +75 -9
- package/src/routes/today.tsx +67 -11
- package/vite.config.ts +0 -2
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) {
|
|
@@ -407,7 +407,7 @@ function mergeXurlTweetsIntoLocalStore(
|
|
|
407
407
|
replyToId,
|
|
408
408
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
409
409
|
countTweetMedia(tweet),
|
|
410
|
-
JSON.stringify(tweet.entities
|
|
410
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
411
411
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
412
412
|
quotedTweetId,
|
|
413
413
|
);
|
package/src/lib/queries.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import type { Database } from "./sqlite";
|
|
4
|
-
import {
|
|
4
|
+
import { findArchivesCachedEffect } from "./archive-finder";
|
|
5
5
|
import { getDb, getNativeDb } from "./db";
|
|
6
6
|
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
7
7
|
import { fetchProfileAffiliations } from "./profile-affiliations";
|
|
@@ -701,10 +701,9 @@ function getAccountProfileMeta(
|
|
|
701
701
|
| undefined;
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
-
export function getQueryEnvelopeEffect(
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
> {
|
|
704
|
+
export function getQueryEnvelopeEffect({
|
|
705
|
+
includeArchives = true,
|
|
706
|
+
}: { includeArchives?: boolean } = {}): Effect.Effect<QueryEnvelope, unknown> {
|
|
708
707
|
return Effect.gen(function* () {
|
|
709
708
|
const db = yield* trySync(() => getDb());
|
|
710
709
|
const nativeDb = yield* trySync(() => getNativeDb());
|
|
@@ -736,7 +735,9 @@ export function getQueryEnvelopeEffect(): Effect.Effect<
|
|
|
736
735
|
.orderBy("name", "asc")
|
|
737
736
|
.execute(),
|
|
738
737
|
),
|
|
739
|
-
archives:
|
|
738
|
+
archives: includeArchives
|
|
739
|
+
? findArchivesCachedEffect()
|
|
740
|
+
: Effect.succeed([]),
|
|
740
741
|
transport: getTransportStatusEffect(),
|
|
741
742
|
});
|
|
742
743
|
|
|
@@ -1286,6 +1287,67 @@ function getTweetById(
|
|
|
1286
1287
|
);
|
|
1287
1288
|
}
|
|
1288
1289
|
|
|
1290
|
+
export function getTweetsByIds(
|
|
1291
|
+
tweetIds: string[],
|
|
1292
|
+
accountId?: string,
|
|
1293
|
+
): EmbeddedTweet[] {
|
|
1294
|
+
const db = getNativeDb();
|
|
1295
|
+
const scopedAccountId =
|
|
1296
|
+
accountId && accountId !== "all" ? accountId : undefined;
|
|
1297
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1298
|
+
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1299
|
+
const resolveProfileByHandle = (handle: string) =>
|
|
1300
|
+
getProfileByHandle(db, profileByHandleCache, handle);
|
|
1301
|
+
const seen = new Set<string>();
|
|
1302
|
+
const tweets: EmbeddedTweet[] = [];
|
|
1303
|
+
|
|
1304
|
+
for (const tweetId of tweetIds) {
|
|
1305
|
+
const normalized = tweetId.trim().replace(/^tweet_/, "");
|
|
1306
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
1307
|
+
seen.add(normalized);
|
|
1308
|
+
if (
|
|
1309
|
+
scopedAccountId &&
|
|
1310
|
+
!db
|
|
1311
|
+
.prepare(
|
|
1312
|
+
`
|
|
1313
|
+
select 1
|
|
1314
|
+
from tweets tweet
|
|
1315
|
+
where tweet.id = ?
|
|
1316
|
+
and (
|
|
1317
|
+
tweet.account_id = ?
|
|
1318
|
+
or exists (
|
|
1319
|
+
select 1
|
|
1320
|
+
from tweet_account_edges edge
|
|
1321
|
+
where edge.account_id = ?
|
|
1322
|
+
and edge.tweet_id = tweet.id
|
|
1323
|
+
)
|
|
1324
|
+
or exists (
|
|
1325
|
+
select 1
|
|
1326
|
+
from tweet_collections collection
|
|
1327
|
+
where collection.account_id = ?
|
|
1328
|
+
and collection.tweet_id = tweet.id
|
|
1329
|
+
)
|
|
1330
|
+
)
|
|
1331
|
+
limit 1
|
|
1332
|
+
`,
|
|
1333
|
+
)
|
|
1334
|
+
.get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
|
|
1335
|
+
) {
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
const tweet = getTweetById(
|
|
1339
|
+
db,
|
|
1340
|
+
urlExpansionCache,
|
|
1341
|
+
normalized,
|
|
1342
|
+
resolveProfileByHandle,
|
|
1343
|
+
scopedAccountId,
|
|
1344
|
+
);
|
|
1345
|
+
if (tweet) tweets.push(tweet);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return tweets;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1289
1351
|
function listTweetDescendants(
|
|
1290
1352
|
db: Database,
|
|
1291
1353
|
urlExpansionCache: UrlExpansionCache,
|
package/src/lib/sqlite.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type Database = NativeSqliteDatabase;
|
|
|
5
5
|
type DatabaseOptions = {
|
|
6
6
|
readonly?: boolean;
|
|
7
7
|
fileMustExist?: boolean;
|
|
8
|
+
timeout?: number;
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
type PragmaOptions = {
|
|
@@ -16,6 +17,8 @@ type RunResult = {
|
|
|
16
17
|
lastInsertRowid: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
19
22
|
function bindArgs(parameters: unknown[]) {
|
|
20
23
|
if (parameters.length === 1 && Array.isArray(parameters[0])) {
|
|
21
24
|
return parameters[0];
|
|
@@ -87,7 +90,7 @@ export class NativeSqliteDatabase {
|
|
|
87
90
|
constructor(path: string, options: DatabaseOptions = {}) {
|
|
88
91
|
this.db = new DatabaseSync(path, {
|
|
89
92
|
readOnly: options.readonly,
|
|
90
|
-
timeout:
|
|
93
|
+
timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -123,7 +126,7 @@ export class NativeSqliteDatabase {
|
|
|
123
126
|
return (...args: TArgs) => {
|
|
124
127
|
const nested = this.db.isTransaction;
|
|
125
128
|
const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
|
|
126
|
-
this.exec(nested ? `savepoint ${savepoint}` : "begin");
|
|
129
|
+
this.exec(nested ? `savepoint ${savepoint}` : "begin immediate");
|
|
127
130
|
try {
|
|
128
131
|
const result = fn(...args);
|
|
129
132
|
this.exec(nested ? `release ${savepoint}` : "commit");
|
|
@@ -8,6 +8,7 @@ import { getNativeDb } from "./db";
|
|
|
8
8
|
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
9
9
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
10
10
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
11
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
11
12
|
import type {
|
|
12
13
|
XurlMentionData,
|
|
13
14
|
XurlMentionsResponse,
|
|
@@ -310,7 +311,7 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
310
311
|
countTweetMedia(tweet),
|
|
311
312
|
bookmarked,
|
|
312
313
|
liked,
|
|
313
|
-
JSON.stringify(tweet.entities
|
|
314
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
314
315
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
315
316
|
quotedTweetId,
|
|
316
317
|
);
|
package/src/lib/timeline-live.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
|
|
|
5
5
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
8
9
|
import type {
|
|
9
10
|
XurlMediaItem,
|
|
10
11
|
XurlMentionUser,
|
|
@@ -244,7 +245,7 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
244
245
|
replyToId,
|
|
245
246
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
246
247
|
countTweetMedia(tweet),
|
|
247
|
-
JSON.stringify(tweet.entities
|
|
248
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
248
249
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
249
250
|
quotedTweetId,
|
|
250
251
|
);
|
package/src/lib/tweet-render.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
TweetArticle,
|
|
2
3
|
TweetEntities,
|
|
3
4
|
TweetHashtagEntity,
|
|
4
5
|
TweetMentionEntity,
|
|
@@ -57,6 +58,34 @@ export function displayUrlForLink(url: string) {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
function comparableUrl(value: string) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = new URL(value);
|
|
64
|
+
return `${parsed.protocol}//${parsed.hostname.replace(/^www\./, "")}${parsed.pathname}`;
|
|
65
|
+
} catch {
|
|
66
|
+
return value.split("?")[0] ?? value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isTweetArticleUrlEntity(
|
|
71
|
+
entry: TweetUrlEntity,
|
|
72
|
+
article: TweetArticle,
|
|
73
|
+
) {
|
|
74
|
+
if (comparableUrl(entry.expandedUrl) === comparableUrl(article.url)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = new URL(entry.expandedUrl);
|
|
79
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
80
|
+
return (
|
|
81
|
+
(host === "x.com" || host === "twitter.com") &&
|
|
82
|
+
parsed.pathname.startsWith("/i/article/")
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
function asRecord(value: unknown) {
|
|
61
90
|
return value && typeof value === "object"
|
|
62
91
|
? (value as Record<string, unknown>)
|
|
@@ -68,6 +97,9 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
|
68
97
|
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
69
98
|
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
70
99
|
const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
|
|
100
|
+
const rawArticle = asRecord(entities.article);
|
|
101
|
+
const articleTitle = String(rawArticle.title ?? "").trim();
|
|
102
|
+
const articleUrl = String(rawArticle.url ?? "").trim();
|
|
71
103
|
|
|
72
104
|
return {
|
|
73
105
|
...(rawMentions.length
|
|
@@ -102,6 +134,23 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
|
102
134
|
),
|
|
103
135
|
start: Number(value.start ?? 0),
|
|
104
136
|
end: Number(value.end ?? 0),
|
|
137
|
+
...(typeof value.title === "string"
|
|
138
|
+
? { title: value.title }
|
|
139
|
+
: {}),
|
|
140
|
+
...(typeof value.description === "string" ||
|
|
141
|
+
value.description === null
|
|
142
|
+
? { description: value.description }
|
|
143
|
+
: {}),
|
|
144
|
+
...(typeof value.imageUrl === "string"
|
|
145
|
+
? { imageUrl: value.imageUrl }
|
|
146
|
+
: typeof value.image_url === "string"
|
|
147
|
+
? { imageUrl: value.image_url }
|
|
148
|
+
: {}),
|
|
149
|
+
...(typeof value.siteName === "string"
|
|
150
|
+
? { siteName: value.siteName }
|
|
151
|
+
: typeof value.site_name === "string"
|
|
152
|
+
? { siteName: value.site_name }
|
|
153
|
+
: {}),
|
|
105
154
|
};
|
|
106
155
|
}),
|
|
107
156
|
}
|
|
@@ -118,6 +167,35 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
|
|
|
118
167
|
}),
|
|
119
168
|
}
|
|
120
169
|
: {}),
|
|
170
|
+
...(articleTitle && articleUrl
|
|
171
|
+
? {
|
|
172
|
+
article: {
|
|
173
|
+
title: articleTitle,
|
|
174
|
+
url: articleUrl,
|
|
175
|
+
...(typeof (rawArticle.previewText ?? rawArticle.preview_text) ===
|
|
176
|
+
"string" &&
|
|
177
|
+
String(rawArticle.previewText ?? rawArticle.preview_text).trim()
|
|
178
|
+
? {
|
|
179
|
+
previewText: String(
|
|
180
|
+
rawArticle.previewText ?? rawArticle.preview_text,
|
|
181
|
+
).trim(),
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
184
|
+
...(typeof (
|
|
185
|
+
rawArticle.coverImageUrl ?? rawArticle.cover_image_url
|
|
186
|
+
) === "string" &&
|
|
187
|
+
String(
|
|
188
|
+
rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
|
|
189
|
+
).trim()
|
|
190
|
+
? {
|
|
191
|
+
coverImageUrl: String(
|
|
192
|
+
rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
|
|
193
|
+
).trim(),
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
121
199
|
};
|
|
122
200
|
}
|
|
123
201
|
|
|
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
|
|
|
5
5
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
7
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
8
9
|
import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
|
|
9
10
|
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
10
11
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
@@ -190,7 +191,7 @@ function mergeTweetSearchIntoLocalStore(
|
|
|
190
191
|
replyToId,
|
|
191
192
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
192
193
|
countTweetMedia(tweet),
|
|
193
|
-
JSON.stringify(tweet.entities
|
|
194
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
194
195
|
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
195
196
|
quotedTweetId,
|
|
196
197
|
);
|
package/src/lib/types.ts
CHANGED
|
@@ -101,10 +101,18 @@ export interface TweetHashtagEntity {
|
|
|
101
101
|
end: number;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
export interface TweetArticle {
|
|
105
|
+
title: string;
|
|
106
|
+
previewText?: string;
|
|
107
|
+
url: string;
|
|
108
|
+
coverImageUrl?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
export interface TweetEntities {
|
|
105
112
|
mentions?: TweetMentionEntity[];
|
|
106
113
|
urls?: TweetUrlEntity[];
|
|
107
114
|
hashtags?: TweetHashtagEntity[];
|
|
115
|
+
article?: TweetArticle;
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
export interface TweetMediaItem {
|
package/src/lib/ui.ts
CHANGED
|
@@ -311,7 +311,7 @@ export const profilePreviewTriggerClass =
|
|
|
311
311
|
"profile-preview-trigger inline-flex text-inherit";
|
|
312
312
|
|
|
313
313
|
export const profilePreviewCardClass =
|
|
314
|
-
"
|
|
314
|
+
"fixed z-40 w-[280px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 shadow-[0_8px_28px_var(--shadow-strong)]";
|
|
315
315
|
|
|
316
316
|
export const profilePreviewHeaderClass = "flex items-center gap-3";
|
|
317
317
|
|
package/src/router.tsx
CHANGED
package/src/routes/__root.tsx
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { TanStackDevtools } from "@tanstack/react-devtools";
|
|
2
1
|
import {
|
|
3
2
|
createRootRoute,
|
|
4
3
|
HeadContent,
|
|
5
4
|
Scripts,
|
|
6
5
|
useRouterState,
|
|
7
6
|
} from "@tanstack/react-router";
|
|
8
|
-
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
|
9
7
|
import type { ReactNode } from "react";
|
|
10
8
|
import { AppNav } from "#/components/AppNav";
|
|
11
9
|
import { ThemeProvider, themeScript } from "#/lib/theme";
|
|
@@ -74,17 +72,6 @@ function RootDocument({ children }: { children: ReactNode }) {
|
|
|
74
72
|
</main>
|
|
75
73
|
</div>
|
|
76
74
|
</ThemeProvider>
|
|
77
|
-
<TanStackDevtools
|
|
78
|
-
config={{
|
|
79
|
-
position: "bottom-right",
|
|
80
|
-
}}
|
|
81
|
-
plugins={[
|
|
82
|
-
{
|
|
83
|
-
name: "Tanstack Router",
|
|
84
|
-
render: <TanStackRouterDevtoolsPanel />,
|
|
85
|
-
},
|
|
86
|
-
]}
|
|
87
|
-
/>
|
|
88
75
|
<Scripts />
|
|
89
76
|
</body>
|
|
90
77
|
</html>
|
|
@@ -1,13 +1,13 @@
|
|
|
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
|
jsonResponse,
|
|
7
6
|
parseBoundedInteger,
|
|
8
7
|
runRouteEffect,
|
|
9
8
|
sensitiveRequestErrorResponse,
|
|
10
9
|
} from "#/lib/http-effect";
|
|
10
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
11
11
|
import {
|
|
12
12
|
normalizeDigestLanguage,
|
|
13
13
|
streamPeriodDigestEffect,
|
|
@@ -15,8 +15,6 @@ import {
|
|
|
15
15
|
type PeriodDigestStreamEvent,
|
|
16
16
|
} from "#/lib/period-digest";
|
|
17
17
|
|
|
18
|
-
const encoder = new TextEncoder();
|
|
19
|
-
|
|
20
18
|
function parseBoolean(value: string | null) {
|
|
21
19
|
return value === "true" || value === "1" || value === "yes";
|
|
22
20
|
}
|
|
@@ -52,16 +50,12 @@ function parseOptions(url: URL): PeriodDigestOptions {
|
|
|
52
50
|
};
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
function encodeEvent(event: PeriodDigestStreamEvent) {
|
|
56
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
53
|
export const Route = createFileRoute("/api/period-digest")({
|
|
60
54
|
server: {
|
|
61
55
|
handlers: {
|
|
62
56
|
GET: ({ request }) =>
|
|
63
57
|
runRouteEffect(
|
|
64
|
-
Effect.
|
|
58
|
+
Effect.sync(() => {
|
|
65
59
|
const denied = sensitiveRequestErrorResponse(request);
|
|
66
60
|
if (denied) return denied;
|
|
67
61
|
|
|
@@ -78,70 +72,28 @@ export const Route = createFileRoute("/api/period-digest")({
|
|
|
78
72
|
{ status: 400 },
|
|
79
73
|
);
|
|
80
74
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
return createEffectNdjsonResponse<PeriodDigestStreamEvent>({
|
|
76
|
+
request,
|
|
77
|
+
initialEvents: [
|
|
78
|
+
{
|
|
79
|
+
type: "status",
|
|
80
|
+
label: "Preparing local archive",
|
|
81
|
+
detail: "Checking for backup updates.",
|
|
88
82
|
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const closeController = () => {
|
|
97
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
98
|
-
if (!closed) {
|
|
99
|
-
closed = true;
|
|
100
|
-
controller.close();
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
const onAbort = () => close();
|
|
104
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
105
|
-
once: true,
|
|
106
|
-
});
|
|
107
|
-
abortDigest = close;
|
|
108
|
-
const enqueue = (event: PeriodDigestStreamEvent) => {
|
|
109
|
-
if (closed) return;
|
|
110
|
-
try {
|
|
111
|
-
controller.enqueue(encodeEvent(event));
|
|
112
|
-
} catch {
|
|
113
|
-
close();
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
runEffectBackground(
|
|
118
|
-
streamPeriodDigestEffect(
|
|
119
|
-
{ ...options, signal: abortController.signal },
|
|
120
|
-
{ onEvent: enqueue },
|
|
121
|
-
),
|
|
122
|
-
{
|
|
123
|
-
onSuccess: closeController,
|
|
124
|
-
onFailure: (error) => {
|
|
125
|
-
enqueue({
|
|
126
|
-
type: "error",
|
|
127
|
-
error:
|
|
128
|
-
error instanceof Error
|
|
129
|
-
? error.message
|
|
130
|
-
: "Digest failed",
|
|
131
|
-
});
|
|
132
|
-
closeController();
|
|
133
|
-
},
|
|
134
|
-
},
|
|
83
|
+
],
|
|
84
|
+
run: ({ signal, emit }) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
yield* maybeAutoUpdateBackupEffect();
|
|
87
|
+
return yield* streamPeriodDigestEffect(
|
|
88
|
+
{ ...options, signal },
|
|
89
|
+
{ onEvent: emit },
|
|
135
90
|
);
|
|
136
|
-
},
|
|
91
|
+
}),
|
|
92
|
+
errorEvent: (error) => ({
|
|
93
|
+
type: "error",
|
|
94
|
+
error: error instanceof Error ? error.message : "Digest failed",
|
|
137
95
|
}),
|
|
138
|
-
|
|
139
|
-
headers: {
|
|
140
|
-
"cache-control": "no-store",
|
|
141
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
);
|
|
96
|
+
});
|
|
145
97
|
}),
|
|
146
98
|
),
|
|
147
99
|
},
|