birdclaw 0.1.1 → 0.2.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 -1
- package/README.md +122 -16
- package/package.json +4 -3
- package/src/cli.ts +232 -7
- package/src/components/AppNav.tsx +3 -1
- 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 +3 -6
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/mutes-write.ts +2 -2
- package/src/lib/openai.ts +1 -1
- package/src/lib/profile-replies.ts +1 -1
- package/src/lib/queries.ts +20 -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 +4 -1
- package/src/lib/xurl.ts +92 -6
- 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 +5 -1
- package/src/routes/api/status.tsx +6 -3
- package/src/routes/blocks.tsx +1 -1
- package/src/routes/bookmarks.tsx +18 -0
- package/src/routes/likes.tsx +18 -0
- package/tsconfig.json +0 -1
|
@@ -16,7 +16,7 @@ export async function inspectProfileReplies(
|
|
|
16
16
|
): Promise<ProfileRepliesResponse> {
|
|
17
17
|
const resolved = await resolveProfile(query);
|
|
18
18
|
if (!resolved.externalUserId) {
|
|
19
|
-
throw new Error(`Profile has no external
|
|
19
|
+
throw new Error(`Profile has no external Twitter user id: ${query}`);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const timeline = await listUserTweets(resolved.externalUserId, {
|
package/src/lib/queries.ts
CHANGED
|
@@ -199,6 +199,7 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
|
199
199
|
id: row.id,
|
|
200
200
|
name: row.name,
|
|
201
201
|
handle: row.handle,
|
|
202
|
+
externalUserId: row.external_user_id,
|
|
202
203
|
transport: row.transport,
|
|
203
204
|
isDefault: row.is_default,
|
|
204
205
|
createdAt: row.created_at,
|
|
@@ -217,6 +218,8 @@ export function listTimelineItems({
|
|
|
217
218
|
until,
|
|
218
219
|
includeReplies = true,
|
|
219
220
|
qualityFilter = "all",
|
|
221
|
+
likedOnly = false,
|
|
222
|
+
bookmarkedOnly = false,
|
|
220
223
|
limit = 18,
|
|
221
224
|
}: TimelineQuery): TimelineItem[] {
|
|
222
225
|
const db = getNativeDb();
|
|
@@ -225,6 +228,11 @@ export function listTimelineItems({
|
|
|
225
228
|
let join = "";
|
|
226
229
|
let where = "where t.kind = ?";
|
|
227
230
|
|
|
231
|
+
if (likedOnly || bookmarkedOnly) {
|
|
232
|
+
params.length = 0;
|
|
233
|
+
where = "where 1 = 1";
|
|
234
|
+
}
|
|
235
|
+
|
|
228
236
|
if (account && account !== "all") {
|
|
229
237
|
where += " and a.id = ?";
|
|
230
238
|
params.push(account);
|
|
@@ -256,6 +264,14 @@ export function listTimelineItems({
|
|
|
256
264
|
params.push(search.trim());
|
|
257
265
|
}
|
|
258
266
|
|
|
267
|
+
if (likedOnly) {
|
|
268
|
+
where += " and t.liked = 1";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (bookmarkedOnly) {
|
|
272
|
+
where += " and t.bookmarked = 1";
|
|
273
|
+
}
|
|
274
|
+
|
|
259
275
|
params.push(limit);
|
|
260
276
|
|
|
261
277
|
const rows = db
|
|
@@ -630,11 +646,14 @@ export function queryResource(
|
|
|
630
646
|
};
|
|
631
647
|
}
|
|
632
648
|
|
|
649
|
+
const { resource: _filterResource, ...timelineFilters } =
|
|
650
|
+
filters as TimelineQuery;
|
|
651
|
+
|
|
633
652
|
return {
|
|
634
653
|
resource,
|
|
635
654
|
items: listTimelineItems({
|
|
636
655
|
resource,
|
|
637
|
-
...
|
|
656
|
+
...timelineFilters,
|
|
638
657
|
}),
|
|
639
658
|
};
|
|
640
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 Twitter 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
|
@@ -8,6 +8,7 @@ export interface AccountRecord {
|
|
|
8
8
|
id: string;
|
|
9
9
|
name: string;
|
|
10
10
|
handle: string;
|
|
11
|
+
externalUserId?: string | null;
|
|
11
12
|
transport: string;
|
|
12
13
|
isDefault: number;
|
|
13
14
|
createdAt: string;
|
|
@@ -95,7 +96,7 @@ export interface TimelineItem {
|
|
|
95
96
|
id: string;
|
|
96
97
|
accountId: string;
|
|
97
98
|
accountHandle: string;
|
|
98
|
-
kind:
|
|
99
|
+
kind: "home" | "mention" | "like" | "bookmark";
|
|
99
100
|
text: string;
|
|
100
101
|
createdAt: string;
|
|
101
102
|
isReplied: boolean;
|
|
@@ -144,6 +145,8 @@ export interface TimelineQuery {
|
|
|
144
145
|
until?: string;
|
|
145
146
|
includeReplies?: boolean;
|
|
146
147
|
qualityFilter?: TimelineQualityFilter;
|
|
148
|
+
likedOnly?: boolean;
|
|
149
|
+
bookmarkedOnly?: boolean;
|
|
147
150
|
limit?: number;
|
|
148
151
|
}
|
|
149
152
|
|