birdclaw 0.1.0 → 0.2.0
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 +32 -0
- package/README.md +117 -10
- package/bin/birdclaw.mjs +3 -1
- package/package.json +4 -3
- package/src/cli.ts +237 -4
- package/src/components/AppNav.tsx +2 -0
- package/src/components/SavedTimelineView.tsx +115 -0
- package/src/components/TimelineCard.tsx +26 -17
- package/src/lib/archive-import.ts +193 -16
- package/src/lib/backup.ts +1617 -0
- package/src/lib/bird.ts +163 -24
- package/src/lib/blocks-write.ts +2 -2
- package/src/lib/blocks.ts +2 -7
- package/src/lib/bookmark-sync-job.ts +446 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/db.ts +82 -4
- package/src/lib/mentions-live.ts +2 -5
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/mutes-write.ts +2 -2
- package/src/lib/queries.ts +70 -1
- package/src/lib/seed.ts +4 -2
- package/src/lib/theme-transition.ts +6 -10
- package/src/lib/theme.tsx +13 -5
- package/src/lib/timeline-collections-live.ts +406 -0
- package/src/lib/types.ts +9 -1
- package/src/lib/xurl.ts +90 -4
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +6 -1
- package/src/routes/api/action.tsx +14 -4
- package/src/routes/api/inbox.tsx +3 -1
- package/src/routes/api/query.tsx +20 -2
- package/src/routes/api/status.tsx +6 -3
- package/src/routes/bookmarks.tsx +18 -0
- package/src/routes/likes.tsx +18 -0
- package/tsconfig.json +0 -1
package/src/lib/queries.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
QueryEnvelope,
|
|
13
13
|
QueryResponse,
|
|
14
14
|
ReplyFilter,
|
|
15
|
+
TimelineQualityFilter,
|
|
15
16
|
TimelineItem,
|
|
16
17
|
TimelineQuery,
|
|
17
18
|
TweetEntities,
|
|
@@ -124,6 +125,36 @@ function buildReplyClause(replyFilter: ReplyFilter) {
|
|
|
124
125
|
return "";
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
|
|
129
|
+
if (qualityFilter === "all") {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return `
|
|
134
|
+
and not (
|
|
135
|
+
t.text like 'RT @%'
|
|
136
|
+
or (
|
|
137
|
+
t.like_count < 50
|
|
138
|
+
and (
|
|
139
|
+
(
|
|
140
|
+
length(trim(replace(t.text, 'https://t.co/', ''))) < 16
|
|
141
|
+
and t.media_count = 0
|
|
142
|
+
)
|
|
143
|
+
or (
|
|
144
|
+
t.text like '@%'
|
|
145
|
+
and length(trim(t.text)) < 60
|
|
146
|
+
)
|
|
147
|
+
or (
|
|
148
|
+
t.text glob '*https://t.co/*'
|
|
149
|
+
and t.media_count = 0
|
|
150
|
+
and length(trim(replace(t.text, 'https://t.co/', ''))) < 45
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
127
158
|
export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
128
159
|
const db = getDb();
|
|
129
160
|
const counts = await Promise.all([
|
|
@@ -168,6 +199,7 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
|
168
199
|
id: row.id,
|
|
169
200
|
name: row.name,
|
|
170
201
|
handle: row.handle,
|
|
202
|
+
externalUserId: row.external_user_id,
|
|
171
203
|
transport: row.transport,
|
|
172
204
|
isDefault: row.is_default,
|
|
173
205
|
createdAt: row.created_at,
|
|
@@ -182,6 +214,12 @@ export function listTimelineItems({
|
|
|
182
214
|
account,
|
|
183
215
|
search,
|
|
184
216
|
replyFilter = "all",
|
|
217
|
+
since,
|
|
218
|
+
until,
|
|
219
|
+
includeReplies = true,
|
|
220
|
+
qualityFilter = "all",
|
|
221
|
+
likedOnly = false,
|
|
222
|
+
bookmarkedOnly = false,
|
|
185
223
|
limit = 18,
|
|
186
224
|
}: TimelineQuery): TimelineItem[] {
|
|
187
225
|
const db = getNativeDb();
|
|
@@ -190,6 +228,11 @@ export function listTimelineItems({
|
|
|
190
228
|
let join = "";
|
|
191
229
|
let where = "where t.kind = ?";
|
|
192
230
|
|
|
231
|
+
if (likedOnly || bookmarkedOnly) {
|
|
232
|
+
params.length = 0;
|
|
233
|
+
where = "where 1 = 1";
|
|
234
|
+
}
|
|
235
|
+
|
|
193
236
|
if (account && account !== "all") {
|
|
194
237
|
where += " and a.id = ?";
|
|
195
238
|
params.push(account);
|
|
@@ -199,6 +242,21 @@ export function listTimelineItems({
|
|
|
199
242
|
"is_replied",
|
|
200
243
|
"t.is_replied",
|
|
201
244
|
);
|
|
245
|
+
where += buildTimelineQualityClause(qualityFilter);
|
|
246
|
+
|
|
247
|
+
if (!includeReplies) {
|
|
248
|
+
where += " and t.text not like '@%'";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (since?.trim()) {
|
|
252
|
+
where += " and t.created_at >= ?";
|
|
253
|
+
params.push(since.trim());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (until?.trim()) {
|
|
257
|
+
where += " and t.created_at < ?";
|
|
258
|
+
params.push(until.trim());
|
|
259
|
+
}
|
|
202
260
|
|
|
203
261
|
if (search?.trim()) {
|
|
204
262
|
join += " join tweets_fts fts on fts.tweet_id = t.id ";
|
|
@@ -206,6 +264,14 @@ export function listTimelineItems({
|
|
|
206
264
|
params.push(search.trim());
|
|
207
265
|
}
|
|
208
266
|
|
|
267
|
+
if (likedOnly) {
|
|
268
|
+
where += " and t.liked = 1";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (bookmarkedOnly) {
|
|
272
|
+
where += " and t.bookmarked = 1";
|
|
273
|
+
}
|
|
274
|
+
|
|
209
275
|
params.push(limit);
|
|
210
276
|
|
|
211
277
|
const rows = db
|
|
@@ -580,11 +646,14 @@ export function queryResource(
|
|
|
580
646
|
};
|
|
581
647
|
}
|
|
582
648
|
|
|
649
|
+
const { resource: _filterResource, ...timelineFilters } =
|
|
650
|
+
filters as TimelineQuery;
|
|
651
|
+
|
|
583
652
|
return {
|
|
584
653
|
resource,
|
|
585
654
|
items: listTimelineItems({
|
|
586
655
|
resource,
|
|
587
|
-
...
|
|
656
|
+
...timelineFilters,
|
|
588
657
|
}),
|
|
589
658
|
};
|
|
590
659
|
}
|
package/src/lib/seed.ts
CHANGED
|
@@ -34,8 +34,8 @@ export function seedDemoData(db: Database.Database) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const insertAccount = db.prepare(`
|
|
37
|
-
insert into accounts (id, name, handle, transport, is_default, created_at)
|
|
38
|
-
values (@id, @name, @handle, @transport, @isDefault, @createdAt)
|
|
37
|
+
insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
|
|
38
|
+
values (@id, @name, @handle, @externalUserId, @transport, @isDefault, @createdAt)
|
|
39
39
|
`);
|
|
40
40
|
|
|
41
41
|
const insertProfile = db.prepare(`
|
|
@@ -81,6 +81,7 @@ export function seedDemoData(db: Database.Database) {
|
|
|
81
81
|
id: "acct_primary",
|
|
82
82
|
name: "Peter",
|
|
83
83
|
handle: "@steipete",
|
|
84
|
+
externalUserId: "25401953",
|
|
84
85
|
transport: "xurl",
|
|
85
86
|
isDefault: 1,
|
|
86
87
|
createdAt: now.toISOString(),
|
|
@@ -89,6 +90,7 @@ export function seedDemoData(db: Database.Database) {
|
|
|
89
90
|
id: "acct_studio",
|
|
90
91
|
name: "Studio",
|
|
91
92
|
handle: "@birdclaw_lab",
|
|
93
|
+
externalUserId: null,
|
|
92
94
|
transport: "xurl",
|
|
93
95
|
isDefault: 0,
|
|
94
96
|
createdAt: now.toISOString(),
|
|
@@ -7,12 +7,6 @@ export interface ThemeTransitionContext {
|
|
|
7
7
|
pointerClientY?: number;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
interface DocumentWithViewTransition extends Document {
|
|
11
|
-
startViewTransition?: (callback: () => void) => {
|
|
12
|
-
finished: Promise<void>;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
10
|
const clamp01 = (value: number) => {
|
|
17
11
|
if (Number.isNaN(value)) return 0.5;
|
|
18
12
|
if (value <= 0) return 0;
|
|
@@ -58,10 +52,12 @@ export function startThemeTransition({
|
|
|
58
52
|
}
|
|
59
53
|
|
|
60
54
|
const root = document.documentElement;
|
|
61
|
-
const
|
|
55
|
+
const startViewTransition =
|
|
56
|
+
typeof document.startViewTransition === "function"
|
|
57
|
+
? document.startViewTransition.bind(document)
|
|
58
|
+
: undefined;
|
|
62
59
|
const canUseViewTransition =
|
|
63
|
-
Boolean(
|
|
64
|
-
!hasReducedMotionPreference();
|
|
60
|
+
Boolean(startViewTransition) && !hasReducedMotionPreference();
|
|
65
61
|
|
|
66
62
|
const applyTheme = () => {
|
|
67
63
|
flushSync(() => {
|
|
@@ -98,7 +94,7 @@ export function startThemeTransition({
|
|
|
98
94
|
root.classList.add("theme-transition");
|
|
99
95
|
|
|
100
96
|
try {
|
|
101
|
-
const transition =
|
|
97
|
+
const transition = startViewTransition?.(() => {
|
|
102
98
|
applyTheme();
|
|
103
99
|
});
|
|
104
100
|
|
package/src/lib/theme.tsx
CHANGED
|
@@ -111,7 +111,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
const media = window.matchMedia(
|
|
114
|
+
const media = window.matchMedia(
|
|
115
|
+
"(prefers-color-scheme: dark)",
|
|
116
|
+
) as unknown as {
|
|
117
|
+
matches: boolean;
|
|
118
|
+
addEventListener?: (type: "change", listener: () => void) => void;
|
|
119
|
+
removeEventListener?: (type: "change", listener: () => void) => void;
|
|
120
|
+
addListener?: (listener: () => void) => void;
|
|
121
|
+
removeListener?: (listener: () => void) => void;
|
|
122
|
+
};
|
|
115
123
|
const onChange = () => {
|
|
116
124
|
const nextResolvedTheme = media.matches ? "dark" : "light";
|
|
117
125
|
setResolvedTheme(nextResolvedTheme);
|
|
@@ -119,16 +127,16 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
|
119
127
|
document.documentElement.style.colorScheme = nextResolvedTheme;
|
|
120
128
|
};
|
|
121
129
|
|
|
122
|
-
if (
|
|
130
|
+
if (typeof media.addEventListener === "function") {
|
|
123
131
|
media.addEventListener("change", onChange);
|
|
124
132
|
} else {
|
|
125
|
-
media.addListener(onChange);
|
|
133
|
+
media.addListener?.(onChange);
|
|
126
134
|
}
|
|
127
135
|
return () => {
|
|
128
|
-
if (
|
|
136
|
+
if (typeof media.removeEventListener === "function") {
|
|
129
137
|
media.removeEventListener("change", onChange);
|
|
130
138
|
} else {
|
|
131
|
-
media.removeListener(onChange);
|
|
139
|
+
media.removeListener?.(onChange);
|
|
132
140
|
}
|
|
133
141
|
};
|
|
134
142
|
}, [isReady, theme]);
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
|
|
3
|
+
import { getNativeDb } from "./db";
|
|
4
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
5
|
+
import type {
|
|
6
|
+
XurlMentionData,
|
|
7
|
+
XurlMentionsResponse,
|
|
8
|
+
XurlMentionUser,
|
|
9
|
+
} from "./types";
|
|
10
|
+
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
11
|
+
import {
|
|
12
|
+
listBookmarkedTweetsViaXurl,
|
|
13
|
+
listLikedTweetsViaXurl,
|
|
14
|
+
lookupUsersByHandles,
|
|
15
|
+
} from "./xurl";
|
|
16
|
+
|
|
17
|
+
export type TimelineCollectionKind = "likes" | "bookmarks";
|
|
18
|
+
export type TimelineCollectionMode = "auto" | "xurl" | "bird";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
|
|
21
|
+
const MIN_XURL_LIMIT = 5;
|
|
22
|
+
const MAX_XURL_LIMIT = 100;
|
|
23
|
+
|
|
24
|
+
function parseCacheTtlMs(value?: number) {
|
|
25
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
26
|
+
return DEFAULT_COLLECTION_CACHE_TTL_MS;
|
|
27
|
+
}
|
|
28
|
+
return Math.floor(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseMaxPages(value?: number) {
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
36
|
+
throw new Error("--max-pages must be at least 1");
|
|
37
|
+
}
|
|
38
|
+
return Math.floor(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertLimit(limit: number) {
|
|
42
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
43
|
+
throw new Error("--limit must be at least 1");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertXurlLimit(limit: number) {
|
|
48
|
+
if (limit < MIN_XURL_LIMIT || limit > MAX_XURL_LIMIT) {
|
|
49
|
+
throw new Error("xurl mode requires --limit between 5 and 100");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveAccount(db: Database.Database, accountId?: string) {
|
|
54
|
+
const row = accountId
|
|
55
|
+
? (db
|
|
56
|
+
.prepare(
|
|
57
|
+
"select id, handle, external_user_id from accounts where id = ?",
|
|
58
|
+
)
|
|
59
|
+
.get(accountId) as
|
|
60
|
+
| { id: string; handle: string; external_user_id: string | null }
|
|
61
|
+
| undefined)
|
|
62
|
+
: (db
|
|
63
|
+
.prepare(
|
|
64
|
+
`
|
|
65
|
+
select id, handle, external_user_id
|
|
66
|
+
from accounts
|
|
67
|
+
order by is_default desc, created_at asc
|
|
68
|
+
limit 1
|
|
69
|
+
`,
|
|
70
|
+
)
|
|
71
|
+
.get() as
|
|
72
|
+
| { id: string; handle: string; external_user_id: string | null }
|
|
73
|
+
| undefined);
|
|
74
|
+
|
|
75
|
+
if (!row) {
|
|
76
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
accountId: row.id,
|
|
81
|
+
username: row.handle.replace(/^@/, ""),
|
|
82
|
+
externalUserId:
|
|
83
|
+
typeof row.external_user_id === "string" &&
|
|
84
|
+
row.external_user_id.length > 0
|
|
85
|
+
? row.external_user_id
|
|
86
|
+
: undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getMediaCount(tweet: XurlMentionData) {
|
|
91
|
+
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
92
|
+
return urls.filter(
|
|
93
|
+
(url) =>
|
|
94
|
+
url &&
|
|
95
|
+
typeof url === "object" &&
|
|
96
|
+
typeof (url as Record<string, unknown>).media_key === "string",
|
|
97
|
+
).length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
|
|
101
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
102
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
103
|
+
tweetId,
|
|
104
|
+
text,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
109
|
+
const tweets: XurlMentionData[] = [];
|
|
110
|
+
const seenTweetIds = new Set<string>();
|
|
111
|
+
const users: XurlMentionUser[] = [];
|
|
112
|
+
const seenUserIds = new Set<string>();
|
|
113
|
+
|
|
114
|
+
for (const page of pages) {
|
|
115
|
+
for (const tweet of page.data) {
|
|
116
|
+
if (seenTweetIds.has(tweet.id)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
seenTweetIds.add(tweet.id);
|
|
120
|
+
tweets.push(tweet);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const user of page.includes?.users ?? []) {
|
|
124
|
+
if (seenUserIds.has(user.id)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
seenUserIds.add(user.id);
|
|
128
|
+
users.push(user);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const lastPage = pages.at(-1);
|
|
133
|
+
return {
|
|
134
|
+
data: tweets,
|
|
135
|
+
includes: users.length > 0 ? { users } : undefined,
|
|
136
|
+
meta: {
|
|
137
|
+
result_count: tweets.length,
|
|
138
|
+
page_count: pages.length,
|
|
139
|
+
next_token: lastPage?.meta?.next_token ?? null,
|
|
140
|
+
...(tweets[0] ? { newest_id: tweets[0].id } : {}),
|
|
141
|
+
...(tweets.at(-1) ? { oldest_id: tweets.at(-1)?.id } : {}),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mergeTimelineCollectionIntoLocalStore(
|
|
147
|
+
db: Database.Database,
|
|
148
|
+
accountId: string,
|
|
149
|
+
kind: TimelineCollectionKind,
|
|
150
|
+
payload: XurlMentionsResponse,
|
|
151
|
+
source: "xurl" | "bird",
|
|
152
|
+
) {
|
|
153
|
+
const usersById = new Map(
|
|
154
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
155
|
+
);
|
|
156
|
+
const tweetKind = kind === "likes" ? "like" : "bookmark";
|
|
157
|
+
const liked = kind === "likes" ? 1 : 0;
|
|
158
|
+
const bookmarked = kind === "bookmarks" ? 1 : 0;
|
|
159
|
+
const upsertTweet = db.prepare(
|
|
160
|
+
`
|
|
161
|
+
insert into tweets (
|
|
162
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
163
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
164
|
+
entities_json, media_json, quoted_tweet_id
|
|
165
|
+
) values (?, ?, ?, ?, ?, ?, 0, null, ?, ?, ?, ?, ?, '[]', null)
|
|
166
|
+
on conflict(id) do update set
|
|
167
|
+
account_id = excluded.account_id,
|
|
168
|
+
author_profile_id = excluded.author_profile_id,
|
|
169
|
+
kind = case
|
|
170
|
+
when tweets.kind in ('home', 'mention') then tweets.kind
|
|
171
|
+
else excluded.kind
|
|
172
|
+
end,
|
|
173
|
+
text = excluded.text,
|
|
174
|
+
created_at = excluded.created_at,
|
|
175
|
+
like_count = excluded.like_count,
|
|
176
|
+
media_count = excluded.media_count,
|
|
177
|
+
entities_json = excluded.entities_json,
|
|
178
|
+
media_json = excluded.media_json,
|
|
179
|
+
bookmarked = max(tweets.bookmarked, excluded.bookmarked),
|
|
180
|
+
liked = max(tweets.liked, excluded.liked)
|
|
181
|
+
`,
|
|
182
|
+
);
|
|
183
|
+
const upsertCollection = db.prepare(`
|
|
184
|
+
insert into tweet_collections (
|
|
185
|
+
account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
|
|
186
|
+
) values (?, ?, ?, null, ?, ?, ?)
|
|
187
|
+
on conflict(account_id, tweet_id, kind) do update set
|
|
188
|
+
source = excluded.source,
|
|
189
|
+
raw_json = excluded.raw_json,
|
|
190
|
+
updated_at = excluded.updated_at
|
|
191
|
+
`);
|
|
192
|
+
|
|
193
|
+
db.transaction(() => {
|
|
194
|
+
const updatedAt = new Date().toISOString();
|
|
195
|
+
for (const tweet of payload.data) {
|
|
196
|
+
const author =
|
|
197
|
+
usersById.get(tweet.author_id) ??
|
|
198
|
+
({
|
|
199
|
+
id: tweet.author_id,
|
|
200
|
+
username: `user_${tweet.author_id}`,
|
|
201
|
+
name: `user_${tweet.author_id}`,
|
|
202
|
+
} as const);
|
|
203
|
+
const profile = usersById.has(tweet.author_id)
|
|
204
|
+
? upsertProfileFromXUser(db, author)
|
|
205
|
+
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
206
|
+
upsertTweet.run(
|
|
207
|
+
tweet.id,
|
|
208
|
+
accountId,
|
|
209
|
+
profile.profile.id,
|
|
210
|
+
tweetKind,
|
|
211
|
+
tweet.text,
|
|
212
|
+
tweet.created_at,
|
|
213
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
214
|
+
getMediaCount(tweet),
|
|
215
|
+
bookmarked,
|
|
216
|
+
liked,
|
|
217
|
+
JSON.stringify(tweet.entities ?? {}),
|
|
218
|
+
);
|
|
219
|
+
upsertCollection.run(
|
|
220
|
+
accountId,
|
|
221
|
+
tweet.id,
|
|
222
|
+
kind,
|
|
223
|
+
source,
|
|
224
|
+
JSON.stringify(tweet),
|
|
225
|
+
updatedAt,
|
|
226
|
+
);
|
|
227
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function fetchXurlCollection({
|
|
233
|
+
kind,
|
|
234
|
+
username,
|
|
235
|
+
userId,
|
|
236
|
+
limit,
|
|
237
|
+
all,
|
|
238
|
+
maxPages,
|
|
239
|
+
}: {
|
|
240
|
+
kind: TimelineCollectionKind;
|
|
241
|
+
username: string;
|
|
242
|
+
userId?: string;
|
|
243
|
+
limit: number;
|
|
244
|
+
all: boolean;
|
|
245
|
+
maxPages: number | null;
|
|
246
|
+
}) {
|
|
247
|
+
let resolvedUserId = userId;
|
|
248
|
+
if (!resolvedUserId) {
|
|
249
|
+
const [accountUser] = await lookupUsersByHandles([username]);
|
|
250
|
+
if (!accountUser?.id) {
|
|
251
|
+
throw new Error(`Could not resolve X user id for @${username}`);
|
|
252
|
+
}
|
|
253
|
+
resolvedUserId = String(accountUser.id);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const pages: XurlMentionsResponse[] = [];
|
|
257
|
+
let nextToken: string | undefined;
|
|
258
|
+
let pageCount = 0;
|
|
259
|
+
do {
|
|
260
|
+
const payload =
|
|
261
|
+
kind === "likes"
|
|
262
|
+
? await listLikedTweetsViaXurl({
|
|
263
|
+
maxResults: limit,
|
|
264
|
+
username,
|
|
265
|
+
userId: resolvedUserId,
|
|
266
|
+
paginationToken: nextToken,
|
|
267
|
+
})
|
|
268
|
+
: await listBookmarkedTweetsViaXurl({
|
|
269
|
+
maxResults: limit,
|
|
270
|
+
username,
|
|
271
|
+
userId: resolvedUserId,
|
|
272
|
+
paginationToken: nextToken,
|
|
273
|
+
});
|
|
274
|
+
pages.push(payload);
|
|
275
|
+
nextToken =
|
|
276
|
+
typeof payload.meta?.next_token === "string"
|
|
277
|
+
? payload.meta.next_token
|
|
278
|
+
: undefined;
|
|
279
|
+
pageCount += 1;
|
|
280
|
+
} while (all && nextToken && (maxPages === null || pageCount < maxPages));
|
|
281
|
+
|
|
282
|
+
return mergePayloads(pages);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function fetchBirdCollection({
|
|
286
|
+
kind,
|
|
287
|
+
limit,
|
|
288
|
+
all,
|
|
289
|
+
maxPages,
|
|
290
|
+
}: {
|
|
291
|
+
kind: TimelineCollectionKind;
|
|
292
|
+
limit: number;
|
|
293
|
+
all: boolean;
|
|
294
|
+
maxPages: number | null;
|
|
295
|
+
}) {
|
|
296
|
+
return kind === "likes"
|
|
297
|
+
? listLikedTweetsViaBird({
|
|
298
|
+
maxResults: limit,
|
|
299
|
+
all,
|
|
300
|
+
maxPages: maxPages ?? undefined,
|
|
301
|
+
})
|
|
302
|
+
: listBookmarkedTweetsViaBird({
|
|
303
|
+
maxResults: limit,
|
|
304
|
+
all,
|
|
305
|
+
maxPages: maxPages ?? undefined,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function syncTimelineCollection({
|
|
310
|
+
kind,
|
|
311
|
+
account,
|
|
312
|
+
mode = "auto",
|
|
313
|
+
limit = 20,
|
|
314
|
+
all = false,
|
|
315
|
+
maxPages,
|
|
316
|
+
refresh = false,
|
|
317
|
+
cacheTtlMs,
|
|
318
|
+
}: {
|
|
319
|
+
kind: TimelineCollectionKind;
|
|
320
|
+
account?: string;
|
|
321
|
+
mode?: TimelineCollectionMode;
|
|
322
|
+
limit?: number;
|
|
323
|
+
all?: boolean;
|
|
324
|
+
maxPages?: number;
|
|
325
|
+
refresh?: boolean;
|
|
326
|
+
cacheTtlMs?: number;
|
|
327
|
+
}) {
|
|
328
|
+
assertLimit(limit);
|
|
329
|
+
const parsedMaxPages = parseMaxPages(maxPages);
|
|
330
|
+
if (mode === "xurl" || mode === "auto") {
|
|
331
|
+
assertXurlLimit(limit);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const db = getNativeDb();
|
|
335
|
+
const resolvedAccount = resolveAccount(db, account);
|
|
336
|
+
const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${parsedMaxPages === null ? "all-pages" : String(parsedMaxPages)}`;
|
|
337
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
338
|
+
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
339
|
+
const cacheAgeMs = cached
|
|
340
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
341
|
+
: Number.POSITIVE_INFINITY;
|
|
342
|
+
|
|
343
|
+
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
source: "cache",
|
|
347
|
+
kind,
|
|
348
|
+
accountId: resolvedAccount.accountId,
|
|
349
|
+
count: cached.value.data.length,
|
|
350
|
+
payload: cached.value,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let source: "xurl" | "bird";
|
|
355
|
+
let payload: XurlMentionsResponse;
|
|
356
|
+
if (mode === "bird") {
|
|
357
|
+
payload = await fetchBirdCollection({
|
|
358
|
+
kind,
|
|
359
|
+
limit,
|
|
360
|
+
all,
|
|
361
|
+
maxPages: parsedMaxPages,
|
|
362
|
+
});
|
|
363
|
+
source = "bird";
|
|
364
|
+
} else {
|
|
365
|
+
try {
|
|
366
|
+
payload = await fetchXurlCollection({
|
|
367
|
+
kind,
|
|
368
|
+
username: resolvedAccount.username,
|
|
369
|
+
userId: resolvedAccount.externalUserId,
|
|
370
|
+
limit,
|
|
371
|
+
all,
|
|
372
|
+
maxPages: parsedMaxPages,
|
|
373
|
+
});
|
|
374
|
+
source = "xurl";
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (mode === "xurl") {
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
payload = await fetchBirdCollection({
|
|
380
|
+
kind,
|
|
381
|
+
limit,
|
|
382
|
+
all,
|
|
383
|
+
maxPages: parsedMaxPages,
|
|
384
|
+
});
|
|
385
|
+
source = "bird";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
mergeTimelineCollectionIntoLocalStore(
|
|
390
|
+
db,
|
|
391
|
+
resolvedAccount.accountId,
|
|
392
|
+
kind,
|
|
393
|
+
payload,
|
|
394
|
+
source,
|
|
395
|
+
);
|
|
396
|
+
writeSyncCache(cacheKey, payload, db);
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
source,
|
|
401
|
+
kind,
|
|
402
|
+
accountId: resolvedAccount.accountId,
|
|
403
|
+
count: payload.data.length,
|
|
404
|
+
payload,
|
|
405
|
+
};
|
|
406
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -2,11 +2,13 @@ export type ResourceKind = "home" | "mentions" | "dms";
|
|
|
2
2
|
export type InboxKind = "mixed" | "mentions" | "dms";
|
|
3
3
|
|
|
4
4
|
export type ReplyFilter = "all" | "replied" | "unreplied";
|
|
5
|
+
export type TimelineQualityFilter = "all" | "summary";
|
|
5
6
|
|
|
6
7
|
export interface AccountRecord {
|
|
7
8
|
id: string;
|
|
8
9
|
name: string;
|
|
9
10
|
handle: string;
|
|
11
|
+
externalUserId?: string | null;
|
|
10
12
|
transport: string;
|
|
11
13
|
isDefault: number;
|
|
12
14
|
createdAt: string;
|
|
@@ -94,7 +96,7 @@ export interface TimelineItem {
|
|
|
94
96
|
id: string;
|
|
95
97
|
accountId: string;
|
|
96
98
|
accountHandle: string;
|
|
97
|
-
kind:
|
|
99
|
+
kind: "home" | "mention" | "like" | "bookmark";
|
|
98
100
|
text: string;
|
|
99
101
|
createdAt: string;
|
|
100
102
|
isReplied: boolean;
|
|
@@ -139,6 +141,12 @@ export interface TimelineQuery {
|
|
|
139
141
|
account?: string;
|
|
140
142
|
search?: string;
|
|
141
143
|
replyFilter?: ReplyFilter;
|
|
144
|
+
since?: string;
|
|
145
|
+
until?: string;
|
|
146
|
+
includeReplies?: boolean;
|
|
147
|
+
qualityFilter?: TimelineQualityFilter;
|
|
148
|
+
likedOnly?: boolean;
|
|
149
|
+
bookmarkedOnly?: boolean;
|
|
142
150
|
limit?: number;
|
|
143
151
|
}
|
|
144
152
|
|