[READ-ONLY] Mirror of https://github.com/flo-bit/atmo-tools. atmo.tools
0

Configure Feed

Select the types of activity you want to include in your feed.

fixes

+226 -170
+1 -1
.env.example
··· 3 3 COOKIE_SECRET= 4 4 5 5 # Set to your tunnel URL to use a confidential client in dev 6 - # OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com 6 + OAUTH_PUBLIC_URL=
+1 -1
package.json
··· 50 50 }, 51 51 "dependencies": { 52 52 "@foxui/core": "^0.5.2", 53 - "@foxui/social": "^0.5.4", 53 + "@foxui/social": "^0.6.0", 54 54 "dexie": "^4.3.0", 55 55 "flexsearch": "^0.8.212", 56 56 "valibot": "^1.2.0",
+14 -14
pnpm-lock.yaml
··· 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 - specifier: ^0.5.4 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 + specifier: ^0.6.0 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 - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} 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 - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} 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 - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} 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 - '@foxui/social@0.5.4': 450 - resolution: {integrity: sha512-6IvVJv6+NyEf0MbLZiOsNW11qK6QDAIMGzF6Wa4v5xmE8F5HCIOsx7am7JRS8zE+4da8rQ5otM8NpAIQhb5Edw==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.5.4.tgz} 449 + '@foxui/social@0.6.0': 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 - resolution: {integrity: sha512-bgEpRRF7Ck9nRP1pbuKVxpaSMrz+8Pm0y+dmuvlkrSe+uUwIQECef29y6eslFHM6pCAubUh7STrsTLUUp8fzFQ==} 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 - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} 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 - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} 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 - resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} 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 - resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} 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 - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} 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 - '@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 + '@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
+24 -5
src/lib/db.ts
··· 12 12 export interface SourceMeta { 13 13 source: string; 14 14 tailCursor?: string; // deepest point reached paginating into the past 15 + fetchCursor?: string; // resume point for "new from top" pass (set = interrupted) 15 16 } 16 17 17 - export const db = new Dexie('atmo') as Dexie & { 18 + type AtmoDb = Dexie & { 18 19 posts: EntityTable<StoredPost, 'uri'>; 19 20 meta: EntityTable<SourceMeta, 'source'>; 20 21 }; 21 22 22 - db.version(1).stores({ 23 - posts: 'uri, *sources, savedAt, fetchedAt, likeCount, repostCount, replyCount', 24 - meta: 'source' 25 - }); 23 + let currentDb: AtmoDb | null = null; 24 + 25 + export function openDb(did: string): AtmoDb { 26 + if (currentDb) { 27 + if (currentDb.name === `atmo-${did}`) return currentDb; 28 + currentDb.close(); 29 + } 30 + 31 + const instance = new Dexie(`atmo-${did}`) as AtmoDb; 32 + instance.version(1).stores({ 33 + posts: 'uri, *sources, savedAt, fetchedAt, likeCount, repostCount, replyCount', 34 + meta: 'source' 35 + }); 36 + 37 + currentDb = instance; 38 + return instance; 39 + } 40 + 41 + export function getDb(): AtmoDb { 42 + if (!currentDb) throw new Error('Database not initialized — call openDb(did) first'); 43 + return currentDb; 44 + }
+179 -139
src/lib/search-state.svelte.ts
··· 2 2 import { toast } from '@foxui/core'; 3 3 import { Document, Charset, IndexedDB } from 'flexsearch'; 4 4 import { Client, simpleFetchHandler } from '@atcute/client'; 5 - import { db, type StoredPost } from '$lib/db'; 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 - indexed: number; 55 - totalToIndex: number; 56 - phase: 'idle' | 'fetching' | 'hydrating' | 'done'; 57 - pendingUris: string[]; 58 - pendingIndex: number; 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 - indexed: 0, 66 - totalToIndex: 0, 67 - phase: 'idle', 68 - pendingUris: [], 69 - pendingIndex: 0 61 + phase: 'idle' 70 62 }; 71 63 } 72 64 ··· 90 82 field: 'record:text', 91 83 tokenize: 'forward', 92 84 encoder: Charset.LatinBalance 85 + }, 86 + { 87 + field: '_embedText', 88 + tokenize: 'forward', 89 + encoder: Charset.LatinBalance 93 90 } 94 91 ] 95 92 } 96 93 }); 97 94 } 98 95 96 + function getEmbedText(post: any): string { 97 + const parts: string[] = []; 98 + const embed = post.embed; 99 + if (!embed) return ''; 100 + 101 + // Link embed: title + description 102 + if (embed.$type === 'app.bsky.embed.external#view') { 103 + if (embed.external?.title) parts.push(embed.external.title); 104 + if (embed.external?.description) parts.push(embed.external.description); 105 + } 106 + 107 + // Quote embed: quoted post text 108 + if (embed.$type === 'app.bsky.embed.record#view') { 109 + if (embed.record?.value?.text) parts.push(embed.record.value.text); 110 + } 111 + 112 + // Record + media combo 113 + if (embed.$type === 'app.bsky.embed.recordWithMedia#view') { 114 + if (embed.record?.record?.value?.text) parts.push(embed.record.record.value.text); 115 + const media = embed.media; 116 + if (media?.$type === 'app.bsky.embed.external#view') { 117 + if (media.external?.title) parts.push(media.external.title); 118 + if (media.external?.description) parts.push(media.external.description); 119 + } 120 + } 121 + 122 + return parts.join(' '); 123 + } 124 + 125 + function indexPost(index: Document, post: any) { 126 + const embedText = getEmbedText(post); 127 + if (embedText) { 128 + index.add({ ...post, _embedText: embedText } as any); 129 + } else { 130 + index.add(post as any); 131 + } 132 + } 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 + if (!user.did) return; 152 + 153 + const db = openDb(user.did); 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 - const flexDb = new IndexedDB(`${source}-idx`); 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 - async function fetchRecordPage( 216 + async function fetchAndHydratePage( 178 217 source: SourceType, 179 218 s: SourceState, 180 219 fetchFn: Function, 181 220 cursor: string | undefined, 182 - myGen: number 221 + myGen: number, 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 + const db = getDb(); 198 239 const existingDocs = await db.posts.bulkGet(uris); 199 240 const toUpdate: any[] = []; 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 - // Already indexed for this source — flush updates and stop. 209 - // Use the last record's rkey as cursor so the tail resumes past this point. 210 - const lastRecord = data.records[j]; 211 - const stopCursor = lastRecord?.uri?.split('/').pop() ?? data.cursor; 212 - if (toUpdate.length > 0) await db.posts.bulkPut(toUpdate); 213 - return { cursor: stopCursor, done: true }; 250 + if (stopOnExisting) { 251 + // Already indexed for this source — flush updates and stop. 252 + const lastRecord = data.records[j]; 253 + const stopCursor = lastRecord?.uri?.split('/').pop() ?? data.cursor; 254 + if (toUpdate.length > 0) await db.posts.bulkPut(toUpdate); 255 + return { cursor: stopCursor, done: true }; 256 + } 257 + // Resuming an interrupted fetch — skip already-processed posts 258 + continue; 214 259 } 215 - // Post exists from another source — queue source tag update 260 + // Post exists from another source — add source tag 216 261 toUpdate.push({ ...existing, sources: [...existing.sources, source], fetchedAt: now }); 217 - s.index!.add(existing as any); 262 + indexPost(s.index!, existing); 218 263 s.count++; 219 264 continue; 220 265 } 221 266 222 - s.pendingUris.push(subjectUri); 267 + toHydrate.push(subjectUri); 223 268 } 224 269 225 270 if (toUpdate.length > 0) await db.posts.bulkPut(toUpdate); 226 271 227 - const nextCursor = data.records.length > 0 ? data.cursor : undefined; 228 - // Detect stalled cursor — if the API returns the same cursor we sent, stop. 229 - if (nextCursor && nextCursor === cursor) { 230 - return { cursor: nextCursor, done: true }; 231 - } 232 - return { cursor: nextCursor, done: !nextCursor }; 233 - } 234 - 235 - async function loadRecords(source: SourceType, myGen: number) { 236 - if (!user.did || !searchState.sources[source].index) return; 237 - 238 - const { listLikeRecords, listPostRecords, listRepostRecords } = await import( 239 - '$lib/atproto/server/search.remote' 240 - ); 241 - const fetchFn = 242 - source === 'likes' 243 - ? listLikeRecords 244 - : source === 'reposts' 245 - ? listRepostRecords 246 - : listPostRecords; 247 - 248 - const s = searchState.sources[source]; 249 - 250 - if (s.phase === 'idle' || s.phase === 'fetching') { 251 - s.phase = 'fetching'; 252 - 253 - const meta = await db.meta.get(source); 254 - 255 - // Step 1: Fetch new posts from the top until we hit one we already have 256 - let result = { cursor: undefined as string | undefined, done: false }; 257 - do { 258 - result = await fetchRecordPage(source, s, fetchFn, result.cursor, myGen); 259 - } while (!result.done && myGen === generation); 260 - 261 - if (myGen !== generation) return; 262 - 263 - // Step 2: Continue from where we left off last time (tail) 264 - if (meta?.tailCursor) { 265 - result = { cursor: meta.tailCursor, done: false }; 266 - do { 267 - result = await fetchRecordPage(source, s, fetchFn, result.cursor, myGen); 268 - } while (!result.done && myGen === generation); 269 - 270 - if (myGen !== generation) return; 272 + // Hydrate new URIs inline via getPosts 273 + if (toHydrate.length > 0) { 274 + const BATCH = 25; 275 + const batches: string[][] = []; 276 + for (let i = 0; i < toHydrate.length; i += BATCH) { 277 + batches.push(toHydrate.slice(i, i + BATCH)); 271 278 } 272 279 273 - // Save how far we got into the past 274 - if (result.cursor) { 275 - await db.meta.put({ source, tailCursor: result.cursor }); 276 - } else { 277 - // We've reached the very end — no tail cursor needed 278 - await db.meta.put({ source, tailCursor: undefined }); 279 - } 280 - 281 - s.totalToIndex = s.pendingUris.length; 282 - s.phase = 'hydrating'; 283 - } 284 - 285 - if (s.phase === 'hydrating') { 286 - await hydrateUris(source, myGen); 287 - } 288 - } 289 - 290 - async function hydrateUris(source: SourceType, myGen: number) { 291 - const s = searchState.sources[source]; 292 - const BATCH_SIZE = 25; 293 - const CONCURRENCY = 5; 294 - 295 - const remaining = s.pendingUris.slice(s.pendingIndex); 296 - const batches: string[][] = []; 297 - for (let i = 0; i < remaining.length; i += BATCH_SIZE) { 298 - batches.push(remaining.slice(i, i + BATCH_SIZE)); 299 - } 300 - 301 - for (let i = 0; i < batches.length; i += CONCURRENCY) { 302 - if (myGen !== generation) return; 303 - 304 - const chunk = batches.slice(i, i + CONCURRENCY); 305 280 const results = await Promise.all( 306 - chunk.map((uris) => 281 + batches.map((batch) => 307 282 publicClient 308 - .get('app.bsky.feed.getPosts', { params: { uris: uris as any } }) 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 - // Collect all posts from this chunk 317 291 const allPosts: any[] = []; 318 292 for (const result of results) { 319 - if (result?.ok) { 320 - allPosts.push(...result.data.posts); 321 - } 293 + if (result?.ok) allPosts.push(...result.data.posts); 322 294 } 323 295 324 296 if (allPosts.length > 0) { 325 - // Batch-check which already exist in Dexie 326 - const uris = allPosts.map((p) => p.uri); 327 - const existing = await db.posts.bulkGet(uris); 297 + const existingPosts = await db.posts.bulkGet(allPosts.map((p) => p.uri)); 328 298 const existingMap = new Map<string, StoredPost>(); 329 - for (const doc of existing) { 299 + for (const doc of existingPosts) { 330 300 if (doc) existingMap.set(doc.uri, doc); 331 301 } 332 302 333 - // Build batch for bulkPut 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 - s.index!.add(post as any); 344 - s.indexed++; 311 + indexPost(s.index!, post); 345 312 s.count++; 346 313 } 347 314 348 315 await db.posts.bulkPut(toPut); 349 316 } 317 + } 350 318 351 - s.pendingIndex += chunk.reduce((sum, b) => sum + b.length, 0); 352 - await s.index!.commit(); 319 + await s.index!.commit(); 320 + 321 + const nextCursor = data.records.length > 0 ? data.cursor : undefined; 322 + if (nextCursor && nextCursor === cursor) { 323 + return { cursor: nextCursor, done: true }; 324 + } 325 + return { cursor: nextCursor, done: !nextCursor }; 326 + } 327 + 328 + async function loadRecords(source: SourceType, myGen: number) { 329 + if (!user.did || !searchState.sources[source].index) return; 330 + 331 + const { listLikeRecords, listPostRecords, listRepostRecords } = await import( 332 + '$lib/atproto/server/search.remote' 333 + ); 334 + const fetchFn = 335 + source === 'likes' 336 + ? listLikeRecords 337 + : source === 'reposts' 338 + ? listRepostRecords 339 + : listPostRecords; 340 + 341 + const s = searchState.sources[source]; 342 + s.phase = 'loading'; 343 + 344 + const db = getDb(); 345 + const meta = await db.meta.get(source); 346 + 347 + // Step 1: Fetch new posts from the top until we hit one we already have 348 + // If fetchCursor is set, we were interrupted last time — resume from there 349 + const isResuming = !!meta?.fetchCursor; 350 + let result = { cursor: meta?.fetchCursor as string | undefined, done: false }; 351 + do { 352 + // When resuming, don't stop on existing posts — the interrupted page 353 + // may have partially-hydrated posts in Dexie that would trigger a false stop 354 + result = await fetchAndHydratePage(source, s, fetchFn, result.cursor, myGen, !isResuming); 355 + if (myGen !== generation) return; 356 + // Save fetch progress so we can resume on reload 357 + if (!result.done && result.cursor) { 358 + await db.meta.put({ ...meta, source, fetchCursor: result.cursor }); 359 + } 360 + } while (!result.done); 361 + 362 + // Pass 1 complete — clear fetchCursor 363 + await db.meta.put({ ...meta, source, fetchCursor: undefined }); 364 + 365 + // Step 2: Continue from where we left off last time (tail) 366 + if (meta?.tailCursor) { 367 + result = { cursor: meta.tailCursor, done: false }; 368 + do { 369 + result = await fetchAndHydratePage(source, s, fetchFn, result.cursor, myGen); 370 + if (myGen !== generation) return; 371 + // Save tail progress incrementally 372 + if (result.cursor) { 373 + await db.meta.put({ source, tailCursor: result.cursor }); 374 + } 375 + } while (!result.done); 376 + } 377 + 378 + // Save final tail position 379 + if (result.cursor) { 380 + await db.meta.put({ source, tailCursor: result.cursor }); 381 + } else { 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 - myGen: number 394 + myGen: number, 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 + 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 - const postUri = uris[j]; 386 417 const existing = existingDocs[j]; 387 418 388 419 if (existing) { 389 420 if (existing.sources.includes(source)) { 390 - // Already indexed for this source — flush and stop 391 - if (toPut.length > 0) await db.posts.bulkPut(toPut); 392 - return { cursor: data.cursor ?? cursor, done: true }; 421 + if (stopOnExisting) { 422 + // Already indexed for this source — flush and stop 423 + if (toPut.length > 0) await db.posts.bulkPut(toPut); 424 + return { cursor: data.cursor ?? cursor, done: true }; 425 + } 426 + // Resuming an interrupted fetch — skip already-processed posts 427 + continue; 393 428 } 394 - // Exists from another source — queue tag update 429 + // Exists from another source — add source tag 395 430 toPut.push({ ...existing, sources: [...existing.sources, source], fetchedAt: now }); 396 - s.index!.add(existing as any); 431 + indexPost(s.index!, existing); 397 432 s.count++; 398 433 continue; 399 434 } 400 435 401 - // Bookmarks come with full PostView — queue for batch write 436 + // Bookmarks come with full PostView — store directly 402 437 toPut.push({ ...bookmark.item, sources: [source], savedAt: now, fetchedAt: now }); 403 - s.index!.add(bookmark.item as any); 404 - s.indexed++; 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 - // 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 + const db = getDb(); 423 457 const s = searchState.sources[source]; 424 - s.phase = 'fetching'; 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 - let result = { cursor: undefined as string | undefined, done: false }; 463 + const isResuming = !!meta?.fetchCursor; 464 + let result = { cursor: meta?.fetchCursor as string | undefined, done: false }; 430 465 do { 431 - result = await fetchBookmarkPage(source, s, getBookmarks, result.cursor, myGen); 466 + result = await fetchBookmarkPage(source, s, getBookmarks, result.cursor, myGen, !isResuming); 432 467 await s.index!.commit(); 433 - } while (!result.done && myGen === generation); 468 + if (myGen !== generation) return; 469 + if (!result.done && result.cursor) { 470 + await db.meta.put({ ...meta, source, fetchCursor: result.cursor }); 471 + } 472 + } while (!result.done); 434 473 435 - if (myGen !== generation) return; 474 + // Pass 1 complete — clear fetchCursor 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 - } while (!result.done && myGen === generation); 444 - 445 - if (myGen !== generation) return; 483 + if (myGen !== generation) return; 484 + if (result.cursor) { 485 + await db.meta.put({ source, tailCursor: result.cursor }); 486 + } 487 + } while (!result.done); 446 488 } 447 489 448 - // Save how far we got into the past 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 + 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 + 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 - searchState.sources[source].indexed = 0; 606 - searchState.sources[source].totalToIndex = 0; 607 649 searchState.sources[source].phase = 'idle'; 608 - searchState.sources[source].pendingUris = []; 609 - searchState.sources[source].pendingIndex = 0; 610 650 } 611 651 612 652 export function getLink(uri: string, handle: string) {
+7 -10
src/routes/search/Search.svelte
··· 86 86 87 87 <svelte:window 88 88 bind:scrollY 89 - onkeydown={() => { 89 + onkeydown={(e) => { 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 - {#each ALL_SOURCES as source} 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 - {#each filters.handles as handle} 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 - {:else if src.phase === 'fetching'} 292 - fetching {SOURCE_LABELS[searchState.activeSource].toLowerCase()}... ({src.count + 293 - src.pendingUris.length} found) 294 - {:else if src.phase === 'hydrating'} 295 - indexing {src.indexed}/{src.totalToIndex} 296 - {SOURCE_LABELS[searchState.activeSource].toLowerCase()}... ({src.count} searchable) 292 + {:else if src.phase === 'loading'} 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 - feedViewPost={result.doc} 313 + data={result.doc} 317 314 /> 318 315 </li> 319 316 {/each}