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
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
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
3
|
+
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
4
|
+
import type { XurlMentionData, XurlMentionsResponse } from "./types";
|
|
5
|
+
import {
|
|
6
|
+
type TweetAccountEdgeKind,
|
|
7
|
+
upsertTweetAccountEdge,
|
|
8
|
+
} from "./tweet-account-edges";
|
|
9
|
+
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
10
|
+
|
|
11
|
+
export type CanonicalTweetKind =
|
|
12
|
+
| "authored"
|
|
13
|
+
| "bookmark"
|
|
14
|
+
| "home"
|
|
15
|
+
| "like"
|
|
16
|
+
| "mention"
|
|
17
|
+
| "search"
|
|
18
|
+
| "thread"
|
|
19
|
+
| "thread_context";
|
|
20
|
+
|
|
21
|
+
export interface IngestTweetPayloadOptions {
|
|
22
|
+
accountId: string;
|
|
23
|
+
payload: XurlMentionsResponse;
|
|
24
|
+
kind: CanonicalTweetKind;
|
|
25
|
+
source: string;
|
|
26
|
+
edgeKind?: TweetAccountEdgeKind;
|
|
27
|
+
collectionKind?: "likes" | "bookmarks";
|
|
28
|
+
markRepliesAsReplied?: boolean;
|
|
29
|
+
replaceSecondaryKind?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getReferencedTweetId(tweet: XurlMentionData, type: string) {
|
|
33
|
+
return (
|
|
34
|
+
tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
39
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
40
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
41
|
+
tweetId,
|
|
42
|
+
text,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ingestTweetPayload(
|
|
47
|
+
db: Database,
|
|
48
|
+
{
|
|
49
|
+
accountId,
|
|
50
|
+
payload,
|
|
51
|
+
kind,
|
|
52
|
+
source,
|
|
53
|
+
edgeKind,
|
|
54
|
+
collectionKind,
|
|
55
|
+
markRepliesAsReplied = false,
|
|
56
|
+
replaceSecondaryKind = false,
|
|
57
|
+
}: IngestTweetPayloadOptions,
|
|
58
|
+
) {
|
|
59
|
+
const usersById = new Map(
|
|
60
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
61
|
+
);
|
|
62
|
+
const upsertTweet = db.prepare(`
|
|
63
|
+
insert into tweets (
|
|
64
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
65
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
66
|
+
entities_json, media_json, quoted_tweet_id
|
|
67
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
68
|
+
on conflict(id) do update set
|
|
69
|
+
account_id = tweets.account_id,
|
|
70
|
+
author_profile_id = excluded.author_profile_id,
|
|
71
|
+
kind = case
|
|
72
|
+
when ? = 1
|
|
73
|
+
and tweets.kind not in ('authored', 'home', 'mention')
|
|
74
|
+
then excluded.kind
|
|
75
|
+
else tweets.kind
|
|
76
|
+
end,
|
|
77
|
+
text = excluded.text,
|
|
78
|
+
created_at = excluded.created_at,
|
|
79
|
+
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
80
|
+
reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
|
|
81
|
+
like_count = excluded.like_count,
|
|
82
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
83
|
+
entities_json = excluded.entities_json,
|
|
84
|
+
media_json = case
|
|
85
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
86
|
+
else tweets.media_json
|
|
87
|
+
end,
|
|
88
|
+
bookmarked = tweets.bookmarked,
|
|
89
|
+
liked = tweets.liked,
|
|
90
|
+
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
|
|
91
|
+
`);
|
|
92
|
+
const upsertCollection = collectionKind
|
|
93
|
+
? db.prepare(`
|
|
94
|
+
insert into tweet_collections (
|
|
95
|
+
account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
|
|
96
|
+
) values (?, ?, ?, null, ?, ?, ?)
|
|
97
|
+
on conflict(account_id, tweet_id, kind) do update set
|
|
98
|
+
source = excluded.source,
|
|
99
|
+
raw_json = excluded.raw_json,
|
|
100
|
+
updated_at = excluded.updated_at
|
|
101
|
+
`)
|
|
102
|
+
: undefined;
|
|
103
|
+
const tweetIds: string[] = [];
|
|
104
|
+
|
|
105
|
+
db.transaction(() => {
|
|
106
|
+
const observedAt = new Date().toISOString();
|
|
107
|
+
for (const tweet of payload.data) {
|
|
108
|
+
const author = usersById.get(tweet.author_id);
|
|
109
|
+
const profile = author
|
|
110
|
+
? upsertProfileFromXUser(db, author)
|
|
111
|
+
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
112
|
+
const replyToId = getReferencedTweetId(tweet, "replied_to");
|
|
113
|
+
const quotedTweetId = getReferencedTweetId(tweet, "quoted");
|
|
114
|
+
upsertTweet.run(
|
|
115
|
+
tweet.id,
|
|
116
|
+
accountId,
|
|
117
|
+
profile.profile.id,
|
|
118
|
+
kind,
|
|
119
|
+
tweet.text,
|
|
120
|
+
tweet.created_at,
|
|
121
|
+
markRepliesAsReplied && replyToId ? 1 : 0,
|
|
122
|
+
replyToId,
|
|
123
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
124
|
+
countTweetMedia(tweet),
|
|
125
|
+
collectionKind === "bookmarks" ? 1 : 0,
|
|
126
|
+
collectionKind === "likes" ? 1 : 0,
|
|
127
|
+
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
128
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
129
|
+
quotedTweetId,
|
|
130
|
+
replaceSecondaryKind ? 1 : 0,
|
|
131
|
+
);
|
|
132
|
+
if (edgeKind) {
|
|
133
|
+
upsertTweetAccountEdge(db, {
|
|
134
|
+
accountId,
|
|
135
|
+
tweetId: tweet.id,
|
|
136
|
+
kind: edgeKind,
|
|
137
|
+
source,
|
|
138
|
+
seenAt: observedAt,
|
|
139
|
+
rawJson: JSON.stringify(tweet),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
upsertCollection?.run(
|
|
143
|
+
accountId,
|
|
144
|
+
tweet.id,
|
|
145
|
+
collectionKind,
|
|
146
|
+
source,
|
|
147
|
+
JSON.stringify(tweet),
|
|
148
|
+
observedAt,
|
|
149
|
+
);
|
|
150
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
151
|
+
tweetIds.push(tweet.id);
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
|
|
155
|
+
return tweetIds;
|
|
156
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
-
import { searchTweetsViaBirdEffect } from "./bird";
|
|
3
2
|
import type { Database } from "./sqlite";
|
|
4
3
|
import { getNativeDb } from "./db";
|
|
5
4
|
import { runEffectPromise } from "./effect-runtime";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { liveTransportGateway } from "./live-transport-gateway";
|
|
6
|
+
import {
|
|
7
|
+
normalizeCacheTtlMs,
|
|
8
|
+
resolveLiveSyncAccount,
|
|
9
|
+
runCachedLiveSyncEffect,
|
|
10
|
+
} from "./live-sync-engine";
|
|
8
11
|
import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
|
|
9
|
-
import {
|
|
10
|
-
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
11
|
-
import { searchRecentTweetsEffect } from "./xurl";
|
|
12
|
+
import { ingestTweetPayload } from "./tweet-repository";
|
|
12
13
|
|
|
13
14
|
export type TweetSearchMode = "auto" | "bird" | "xurl" | "local";
|
|
14
15
|
|
|
@@ -84,130 +85,19 @@ function normalizeTime(value: string | undefined, optionName: string) {
|
|
|
84
85
|
return date.toISOString();
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function normalizeCacheTtlMs(value: number | undefined) {
|
|
88
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
89
|
-
return DEFAULT_CACHE_TTL_MS;
|
|
90
|
-
}
|
|
91
|
-
return Math.floor(value);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function resolveAccount(db: Database, accountId?: string) {
|
|
95
|
-
const row = accountId
|
|
96
|
-
? (db
|
|
97
|
-
.prepare("select id, handle from accounts where id = ?")
|
|
98
|
-
.get(accountId) as { id: string; handle: string } | undefined)
|
|
99
|
-
: (db
|
|
100
|
-
.prepare(
|
|
101
|
-
`
|
|
102
|
-
select id, handle
|
|
103
|
-
from accounts
|
|
104
|
-
order by is_default desc, created_at asc
|
|
105
|
-
limit 1
|
|
106
|
-
`,
|
|
107
|
-
)
|
|
108
|
-
.get() as { id: string; handle: string } | undefined);
|
|
109
|
-
|
|
110
|
-
if (!row) {
|
|
111
|
-
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
accountId: row.id,
|
|
116
|
-
username: row.handle.replace(/^@/, ""),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
121
|
-
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
122
|
-
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
123
|
-
tweetId,
|
|
124
|
-
text,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
88
|
function mergeTweetSearchIntoLocalStore(
|
|
129
89
|
db: Database,
|
|
130
90
|
accountId: string,
|
|
131
91
|
payload: XurlMentionsResponse,
|
|
132
92
|
source: "bird" | "xurl" | "cache",
|
|
133
93
|
) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
142
|
-
entities_json, media_json, quoted_tweet_id
|
|
143
|
-
) values (?, ?, ?, 'search', ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
144
|
-
on conflict(id) do update set
|
|
145
|
-
account_id = tweets.account_id,
|
|
146
|
-
author_profile_id = excluded.author_profile_id,
|
|
147
|
-
kind = tweets.kind,
|
|
148
|
-
text = excluded.text,
|
|
149
|
-
created_at = excluded.created_at,
|
|
150
|
-
reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
|
|
151
|
-
like_count = excluded.like_count,
|
|
152
|
-
media_count = max(tweets.media_count, excluded.media_count),
|
|
153
|
-
entities_json = excluded.entities_json,
|
|
154
|
-
media_json = case
|
|
155
|
-
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
156
|
-
else tweets.media_json
|
|
157
|
-
end,
|
|
158
|
-
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id),
|
|
159
|
-
bookmarked = tweets.bookmarked,
|
|
160
|
-
liked = tweets.liked
|
|
161
|
-
`,
|
|
162
|
-
);
|
|
163
|
-
const tweetIds: string[] = [];
|
|
164
|
-
|
|
165
|
-
db.transaction(() => {
|
|
166
|
-
const seenAt = new Date().toISOString();
|
|
167
|
-
for (const tweet of payload.data) {
|
|
168
|
-
const author =
|
|
169
|
-
usersById.get(tweet.author_id) ??
|
|
170
|
-
({
|
|
171
|
-
id: tweet.author_id,
|
|
172
|
-
username: `user_${tweet.author_id}`,
|
|
173
|
-
name: `user_${tweet.author_id}`,
|
|
174
|
-
} as const);
|
|
175
|
-
const profile = usersById.has(tweet.author_id)
|
|
176
|
-
? upsertProfileFromXUser(db, author)
|
|
177
|
-
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
178
|
-
const replyToId =
|
|
179
|
-
tweet.referenced_tweets?.find((item) => item.type === "replied_to")
|
|
180
|
-
?.id ?? null;
|
|
181
|
-
const quotedTweetId =
|
|
182
|
-
tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
|
|
183
|
-
null;
|
|
184
|
-
upsertTweet.run(
|
|
185
|
-
tweet.id,
|
|
186
|
-
accountId,
|
|
187
|
-
profile.profile.id,
|
|
188
|
-
tweet.text,
|
|
189
|
-
tweet.created_at,
|
|
190
|
-
replyToId,
|
|
191
|
-
Number(tweet.public_metrics?.like_count ?? 0),
|
|
192
|
-
countTweetMedia(tweet),
|
|
193
|
-
JSON.stringify(tweet.entities ?? {}),
|
|
194
|
-
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
195
|
-
quotedTweetId,
|
|
196
|
-
);
|
|
197
|
-
upsertTweetAccountEdge(db, {
|
|
198
|
-
accountId,
|
|
199
|
-
tweetId: tweet.id,
|
|
200
|
-
kind: "search",
|
|
201
|
-
source,
|
|
202
|
-
seenAt,
|
|
203
|
-
rawJson: JSON.stringify(tweet),
|
|
204
|
-
});
|
|
205
|
-
replaceTweetFts(db, tweet.id, tweet.text);
|
|
206
|
-
tweetIds.push(tweet.id);
|
|
207
|
-
}
|
|
208
|
-
})();
|
|
209
|
-
|
|
210
|
-
return tweetIds;
|
|
94
|
+
return ingestTweetPayload(db, {
|
|
95
|
+
accountId,
|
|
96
|
+
payload,
|
|
97
|
+
kind: "search",
|
|
98
|
+
edgeKind: "search",
|
|
99
|
+
source,
|
|
100
|
+
});
|
|
211
101
|
}
|
|
212
102
|
|
|
213
103
|
function toMentionsResponse(payload: XurlTweetsResponse): XurlMentionsResponse {
|
|
@@ -301,7 +191,7 @@ function fetchBirdSearchEffect({
|
|
|
301
191
|
limit: number;
|
|
302
192
|
maxPages: number;
|
|
303
193
|
}) {
|
|
304
|
-
return
|
|
194
|
+
return liveTransportGateway.bird.searchTweets(query, {
|
|
305
195
|
maxResults: Math.min(limit, XURL_PAGE_SIZE),
|
|
306
196
|
all: maxPages > 1 || limit > XURL_PAGE_SIZE,
|
|
307
197
|
maxPages,
|
|
@@ -331,13 +221,16 @@ function fetchXurlSearchEffect({
|
|
|
331
221
|
limit -
|
|
332
222
|
responses.reduce((total, response) => total + response.data.length, 0);
|
|
333
223
|
if (remaining <= 0) break;
|
|
334
|
-
const response = yield*
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
224
|
+
const response = yield* liveTransportGateway.xurl.searchRecentTweets(
|
|
225
|
+
query,
|
|
226
|
+
{
|
|
227
|
+
maxResults: Math.max(10, Math.min(XURL_PAGE_SIZE, remaining)),
|
|
228
|
+
paginationToken: nextToken,
|
|
229
|
+
startTime: since,
|
|
230
|
+
endTime: until,
|
|
231
|
+
timeoutMs,
|
|
232
|
+
},
|
|
233
|
+
);
|
|
341
234
|
responses.push(toMentionsResponse(response));
|
|
342
235
|
nextToken =
|
|
343
236
|
typeof response.meta?.next_token === "string"
|
|
@@ -367,42 +260,44 @@ function runModeEffect(
|
|
|
367
260
|
return Effect.gen(function* () {
|
|
368
261
|
const db = getNativeDb();
|
|
369
262
|
const key = cacheKey({ ...options, mode });
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
).pipe(
|
|
263
|
+
const fetch =
|
|
264
|
+
mode === "bird"
|
|
265
|
+
? fetchBirdSearchEffect(options).pipe(Effect.mapError(toError))
|
|
266
|
+
: fetchXurlSearchEffect(options);
|
|
267
|
+
const syncResult = yield* runCachedLiveSyncEffect({
|
|
268
|
+
db,
|
|
269
|
+
cacheKey: key,
|
|
270
|
+
refresh: options.refresh,
|
|
271
|
+
cacheTtlMs: options.cacheTtlMs,
|
|
272
|
+
transports: [
|
|
273
|
+
{
|
|
274
|
+
source: mode,
|
|
275
|
+
fetch: fetch.pipe(
|
|
384
276
|
Effect.map((response) => limitResponse(response, options.limit)),
|
|
385
|
-
)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
277
|
+
),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
persistLive: (writeDb, payload, source) =>
|
|
281
|
+
mergeTweetSearchIntoLocalStore(
|
|
282
|
+
writeDb,
|
|
283
|
+
options.accountId,
|
|
284
|
+
payload,
|
|
285
|
+
source,
|
|
286
|
+
),
|
|
287
|
+
persistCached: (writeDb, payload) =>
|
|
288
|
+
mergeTweetSearchIntoLocalStore(
|
|
289
|
+
writeDb,
|
|
290
|
+
options.accountId,
|
|
291
|
+
payload,
|
|
292
|
+
"cache",
|
|
293
|
+
),
|
|
294
|
+
});
|
|
295
|
+
const { payload, source } = syncResult;
|
|
296
|
+
const tweetIds = syncResult.persisted ?? [];
|
|
399
297
|
|
|
400
298
|
return {
|
|
401
299
|
ok: true,
|
|
402
|
-
source
|
|
403
|
-
!options.refresh && cached && ageMs <= options.cacheTtlMs
|
|
404
|
-
? "cache"
|
|
405
|
-
: mode,
|
|
300
|
+
source,
|
|
406
301
|
accountId: options.accountId,
|
|
407
302
|
query: options.query,
|
|
408
303
|
count: tweetIds.length,
|
|
@@ -478,9 +373,11 @@ export function syncTweetSearchEffect({
|
|
|
478
373
|
const normalizedUntil = yield* trySync(() =>
|
|
479
374
|
normalizeTime(until, "--until"),
|
|
480
375
|
);
|
|
481
|
-
const ttlMs = normalizeCacheTtlMs(cacheTtlMs);
|
|
376
|
+
const ttlMs = normalizeCacheTtlMs(cacheTtlMs, DEFAULT_CACHE_TTL_MS);
|
|
482
377
|
const db = getNativeDb();
|
|
483
|
-
const resolvedAccount = yield* trySync(() =>
|
|
378
|
+
const resolvedAccount = yield* trySync(() =>
|
|
379
|
+
resolveLiveSyncAccount(db, account),
|
|
380
|
+
);
|
|
484
381
|
const accountId = resolvedAccount.accountId;
|
|
485
382
|
if (mode === "local") {
|
|
486
383
|
return {
|
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
|
|