[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.

add replies to my posts as search category

+179 -10
+11
src/lib/db.ts
··· 15 15 fetchCursor?: string; // resume point for "new from top" pass (set = interrupted) 16 16 } 17 17 18 + export interface ThreadMeta { 19 + uri: string; // the user's own post URI 20 + repliesFetchedAt: number; // Date.now() of last getPostThread call 21 + } 22 + 18 23 type AtmoDb = Dexie & { 19 24 posts: EntityTable<StoredPost, 'uri'>; 20 25 meta: EntityTable<SourceMeta, 'source'>; 26 + threadMeta: EntityTable<ThreadMeta, 'uri'>; 21 27 }; 22 28 23 29 let currentDb: AtmoDb | null = null; ··· 32 38 instance.version(1).stores({ 33 39 posts: 'uri, *sources, savedAt, fetchedAt, likeCount, repostCount, replyCount', 34 40 meta: 'source' 41 + }); 42 + instance.version(2).stores({ 43 + posts: 'uri, *sources, savedAt, fetchedAt, likeCount, repostCount, replyCount', 44 + meta: 'source', 45 + threadMeta: 'uri' 35 46 }); 36 47 37 48 currentDb = instance;
+162 -5
src/lib/search-state.svelte.ts
··· 4 4 import { Client, simpleFetchHandler } from '@atcute/client'; 5 5 import { openDb, getDb, type StoredPost } from '$lib/db'; 6 6 7 - export type SourceType = 'likes' | 'bookmarks' | 'posts' | 'reposts'; 7 + export type SourceType = 'likes' | 'bookmarks' | 'posts' | 'reposts' | 'replies'; 8 8 9 9 export type SearchFilters = { 10 10 handles: string[]; ··· 38 38 likes: 'Likes', 39 39 bookmarks: 'Bookmarks', 40 40 posts: 'Posts', 41 - reposts: 'Reposts' 41 + reposts: 'Reposts', 42 + replies: 'Replies' 42 43 }; 43 44 44 45 export const PLACEHOLDERS: Record<SourceType, string> = { 45 46 likes: 'Search liked posts', 46 47 bookmarks: 'Search bookmarks', 47 48 posts: 'Search my posts', 48 - reposts: 'Search reposted posts' 49 + reposts: 'Search reposted posts', 50 + replies: 'Search replies to my posts' 49 51 }; 50 52 51 - export const ALL_SOURCES: SourceType[] = ['likes', 'bookmarks', 'posts', 'reposts']; 53 + export const ALL_SOURCES: SourceType[] = ['likes', 'bookmarks', 'posts', 'reposts', 'replies']; 52 54 53 55 type SourceState = { 54 56 index: Document | null; ··· 142 144 likes: createSourceState(), 143 145 bookmarks: createSourceState(), 144 146 posts: createSourceState(), 145 - reposts: createSourceState() 147 + reposts: createSourceState(), 148 + replies: createSourceState() 146 149 } as Record<SourceType, SourceState>, 147 150 activeSource: 'likes' as SourceType 148 151 }); ··· 195 198 196 199 if (source === 'bookmarks') { 197 200 loadBookmarks(source, myGen); 201 + } else if (source === 'replies') { 202 + loadReplies(myGen); 198 203 } else { 199 204 loadRecords(source, myGen); 200 205 } ··· 501 506 loadNext(myGen); 502 507 } 503 508 509 + // --- Replies loading --- 510 + 511 + const HOUR = 3_600_000; 512 + const DAY = 86_400_000; 513 + 514 + function getRefreshInterval(postAgeMs: number): number { 515 + if (postAgeMs < 4 * HOUR) return 1 * HOUR; 516 + if (postAgeMs < 1 * DAY) return 4 * HOUR; 517 + if (postAgeMs < 3 * DAY) return 12 * HOUR; 518 + if (postAgeMs < 7 * DAY) return 1 * DAY; 519 + if (postAgeMs < 30 * DAY) return 3 * DAY; 520 + return 7 * DAY; 521 + } 522 + 523 + async function loadReplies(myGen: number) { 524 + if (!user.did) return; 525 + 526 + const s = searchState.sources.replies; 527 + s.phase = 'loading'; 528 + 529 + const db = getDb(); 530 + const now = Date.now(); 531 + 532 + // Get all user posts from Dexie 533 + const userPosts = await db.posts.where('sources').equals('posts').toArray(); 534 + if (myGen !== generation) return; 535 + 536 + // Sort newest first 537 + userPosts.sort((a, b) => { 538 + const dateA = a.record?.createdAt ? new Date(a.record.createdAt).getTime() : 0; 539 + const dateB = b.record?.createdAt ? new Date(b.record.createdAt).getTime() : 0; 540 + return dateB - dateA; 541 + }); 542 + 543 + // Get thread meta for all posts 544 + const metaEntries = await db.threadMeta.bulkGet(userPosts.map((p) => p.uri)); 545 + const metaMap = new Map<string, { repliesFetchedAt: number }>(); 546 + for (let i = 0; i < userPosts.length; i++) { 547 + if (metaEntries[i]) metaMap.set(userPosts[i].uri, metaEntries[i]!); 548 + } 549 + 550 + // Filter to posts needing a refresh 551 + const fetchQueue: typeof userPosts = []; 552 + for (const post of userPosts) { 553 + // Skip posts with zero replies 554 + if ((post.replyCount ?? 0) === 0) continue; 555 + 556 + const meta = metaMap.get(post.uri); 557 + if (!meta) { 558 + fetchQueue.push(post); 559 + continue; 560 + } 561 + 562 + const postAge = post.record?.createdAt 563 + ? now - new Date(post.record.createdAt).getTime() 564 + : Infinity; 565 + const interval = getRefreshInterval(postAge); 566 + if (now - meta.repliesFetchedAt >= interval) { 567 + fetchQueue.push(post); 568 + } 569 + } 570 + 571 + // Process in batches of 5 572 + const BATCH_SIZE = 5; 573 + let backoff = 1000; 574 + 575 + for (let i = 0; i < fetchQueue.length; i += BATCH_SIZE) { 576 + if (myGen !== generation) return; 577 + 578 + const batch = fetchQueue.slice(i, i + BATCH_SIZE); 579 + const results = await Promise.all( 580 + batch.map((post) => 581 + publicClient 582 + .get('app.bsky.feed.getPostThread', { 583 + params: { uri: post.uri, depth: 1, parentHeight: 0 } as any 584 + }) 585 + .catch((e: any) => { 586 + if (e?.status === 429) return { ok: false, rateLimited: true } as any; 587 + console.error('Failed to fetch thread:', post.uri, e); 588 + return null; 589 + }) 590 + ) 591 + ); 592 + 593 + // Check for rate limiting 594 + const rateLimited = results.some((r: any) => r?.rateLimited); 595 + if (rateLimited) { 596 + await new Promise((resolve) => setTimeout(resolve, backoff)); 597 + backoff = Math.min(backoff * 2, 30000); 598 + i -= BATCH_SIZE; // Retry this batch 599 + continue; 600 + } 601 + backoff = 1000; // Reset backoff on success 602 + 603 + // Collect all reply posts from this batch 604 + const allReplyPosts: { post: any; parentIdx: number }[] = []; 605 + const metaToPut: any[] = []; 606 + 607 + for (let j = 0; j < results.length; j++) { 608 + const result = results[j]; 609 + if (!result?.ok) continue; 610 + 611 + const thread = result.data.thread; 612 + if (thread.$type !== 'app.bsky.feed.defs#threadViewPost') continue; 613 + 614 + const replies = (thread as any).replies ?? []; 615 + for (const reply of replies) { 616 + if (reply.$type !== 'app.bsky.feed.defs#threadViewPost' || !reply.post?.uri) continue; 617 + allReplyPosts.push({ post: reply.post, parentIdx: j }); 618 + } 619 + 620 + metaToPut.push({ uri: batch[j].uri, repliesFetchedAt: now }); 621 + } 622 + 623 + // Batch-lookup existing posts 624 + const replyUris = allReplyPosts.map((r) => r.post.uri); 625 + const existingPosts = await db.posts.bulkGet(replyUris); 626 + const toPut: any[] = []; 627 + 628 + for (let k = 0; k < allReplyPosts.length; k++) { 629 + const replyPost = allReplyPosts[k].post; 630 + const existing = existingPosts[k]; 631 + 632 + if (existing) { 633 + if (!existing.sources.includes('replies')) { 634 + toPut.push({ 635 + ...existing, 636 + sources: [...existing.sources, 'replies'], 637 + fetchedAt: now 638 + }); 639 + indexPost(s.index!, existing); 640 + s.count++; 641 + } 642 + } else { 643 + toPut.push({ ...replyPost, sources: ['replies'], savedAt: now, fetchedAt: now }); 644 + indexPost(s.index!, replyPost); 645 + s.count++; 646 + } 647 + } 648 + 649 + if (toPut.length > 0) await db.posts.bulkPut(toPut); 650 + if (metaToPut.length > 0) await db.threadMeta.bulkPut(metaToPut); 651 + await s.index!.commit(); 652 + } 653 + 654 + s.phase = 'done'; 655 + loadNext(myGen); 656 + } 657 + 504 658 // --- Filter helpers --- 505 659 506 660 function hasEmbedType(doc: any, type: string): boolean { ··· 649 803 } 650 804 }); 651 805 await db.meta.delete(source); 806 + if (source === 'replies') { 807 + await db.threadMeta.clear(); 808 + } 652 809 653 810 searchState.sources[source].index?.clear(); 654 811 searchState.sources[source].count = 0;
+6 -5
src/routes/search/Search.svelte
··· 26 26 pendingDids.add(did); 27 27 fetch(`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`) 28 28 .then((r) => r.json()) 29 - .then((data: { handle?: string }) => { 30 - if (data.handle) resolvedHandles.set(did, data.handle); 29 + .then((data) => { 30 + const { handle } = data as { handle?: string }; 31 + if (handle) resolvedHandles.set(did, handle); 31 32 }) 32 33 .catch(() => {}) 33 34 .finally(() => pendingDids.delete(did)); ··· 134 135 </a> 135 136 136 137 <Navbar class="top-12 mx-2 h-auto max-w-xl flex-col items-start py-3 sm:mx-auto md:top-10"> 137 - <div class="mx-2 mb-4 flex items-baseline gap-2"> 138 - <div class="flex gap-1"> 138 + <div class="mx-1 mb-3 flex items-baseline gap-1 sm:mx-2 sm:mb-4 sm:gap-2"> 139 + <div class="flex gap-0.5 sm:gap-1"> 139 140 {#each ALL_SOURCES as source (source)} 140 141 <button 141 - class="cursor-pointer rounded-full px-3 py-1 text-sm font-medium transition-colors {searchState.activeSource === 142 + class="cursor-pointer rounded-full px-2 py-0.5 text-xs font-medium transition-colors sm:px-3 sm:py-1 sm:text-sm {searchState.activeSource === 142 143 source 143 144 ? 'bg-accent-600 text-white' 144 145 : 'text-base-600 dark:text-base-400 hover:bg-base-200 dark:hover:bg-base-800'}"