···
3
3
COOKIE_SECRET=
4
4
5
5
# Set to your tunnel URL to use a confidential client in dev
6
6
-
# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com
6
6
+
OAUTH_PUBLIC_URL=
···
50
50
},
51
51
"dependencies": {
52
52
"@foxui/core": "^0.5.2",
53
53
-
"@foxui/social": "^0.5.4",
53
53
+
"@foxui/social": "^0.6.0",
54
54
"dexie": "^4.3.0",
55
55
"flexsearch": "^0.8.212",
56
56
"valibot": "^1.2.0",
···
12
12
specifier: ^0.5.2
13
13
version: 0.5.2(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(tailwindcss@4.2.1)
14
14
'@foxui/social':
15
15
-
specifier: ^0.5.4
16
16
-
version: 0.5.4(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(tailwindcss@4.2.1)
15
15
+
specifier: ^0.6.0
16
16
+
version: 0.6.0(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(tailwindcss@4.2.1)
17
17
dexie:
18
18
specifier: ^4.3.0
19
19
version: 4.3.0
···
432
432
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
433
433
434
434
'@floating-ui/core@1.7.4':
435
435
-
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
435
435
+
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==, tarball: https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz}
436
436
437
437
'@floating-ui/dom@1.7.5':
438
438
-
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
438
438
+
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==, tarball: https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz}
439
439
440
440
'@floating-ui/utils@0.2.10':
441
441
-
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
441
441
+
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz}
442
442
443
443
'@foxui/core@0.5.2':
444
444
resolution: {integrity: sha512-SwzKizuPCpiD47YfDfZ4Ut0KX+WlmidbBTaNafQaSYpQA45D5avRksgbJWsUgIaLx2oyH8PwBq7r/KFjCuKx/g==, tarball: https://registry.npmjs.org/@foxui/core/-/core-0.5.2.tgz}
···
446
446
svelte: '>=5'
447
447
tailwindcss: '>=3'
448
448
449
449
-
'@foxui/social@0.5.4':
450
450
-
resolution: {integrity: sha512-6IvVJv6+NyEf0MbLZiOsNW11qK6QDAIMGzF6Wa4v5xmE8F5HCIOsx7am7JRS8zE+4da8rQ5otM8NpAIQhb5Edw==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.5.4.tgz}
449
449
+
'@foxui/social@0.6.0':
450
450
+
resolution: {integrity: sha512-LjlxCHAdkyt2mSqovOTwBz7/UKGoTZeFtfDS8l608Mc/NQqRr+eFta4Ufro219SdNQBeCE89tQJutcaXNIOHmA==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.6.0.tgz}
451
451
peerDependencies:
452
452
svelte: '>=5'
453
453
tailwindcss: '>=3'
···
1037
1037
engines: {node: 18 || 20 || >=22}
1038
1038
1039
1039
bits-ui@2.16.2:
1040
1040
-
resolution: {integrity: sha512-bgEpRRF7Ck9nRP1pbuKVxpaSMrz+8Pm0y+dmuvlkrSe+uUwIQECef29y6eslFHM6pCAubUh7STrsTLUUp8fzFQ==}
1040
1040
+
resolution: {integrity: sha512-bgEpRRF7Ck9nRP1pbuKVxpaSMrz+8Pm0y+dmuvlkrSe+uUwIQECef29y6eslFHM6pCAubUh7STrsTLUUp8fzFQ==, tarball: https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.2.tgz}
1041
1041
engines: {node: '>=20'}
1042
1042
peerDependencies:
1043
1043
'@internationalized/date': ^3.8.1
···
1101
1101
engines: {node: '>=0.10.0'}
1102
1102
1103
1103
dequal@2.0.3:
1104
1104
-
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1104
1104
+
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, tarball: https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz}
1105
1105
engines: {node: '>=6'}
1106
1106
1107
1107
detect-libc@2.1.2:
···
1418
1418
engines: {node: '>=10'}
1419
1419
1420
1420
lz-string@1.5.0:
1421
1421
-
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
1421
1421
+
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, tarball: https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz}
1422
1422
hasBin: true
1423
1423
1424
1424
magic-string@0.30.21:
···
1653
1653
svelte: ^5.7.0
1654
1654
1655
1655
runed@0.35.1:
1656
1656
-
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
1656
1656
+
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==, tarball: https://registry.npmjs.org/runed/-/runed-0.35.1.tgz}
1657
1657
peerDependencies:
1658
1658
'@sveltejs/kit': ^2.21.0
1659
1659
svelte: ^5.7.0
···
1723
1723
svelte: ^5.0.0
1724
1724
1725
1725
svelte-toolbelt@0.10.6:
1726
1726
-
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==}
1726
1726
+
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz}
1727
1727
engines: {node: '>=18', pnpm: '>=8.7.0'}
1728
1728
peerDependencies:
1729
1729
svelte: ^5.30.2
···
1739
1739
engines: {node: '>=18'}
1740
1740
1741
1741
tabbable@6.4.0:
1742
1742
-
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
1742
1742
+
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==, tarball: https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz}
1743
1743
1744
1744
tailwind-merge@3.5.0:
1745
1745
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
···
2206
2206
- '@internationalized/date'
2207
2207
- '@sveltejs/kit'
2208
2208
2209
2209
-
'@foxui/social@0.5.4(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(tailwindcss@4.2.1)':
2209
2209
+
'@foxui/social@0.6.0(@internationalized/date@3.11.0)(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(svelte@5.53.6)(tailwindcss@4.2.1)':
2210
2210
dependencies:
2211
2211
'@atcute/bluesky': 3.2.19
2212
2212
'@atcute/bluesky-richtext-segmenter': 3.0.0
···
12
12
export interface SourceMeta {
13
13
source: string;
14
14
tailCursor?: string; // deepest point reached paginating into the past
15
15
+
fetchCursor?: string; // resume point for "new from top" pass (set = interrupted)
15
16
}
16
17
17
17
-
export const db = new Dexie('atmo') as Dexie & {
18
18
+
type AtmoDb = Dexie & {
18
19
posts: EntityTable<StoredPost, 'uri'>;
19
20
meta: EntityTable<SourceMeta, 'source'>;
20
21
};
21
22
22
22
-
db.version(1).stores({
23
23
-
posts: 'uri, *sources, savedAt, fetchedAt, likeCount, repostCount, replyCount',
24
24
-
meta: 'source'
25
25
-
});
23
23
+
let currentDb: AtmoDb | null = null;
24
24
+
25
25
+
export function openDb(did: string): AtmoDb {
26
26
+
if (currentDb) {
27
27
+
if (currentDb.name === `atmo-${did}`) return currentDb;
28
28
+
currentDb.close();
29
29
+
}
30
30
+
31
31
+
const instance = new Dexie(`atmo-${did}`) as AtmoDb;
32
32
+
instance.version(1).stores({
33
33
+
posts: 'uri, *sources, savedAt, fetchedAt, likeCount, repostCount, replyCount',
34
34
+
meta: 'source'
35
35
+
});
36
36
+
37
37
+
currentDb = instance;
38
38
+
return instance;
39
39
+
}
40
40
+
41
41
+
export function getDb(): AtmoDb {
42
42
+
if (!currentDb) throw new Error('Database not initialized — call openDb(did) first');
43
43
+
return currentDb;
44
44
+
}
···
2
2
import { toast } from '@foxui/core';
3
3
import { Document, Charset, IndexedDB } from 'flexsearch';
4
4
import { Client, simpleFetchHandler } from '@atcute/client';
5
5
-
import { db, type StoredPost } from '$lib/db';
5
5
+
import { openDb, getDb, type StoredPost } from '$lib/db';
6
6
7
7
export type SourceType = 'likes' | 'bookmarks' | 'posts' | 'reposts';
8
8
···
51
51
type SourceState = {
52
52
index: Document | null;
53
53
count: number;
54
54
-
indexed: number;
55
55
-
totalToIndex: number;
56
56
-
phase: 'idle' | 'fetching' | 'hydrating' | 'done';
57
57
-
pendingUris: string[];
58
58
-
pendingIndex: number;
54
54
+
phase: 'idle' | 'loading' | 'done';
59
55
};
60
56
61
57
function createSourceState(): SourceState {
62
58
return {
63
59
index: null,
64
60
count: 0,
65
65
-
indexed: 0,
66
66
-
totalToIndex: 0,
67
67
-
phase: 'idle',
68
68
-
pendingUris: [],
69
69
-
pendingIndex: 0
61
61
+
phase: 'idle'
70
62
};
71
63
}
72
64
···
90
82
field: 'record:text',
91
83
tokenize: 'forward',
92
84
encoder: Charset.LatinBalance
85
85
+
},
86
86
+
{
87
87
+
field: '_embedText',
88
88
+
tokenize: 'forward',
89
89
+
encoder: Charset.LatinBalance
93
90
}
94
91
]
95
92
}
96
93
});
97
94
}
98
95
96
96
+
function getEmbedText(post: any): string {
97
97
+
const parts: string[] = [];
98
98
+
const embed = post.embed;
99
99
+
if (!embed) return '';
100
100
+
101
101
+
// Link embed: title + description
102
102
+
if (embed.$type === 'app.bsky.embed.external#view') {
103
103
+
if (embed.external?.title) parts.push(embed.external.title);
104
104
+
if (embed.external?.description) parts.push(embed.external.description);
105
105
+
}
106
106
+
107
107
+
// Quote embed: quoted post text
108
108
+
if (embed.$type === 'app.bsky.embed.record#view') {
109
109
+
if (embed.record?.value?.text) parts.push(embed.record.value.text);
110
110
+
}
111
111
+
112
112
+
// Record + media combo
113
113
+
if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
114
114
+
if (embed.record?.record?.value?.text) parts.push(embed.record.record.value.text);
115
115
+
const media = embed.media;
116
116
+
if (media?.$type === 'app.bsky.embed.external#view') {
117
117
+
if (media.external?.title) parts.push(media.external.title);
118
118
+
if (media.external?.description) parts.push(media.external.description);
119
119
+
}
120
120
+
}
121
121
+
122
122
+
return parts.join(' ');
123
123
+
}
124
124
+
125
125
+
function indexPost(index: Document, post: any) {
126
126
+
const embedText = getEmbedText(post);
127
127
+
if (embedText) {
128
128
+
index.add({ ...post, _embedText: embedText } as any);
129
129
+
} else {
130
130
+
index.add(post as any);
131
131
+
}
132
132
+
}
133
133
+
99
134
const publicClient = new Client({
100
135
handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
101
136
});
···
113
148
let generation = 0;
114
149
115
150
export async function initSources() {
151
151
+
if (!user.did) return;
152
152
+
153
153
+
const db = openDb(user.did);
154
154
+
116
155
// Clean up legacy localStorage data
117
156
for (const source of ALL_SOURCES) {
118
157
localStorage.removeItem(`${source}-ids`);
···
121
160
122
161
await Promise.all(
123
162
ALL_SOURCES.map(async (source) => {
124
124
-
const flexDb = new IndexedDB(`${source}-idx`);
163
163
+
const flexDb = new IndexedDB(`${user.did}-${source}-idx`);
125
164
searchState.sources[source].index = createIndex();
126
165
await searchState.sources[source].index!.mount(flexDb);
127
166
···
174
213
}
175
214
}
176
215
177
177
-
async function fetchRecordPage(
216
216
+
async function fetchAndHydratePage(
178
217
source: SourceType,
179
218
s: SourceState,
180
219
fetchFn: Function,
181
220
cursor: string | undefined,
182
182
-
myGen: number
221
221
+
myGen: number,
222
222
+
stopOnExisting = true
183
223
): Promise<{ cursor: string | undefined; done: boolean }> {
184
224
if (myGen !== generation) return { cursor, done: true };
185
225
···
195
235
const uris = data.records.map((r: any) =>
196
236
source === 'posts' ? r.uri : r.value.subject.uri
197
237
);
238
238
+
const db = getDb();
198
239
const existingDocs = await db.posts.bulkGet(uris);
199
240
const toUpdate: any[] = [];
241
241
+
const toHydrate: string[] = [];
200
242
const now = Date.now();
201
243
202
244
for (let j = 0; j < uris.length; j++) {
···
205
247
206
248
if (existing) {
207
249
if (existing.sources.includes(source)) {
208
208
-
// Already indexed for this source — flush updates and stop.
209
209
-
// Use the last record's rkey as cursor so the tail resumes past this point.
210
210
-
const lastRecord = data.records[j];
211
211
-
const stopCursor = lastRecord?.uri?.split('/').pop() ?? data.cursor;
212
212
-
if (toUpdate.length > 0) await db.posts.bulkPut(toUpdate);
213
213
-
return { cursor: stopCursor, done: true };
250
250
+
if (stopOnExisting) {
251
251
+
// Already indexed for this source — flush updates and stop.
252
252
+
const lastRecord = data.records[j];
253
253
+
const stopCursor = lastRecord?.uri?.split('/').pop() ?? data.cursor;
254
254
+
if (toUpdate.length > 0) await db.posts.bulkPut(toUpdate);
255
255
+
return { cursor: stopCursor, done: true };
256
256
+
}
257
257
+
// Resuming an interrupted fetch — skip already-processed posts
258
258
+
continue;
214
259
}
215
215
-
// Post exists from another source — queue source tag update
260
260
+
// Post exists from another source — add source tag
216
261
toUpdate.push({ ...existing, sources: [...existing.sources, source], fetchedAt: now });
217
217
-
s.index!.add(existing as any);
262
262
+
indexPost(s.index!, existing);
218
263
s.count++;
219
264
continue;
220
265
}
221
266
222
222
-
s.pendingUris.push(subjectUri);
267
267
+
toHydrate.push(subjectUri);
223
268
}
224
269
225
270
if (toUpdate.length > 0) await db.posts.bulkPut(toUpdate);
226
271
227
227
-
const nextCursor = data.records.length > 0 ? data.cursor : undefined;
228
228
-
// Detect stalled cursor — if the API returns the same cursor we sent, stop.
229
229
-
if (nextCursor && nextCursor === cursor) {
230
230
-
return { cursor: nextCursor, done: true };
231
231
-
}
232
232
-
return { cursor: nextCursor, done: !nextCursor };
233
233
-
}
234
234
-
235
235
-
async function loadRecords(source: SourceType, myGen: number) {
236
236
-
if (!user.did || !searchState.sources[source].index) return;
237
237
-
238
238
-
const { listLikeRecords, listPostRecords, listRepostRecords } = await import(
239
239
-
'$lib/atproto/server/search.remote'
240
240
-
);
241
241
-
const fetchFn =
242
242
-
source === 'likes'
243
243
-
? listLikeRecords
244
244
-
: source === 'reposts'
245
245
-
? listRepostRecords
246
246
-
: listPostRecords;
247
247
-
248
248
-
const s = searchState.sources[source];
249
249
-
250
250
-
if (s.phase === 'idle' || s.phase === 'fetching') {
251
251
-
s.phase = 'fetching';
252
252
-
253
253
-
const meta = await db.meta.get(source);
254
254
-
255
255
-
// Step 1: Fetch new posts from the top until we hit one we already have
256
256
-
let result = { cursor: undefined as string | undefined, done: false };
257
257
-
do {
258
258
-
result = await fetchRecordPage(source, s, fetchFn, result.cursor, myGen);
259
259
-
} while (!result.done && myGen === generation);
260
260
-
261
261
-
if (myGen !== generation) return;
262
262
-
263
263
-
// Step 2: Continue from where we left off last time (tail)
264
264
-
if (meta?.tailCursor) {
265
265
-
result = { cursor: meta.tailCursor, done: false };
266
266
-
do {
267
267
-
result = await fetchRecordPage(source, s, fetchFn, result.cursor, myGen);
268
268
-
} while (!result.done && myGen === generation);
269
269
-
270
270
-
if (myGen !== generation) return;
272
272
+
// Hydrate new URIs inline via getPosts
273
273
+
if (toHydrate.length > 0) {
274
274
+
const BATCH = 25;
275
275
+
const batches: string[][] = [];
276
276
+
for (let i = 0; i < toHydrate.length; i += BATCH) {
277
277
+
batches.push(toHydrate.slice(i, i + BATCH));
271
278
}
272
279
273
273
-
// Save how far we got into the past
274
274
-
if (result.cursor) {
275
275
-
await db.meta.put({ source, tailCursor: result.cursor });
276
276
-
} else {
277
277
-
// We've reached the very end — no tail cursor needed
278
278
-
await db.meta.put({ source, tailCursor: undefined });
279
279
-
}
280
280
-
281
281
-
s.totalToIndex = s.pendingUris.length;
282
282
-
s.phase = 'hydrating';
283
283
-
}
284
284
-
285
285
-
if (s.phase === 'hydrating') {
286
286
-
await hydrateUris(source, myGen);
287
287
-
}
288
288
-
}
289
289
-
290
290
-
async function hydrateUris(source: SourceType, myGen: number) {
291
291
-
const s = searchState.sources[source];
292
292
-
const BATCH_SIZE = 25;
293
293
-
const CONCURRENCY = 5;
294
294
-
295
295
-
const remaining = s.pendingUris.slice(s.pendingIndex);
296
296
-
const batches: string[][] = [];
297
297
-
for (let i = 0; i < remaining.length; i += BATCH_SIZE) {
298
298
-
batches.push(remaining.slice(i, i + BATCH_SIZE));
299
299
-
}
300
300
-
301
301
-
for (let i = 0; i < batches.length; i += CONCURRENCY) {
302
302
-
if (myGen !== generation) return;
303
303
-
304
304
-
const chunk = batches.slice(i, i + CONCURRENCY);
305
280
const results = await Promise.all(
306
306
-
chunk.map((uris) =>
281
281
+
batches.map((batch) =>
307
282
publicClient
308
308
-
.get('app.bsky.feed.getPosts', { params: { uris: uris as any } })
283
283
+
.get('app.bsky.feed.getPosts', { params: { uris: batch as any } })
309
284
.catch((e) => {
310
285
console.error(`Failed to hydrate ${source} batch:`, e);
311
286
return null;
···
313
288
)
314
289
);
315
290
316
316
-
// Collect all posts from this chunk
317
291
const allPosts: any[] = [];
318
292
for (const result of results) {
319
319
-
if (result?.ok) {
320
320
-
allPosts.push(...result.data.posts);
321
321
-
}
293
293
+
if (result?.ok) allPosts.push(...result.data.posts);
322
294
}
323
295
324
296
if (allPosts.length > 0) {
325
325
-
// Batch-check which already exist in Dexie
326
326
-
const uris = allPosts.map((p) => p.uri);
327
327
-
const existing = await db.posts.bulkGet(uris);
297
297
+
const existingPosts = await db.posts.bulkGet(allPosts.map((p) => p.uri));
328
298
const existingMap = new Map<string, StoredPost>();
329
329
-
for (const doc of existing) {
299
299
+
for (const doc of existingPosts) {
330
300
if (doc) existingMap.set(doc.uri, doc);
331
301
}
332
302
333
333
-
// Build batch for bulkPut
334
334
-
const now = Date.now();
335
303
const toPut: any[] = [];
336
304
for (const post of allPosts) {
337
305
const ex = existingMap.get(post.uri);
···
340
308
} else {
341
309
toPut.push({ ...post, sources: [source], savedAt: now, fetchedAt: now });
342
310
}
343
343
-
s.index!.add(post as any);
344
344
-
s.indexed++;
311
311
+
indexPost(s.index!, post);
345
312
s.count++;
346
313
}
347
314
348
315
await db.posts.bulkPut(toPut);
349
316
}
317
317
+
}
350
318
351
351
-
s.pendingIndex += chunk.reduce((sum, b) => sum + b.length, 0);
352
352
-
await s.index!.commit();
319
319
+
await s.index!.commit();
320
320
+
321
321
+
const nextCursor = data.records.length > 0 ? data.cursor : undefined;
322
322
+
if (nextCursor && nextCursor === cursor) {
323
323
+
return { cursor: nextCursor, done: true };
324
324
+
}
325
325
+
return { cursor: nextCursor, done: !nextCursor };
326
326
+
}
327
327
+
328
328
+
async function loadRecords(source: SourceType, myGen: number) {
329
329
+
if (!user.did || !searchState.sources[source].index) return;
330
330
+
331
331
+
const { listLikeRecords, listPostRecords, listRepostRecords } = await import(
332
332
+
'$lib/atproto/server/search.remote'
333
333
+
);
334
334
+
const fetchFn =
335
335
+
source === 'likes'
336
336
+
? listLikeRecords
337
337
+
: source === 'reposts'
338
338
+
? listRepostRecords
339
339
+
: listPostRecords;
340
340
+
341
341
+
const s = searchState.sources[source];
342
342
+
s.phase = 'loading';
343
343
+
344
344
+
const db = getDb();
345
345
+
const meta = await db.meta.get(source);
346
346
+
347
347
+
// Step 1: Fetch new posts from the top until we hit one we already have
348
348
+
// If fetchCursor is set, we were interrupted last time — resume from there
349
349
+
const isResuming = !!meta?.fetchCursor;
350
350
+
let result = { cursor: meta?.fetchCursor as string | undefined, done: false };
351
351
+
do {
352
352
+
// When resuming, don't stop on existing posts — the interrupted page
353
353
+
// may have partially-hydrated posts in Dexie that would trigger a false stop
354
354
+
result = await fetchAndHydratePage(source, s, fetchFn, result.cursor, myGen, !isResuming);
355
355
+
if (myGen !== generation) return;
356
356
+
// Save fetch progress so we can resume on reload
357
357
+
if (!result.done && result.cursor) {
358
358
+
await db.meta.put({ ...meta, source, fetchCursor: result.cursor });
359
359
+
}
360
360
+
} while (!result.done);
361
361
+
362
362
+
// Pass 1 complete — clear fetchCursor
363
363
+
await db.meta.put({ ...meta, source, fetchCursor: undefined });
364
364
+
365
365
+
// Step 2: Continue from where we left off last time (tail)
366
366
+
if (meta?.tailCursor) {
367
367
+
result = { cursor: meta.tailCursor, done: false };
368
368
+
do {
369
369
+
result = await fetchAndHydratePage(source, s, fetchFn, result.cursor, myGen);
370
370
+
if (myGen !== generation) return;
371
371
+
// Save tail progress incrementally
372
372
+
if (result.cursor) {
373
373
+
await db.meta.put({ source, tailCursor: result.cursor });
374
374
+
}
375
375
+
} while (!result.done);
376
376
+
}
377
377
+
378
378
+
// Save final tail position
379
379
+
if (result.cursor) {
380
380
+
await db.meta.put({ source, tailCursor: result.cursor });
381
381
+
} else {
382
382
+
await db.meta.put({ source, tailCursor: undefined });
353
383
}
354
384
355
385
s.phase = 'done';
···
361
391
s: SourceState,
362
392
getBookmarks: Function,
363
393
cursor: string | undefined,
364
364
-
myGen: number
394
394
+
myGen: number,
395
395
+
stopOnExisting = true
365
396
): Promise<{ cursor: string | undefined; done: boolean }> {
366
397
if (myGen !== generation) return { cursor, done: true };
367
398
···
374
405
return { cursor, done: true };
375
406
}
376
407
408
408
+
const db = getDb();
377
409
const validBookmarks = data.bookmarks.filter((b: any) => b.item?.uri);
378
410
const uris = validBookmarks.map((b: any) => b.item.uri);
379
411
const existingDocs = await db.posts.bulkGet(uris);
···
382
414
383
415
for (let j = 0; j < validBookmarks.length; j++) {
384
416
const bookmark = validBookmarks[j];
385
385
-
const postUri = uris[j];
386
417
const existing = existingDocs[j];
387
418
388
419
if (existing) {
389
420
if (existing.sources.includes(source)) {
390
390
-
// Already indexed for this source — flush and stop
391
391
-
if (toPut.length > 0) await db.posts.bulkPut(toPut);
392
392
-
return { cursor: data.cursor ?? cursor, done: true };
421
421
+
if (stopOnExisting) {
422
422
+
// Already indexed for this source — flush and stop
423
423
+
if (toPut.length > 0) await db.posts.bulkPut(toPut);
424
424
+
return { cursor: data.cursor ?? cursor, done: true };
425
425
+
}
426
426
+
// Resuming an interrupted fetch — skip already-processed posts
427
427
+
continue;
393
428
}
394
394
-
// Exists from another source — queue tag update
429
429
+
// Exists from another source — add source tag
395
430
toPut.push({ ...existing, sources: [...existing.sources, source], fetchedAt: now });
396
396
-
s.index!.add(existing as any);
431
431
+
indexPost(s.index!, existing);
397
432
s.count++;
398
433
continue;
399
434
}
400
435
401
401
-
// Bookmarks come with full PostView — queue for batch write
436
436
+
// Bookmarks come with full PostView — store directly
402
437
toPut.push({ ...bookmark.item, sources: [source], savedAt: now, fetchedAt: now });
403
403
-
s.index!.add(bookmark.item as any);
404
404
-
s.indexed++;
438
438
+
indexPost(s.index!, bookmark.item);
405
439
s.count++;
406
440
}
407
441
408
442
if (toPut.length > 0) await db.posts.bulkPut(toPut);
409
443
410
444
const nextCursor = data.bookmarks.length > 0 ? data.cursor : undefined;
411
411
-
// Detect stalled cursor — if the API returns the same cursor we sent, stop.
412
445
if (nextCursor && nextCursor === cursor) {
413
446
return { cursor: nextCursor, done: true };
414
447
}
···
420
453
421
454
const { getBookmarks } = await import('$lib/atproto/server/search.remote');
422
455
456
456
+
const db = getDb();
423
457
const s = searchState.sources[source];
424
424
-
s.phase = 'fetching';
458
458
+
s.phase = 'loading';
425
459
426
460
const meta = await db.meta.get(source);
427
461
428
462
// Step 1: Fetch new bookmarks from the top until we hit one we already have
429
429
-
let result = { cursor: undefined as string | undefined, done: false };
463
463
+
const isResuming = !!meta?.fetchCursor;
464
464
+
let result = { cursor: meta?.fetchCursor as string | undefined, done: false };
430
465
do {
431
431
-
result = await fetchBookmarkPage(source, s, getBookmarks, result.cursor, myGen);
466
466
+
result = await fetchBookmarkPage(source, s, getBookmarks, result.cursor, myGen, !isResuming);
432
467
await s.index!.commit();
433
433
-
} while (!result.done && myGen === generation);
468
468
+
if (myGen !== generation) return;
469
469
+
if (!result.done && result.cursor) {
470
470
+
await db.meta.put({ ...meta, source, fetchCursor: result.cursor });
471
471
+
}
472
472
+
} while (!result.done);
434
473
435
435
-
if (myGen !== generation) return;
474
474
+
// Pass 1 complete — clear fetchCursor
475
475
+
await db.meta.put({ ...meta, source, fetchCursor: undefined });
436
476
437
477
// Step 2: Continue from where we left off last time (tail)
438
478
if (meta?.tailCursor) {
···
440
480
do {
441
481
result = await fetchBookmarkPage(source, s, getBookmarks, result.cursor, myGen);
442
482
await s.index!.commit();
443
443
-
} while (!result.done && myGen === generation);
444
444
-
445
445
-
if (myGen !== generation) return;
483
483
+
if (myGen !== generation) return;
484
484
+
if (result.cursor) {
485
485
+
await db.meta.put({ source, tailCursor: result.cursor });
486
486
+
}
487
487
+
} while (!result.done);
446
488
}
447
489
448
448
-
// Save how far we got into the past
490
490
+
// Save final tail position
449
491
if (result.cursor) {
450
492
await db.meta.put({ source, tailCursor: result.cursor });
451
493
} else {
···
556
598
): Promise<{ results: any[]; hasMore: boolean }> {
557
599
const source = searchState.activeSource;
558
600
const hasFilters = filtersActive(filters);
601
601
+
const db = getDb();
559
602
560
603
let docs: any[];
561
604
···
586
629
}
587
630
588
631
export async function clearSource(source: SourceType) {
632
632
+
const db = getDb();
589
633
// Remove source tag from all posts, delete orphans
590
634
const posts = await db.posts.where('sources').equals(source).toArray();
591
635
await db.transaction('rw', db.posts, async () => {
···
602
646
603
647
searchState.sources[source].index?.clear();
604
648
searchState.sources[source].count = 0;
605
605
-
searchState.sources[source].indexed = 0;
606
606
-
searchState.sources[source].totalToIndex = 0;
607
649
searchState.sources[source].phase = 'idle';
608
608
-
searchState.sources[source].pendingUris = [];
609
609
-
searchState.sources[source].pendingIndex = 0;
610
650
}
611
651
612
652
export function getLink(uri: string, handle: string) {
···
86
86
87
87
<svelte:window
88
88
bind:scrollY
89
89
-
onkeydown={() => {
89
89
+
onkeydown={(e) => {
90
90
+
if (e.key === 'Tab' || e.metaKey || e.ctrlKey || e.altKey) return;
90
91
const tag = document.activeElement?.tagName;
91
92
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
92
93
input?.focus();
···
110
111
<Navbar class="top-12 mx-2 h-auto max-w-xl flex-col items-start py-3 sm:mx-auto md:top-10">
111
112
<div class="mx-2 mb-4 flex items-baseline gap-2">
112
113
<div class="flex gap-1">
113
113
-
{#each ALL_SOURCES as source}
114
114
+
{#each ALL_SOURCES as source (source)}
114
115
<button
115
116
class="cursor-pointer rounded-full px-3 py-1 text-sm font-medium transition-colors {searchState.activeSource ===
116
117
source
···
173
174
</div>
174
175
{#if filters.handles.length > 0}
175
176
<div class="ml-18 flex flex-wrap gap-1">
176
176
-
{#each filters.handles as handle}
177
177
+
{#each filters.handles as handle (handle)}
177
178
<span
178
179
class="bg-base-200 dark:bg-base-800 text-base-700 dark:text-base-300 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
179
180
>
···
288
289
<span class="text-base-600 dark:text-base-400 mx-4 mt-2 text-xs">
289
290
{#if src.phase === 'idle'}
290
291
loading...
291
291
-
{:else if src.phase === 'fetching'}
292
292
-
fetching {SOURCE_LABELS[searchState.activeSource].toLowerCase()}... ({src.count +
293
293
-
src.pendingUris.length} found)
294
294
-
{:else if src.phase === 'hydrating'}
295
295
-
indexing {src.indexed}/{src.totalToIndex}
296
296
-
{SOURCE_LABELS[searchState.activeSource].toLowerCase()}... ({src.count} searchable)
292
292
+
{:else if src.phase === 'loading'}
293
293
+
loading {SOURCE_LABELS[searchState.activeSource].toLowerCase()}... ({src.count} indexed)
297
294
{:else}
298
295
{SOURCE_LABELS[searchState.activeSource].toLowerCase()}: {src.count}{#if results.length > 0}, {results.length}
299
296
results{/if}
···
313
310
liked={result.doc.sources?.includes('likes')}
314
311
bookmarked={result.doc.sources?.includes('bookmarks')}
315
312
showLogo
316
316
-
feedViewPost={result.doc}
313
313
+
data={result.doc}
317
314
/>
318
315
</li>
319
316
{/each}