grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

feat: add For You feed with collaborative filtering algorithm

Reverse-engineered from spacecowboy17's Bluesky For You feed playground.
Algorithm: find user's favorites → find co-likers → surface their other
favorites, scored with half-life decay, smoothing, and popularity penalty.
Falls back to most-favorited galleries for cold start users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+566 -2
+2 -1
app/lib/components/molecules/FeedTabs.svelte
··· 3 3 import { pinnedFeeds } from '$lib/preferences' 4 4 import { isAuthenticated } from '$lib/stores' 5 5 6 + const authOnlyFeeds = new Set(['following', 'foryou']) 6 7 const tabFeeds = $derived( 7 - $isAuthenticated ? $pinnedFeeds : $pinnedFeeds.filter((f) => f.id !== 'following') 8 + $isAuthenticated ? $pinnedFeeds : $pinnedFeeds.filter((f) => !authOnlyFeeds.has(f.id)) 8 9 ) 9 10 </script> 10 11
+3 -1
app/lib/preferences.ts
··· 1 1 import { writable, get } from "svelte/store"; 2 2 import { callXrpc } from "$hatk/client"; 3 - import { Images, Users, Camera, MapPin, Hash, Pin } from "lucide-svelte"; 3 + import { Images, Users, Camera, MapPin, Hash, Pin, Sparkles } from "lucide-svelte"; 4 4 5 5 export interface PinnedFeed { 6 6 id: string; ··· 12 12 export const DEFAULT_PINNED: PinnedFeed[] = [ 13 13 { id: "recent", label: "Recent", type: "feed", path: "/" }, 14 14 { id: "following", label: "Following", type: "feed", path: "/feeds/following" }, 15 + { id: "foryou", label: "For You", type: "feed", path: "/feeds/for-you" }, 15 16 ]; 16 17 17 18 /* eslint-disable @typescript-eslint/no-explicit-any */ 18 19 const CORE_ICONS: Record<string, any> = { 19 20 recent: Images, 20 21 following: Users, 22 + foryou: Sparkles, 21 23 }; 22 24 23 25 const TYPE_ICONS: Record<string, any> = {
+7
app/lib/queries.ts
··· 19 19 staleTime: 60_000, 20 20 }); 21 21 22 + export const forYouFeedQuery = (did: string, limit = 50, f?: Fetch) => 23 + queryOptions({ 24 + queryKey: ["getFeed", "foryou", did], 25 + queryFn: () => callXrpc("dev.hatk.getFeed", { feed: "foryou", actor: did, limit }, f), 26 + staleTime: 60_000, 27 + }); 28 + 22 29 export const actorFeedQuery = (did: string, limit = 50, f?: Fetch) => 23 30 queryOptions({ 24 31 queryKey: ["getFeed", "actor", did],
+29
app/routes/feeds/for-you/+page.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery } from '@tanstack/svelte-query' 3 + import FeedList from '$lib/components/organisms/FeedList.svelte' 4 + import FeedTabs from '$lib/components/molecules/FeedTabs.svelte' 5 + import { forYouFeedQuery } from '$lib/queries' 6 + import { viewer } from '$lib/stores' 7 + import OGMeta from '$lib/components/atoms/OGMeta.svelte' 8 + 9 + const feed = createQuery(() => forYouFeedQuery($viewer?.did ?? '')) 10 + </script> 11 + 12 + <OGMeta title="For You - grain" /> 13 + <FeedTabs /> 14 + {#if !$viewer?.did} 15 + <div class="empty">Log in to get personalized gallery recommendations.</div> 16 + {:else if feed.isLoading} 17 + <FeedList feed="foryou" params={{ actor: $viewer.did }} skeleton /> 18 + {:else} 19 + <FeedList feed="foryou" params={{ actor: $viewer.did }} initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} /> 20 + {/if} 21 + 22 + <style> 23 + .empty { 24 + text-align: center; 25 + color: var(--text-muted); 26 + padding: 48px 16px; 27 + font-size: 14px; 28 + } 29 + </style>
+11
app/routes/feeds/for-you/+page.ts
··· 1 + import { browser } from "$app/environment"; 2 + import { forYouFeedQuery } from "$lib/queries"; 3 + import type { PageLoad } from "./$types"; 4 + 5 + export const load: PageLoad = async ({ parent, fetch }) => { 6 + const { queryClient, viewer } = await parent(); 7 + if (viewer?.did) { 8 + const prefetch = queryClient.prefetchQuery(forYouFeedQuery(viewer.did, 50, fetch)); 9 + if (!browser) await prefetch; 10 + } 11 + };
+60
docs/plans/2026-04-08-for-you-feed-design.md
··· 1 + # For You Feed Design 2 + 3 + Collaborative filtering feed for Grain, reverse-engineered from the Bluesky For You feed playground by spacecowboy17. 4 + 5 + ## Algorithm 6 + 7 + ### Personalized mode (user has favorites) 8 + 9 + 1. **Seed**: Get user's 500 most recent favorites 10 + 2. **Co-likers**: Find other users who also favorited those same galleries 11 + 3. **Candidates**: Collect galleries those co-likers favorited (excluding user's own favorites and own galleries) 12 + 4. **Score** each candidate: 13 + 14 + ``` 15 + For each path (co-liker → candidate gallery): 16 + path_score = 1 / (co_liker_total_likes ^ divisor_power) 17 + path_score *= (1 - corater_decay) ^ (items_from_this_corater - 1) 18 + 19 + base_score = sum(path_scores) 20 + smoothed = base_score * (num_paths ^ smoothing_factor) 21 + time_factor = 0.5 ^ (age_hours / half_life) // half_life=0 disables decay 22 + final_score = smoothed * time_factor / (total_favorites ^ popularity_penalty) 23 + ``` 24 + 25 + 5. **Rank** by final_score descending, paginate with cursor 26 + 27 + ### Cold start fallback (no favorites) 28 + 29 + Return galleries ordered by favorite count descending, filtered to last 30 days. 30 + 31 + ## Default Parameters 32 + 33 + | Parameter | Default | Description | 34 + |-----------|---------|-------------| 35 + | half_life | 6 | Hours; demotes older posts | 36 + | smoothing_factor | 0.5 | Boosts multi-path posts | 37 + | popularity_penalty | 0.3 | Demotes generally popular posts | 38 + | divisor_power | 1.0 | Divides by co-liker's total likes | 39 + | corater_decay | 0 | Decay for same co-liker (0=none) | 40 + | time_shift | 24 | Hours; co-like contribution window | 41 + | seed_limit | 500 | Max seed favorites | 42 + 43 + ## Files 44 + 45 + - `server/feeds/foryou.ts` — feed generator with scoring logic 46 + - `app/lib/queries.ts` — add forYouFeedQuery 47 + - `app/routes/feeds/for-you/+page.ts` — page load 48 + - `app/routes/feeds/for-you/+page.svelte` — feed UI 49 + - `app/lib/preferences.ts` — add to DEFAULT_PINNED 50 + 51 + ## Pagination 52 + 53 + Score-based cursor: encode `score:rkey` as cursor string. Since scores are computed in JS (not SQL), fetch all candidates, score, sort, then slice by cursor position. For v1, use offset-based cursor to keep it simple. 54 + 55 + ## Filters 56 + 57 + - Exclude galleries user already favorited 58 + - Exclude user's own galleries 59 + - Respect takedown status and hidden labels 60 + - Require gallery to have at least 1 item
+198
seeds/seed.ts
··· 461 461 { rkey: "exif-portrait" }, 462 462 ); 463 463 464 + // ── Additional galleries for For You feed testing ── 465 + 466 + // Bob's second gallery: "Mountain Dawn" 467 + const mountainDawn = await createRecord( 468 + bob, 469 + "social.grain.gallery", 470 + { 471 + title: "Mountain Dawn", 472 + description: "First light on the peaks #nature #landscape", 473 + createdAt: ago(15), 474 + }, 475 + { rkey: "mountain-dawn" }, 476 + ); 477 + const mdPhoto = await createRecord( 478 + bob, 479 + "social.grain.photo", 480 + { photo: forest, alt: "Mountain at sunrise", aspectRatio: { width: 4, height: 3 }, createdAt: ago(15) }, 481 + { rkey: "photo-mountain" }, 482 + ); 483 + await createRecord( 484 + bob, 485 + "social.grain.gallery.item", 486 + { gallery: mountainDawn.uri, item: mdPhoto.uri, position: 0, createdAt: ago(15) }, 487 + { rkey: "gi-mountain" }, 488 + ); 489 + 490 + // Carol's second gallery: "Rainy Days" 491 + const rainyDays = await createRecord( 492 + carol, 493 + "social.grain.gallery", 494 + { 495 + title: "Rainy Days", 496 + description: "Puddles and reflections #streetphotography #film", 497 + createdAt: ago(12), 498 + }, 499 + { rkey: "rainy-days" }, 500 + ); 501 + const rdPhoto = await createRecord( 502 + carol, 503 + "social.grain.photo", 504 + { photo: filmCafe, alt: "Rain on cobblestones", aspectRatio: { width: 3, height: 4 }, createdAt: ago(12) }, 505 + { rkey: "photo-rain" }, 506 + ); 507 + await createRecord( 508 + carol, 509 + "social.grain.gallery.item", 510 + { gallery: rainyDays.uri, item: rdPhoto.uri, position: 0, createdAt: ago(12) }, 511 + { rkey: "gi-rain" }, 512 + ); 513 + 514 + // Carol's third gallery: "Golden Hour Portraits" 515 + const goldenPortraits = await createRecord( 516 + carol, 517 + "social.grain.gallery", 518 + { 519 + title: "Golden Hour Portraits", 520 + description: "Warm light and soft shadows #portrait #film", 521 + createdAt: ago(8), 522 + }, 523 + { rkey: "golden-portraits" }, 524 + ); 525 + const gpPhoto = await createRecord( 526 + carol, 527 + "social.grain.photo", 528 + { photo: filmPortrait, alt: "Portrait in golden light", aspectRatio: { width: 3, height: 4 }, createdAt: ago(8) }, 529 + { rkey: "photo-golden" }, 530 + ); 531 + await createRecord( 532 + carol, 533 + "social.grain.gallery.item", 534 + { gallery: goldenPortraits.uri, item: gpPhoto.uri, position: 0, createdAt: ago(8) }, 535 + { rkey: "gi-golden" }, 536 + ); 537 + 538 + // Dave's gallery: "Concrete Jungle" 539 + const concreteJungle = await createRecord( 540 + dave, 541 + "social.grain.gallery", 542 + { 543 + title: "Concrete Jungle", 544 + description: "Brutalist architecture up close #architecture #city", 545 + createdAt: ago(18), 546 + }, 547 + { rkey: "concrete-jungle" }, 548 + ); 549 + const cjPhoto = await uploadBlob(dave, "./seeds/images/skyline.jpg"); 550 + const cjPhotoRec = await createRecord( 551 + dave, 552 + "social.grain.photo", 553 + { photo: cjPhoto, alt: "Concrete facade", aspectRatio: { width: 4, height: 3 }, createdAt: ago(18) }, 554 + { rkey: "photo-concrete" }, 555 + ); 556 + await createRecord( 557 + dave, 558 + "social.grain.gallery.item", 559 + { gallery: concreteJungle.uri, item: cjPhotoRec.uri, position: 0, createdAt: ago(18) }, 560 + { rkey: "gi-concrete" }, 561 + ); 562 + 563 + // Dave's second gallery: "Night Architecture" 564 + const nightArch = await createRecord( 565 + dave, 566 + "social.grain.gallery", 567 + { 568 + title: "Night Architecture", 569 + description: "Buildings after dark #architecture #city #night", 570 + createdAt: ago(7), 571 + }, 572 + { rkey: "night-architecture" }, 573 + ); 574 + const naPhoto = await uploadBlob(dave, "./seeds/images/city-night.jpg"); 575 + const naPhotoRec = await createRecord( 576 + dave, 577 + "social.grain.photo", 578 + { photo: naPhoto, alt: "Building lit up at night", aspectRatio: { width: 4, height: 3 }, createdAt: ago(7) }, 579 + { rkey: "photo-night-arch" }, 580 + ); 581 + await createRecord( 582 + dave, 583 + "social.grain.gallery.item", 584 + { gallery: nightArch.uri, item: naPhotoRec.uri, position: 0, createdAt: ago(7) }, 585 + { rkey: "gi-night-arch" }, 586 + ); 587 + 464 588 // ── Favorites ── 589 + // Create a web of favorites that gives the collaborative filtering algorithm signal. 590 + // 591 + // Alice favorites: Bob's Forest Trail, Carol's Kodak Moments 592 + // Bob favorites: Alice's City Lights, Carol's Rainy Days, Dave's Concrete Jungle 593 + // Carol favorites: Alice's City Lights, Bob's Forest Trail, Dave's Night Architecture 594 + // Dave favorites: Alice's City Lights, Bob's Forest Trail, Bob's Mountain Dawn, Carol's Golden Portraits 595 + // 596 + // For Alice: co-likers on Forest Trail = Carol, Dave. Co-likers on Kodak Moments = (none extra). 597 + // Carol also liked: City Lights (Alice's own, skip), Dave's Night Architecture → candidate! 598 + // Dave also liked: City Lights (Alice's own, skip), Mountain Dawn → candidate!, Golden Portraits → candidate! 465 599 466 600 // Alice favorites Bob's gallery 467 601 await createRecord( ··· 471 605 { rkey: "fav-forest" }, 472 606 ); 473 607 608 + // Alice favorites Carol's gallery 609 + await createRecord( 610 + alice, 611 + "social.grain.favorite", 612 + { subject: carolGallery.uri, createdAt: ago(6) }, 613 + { rkey: "fav-kodak" }, 614 + ); 615 + 474 616 // Bob favorites Alice's gallery 475 617 await createRecord( 476 618 bob, ··· 479 621 { rkey: "fav-city" }, 480 622 ); 481 623 624 + // Bob favorites Carol's Rainy Days 625 + await createRecord( 626 + bob, 627 + "social.grain.favorite", 628 + { subject: rainyDays.uri, createdAt: ago(10) }, 629 + { rkey: "fav-rainy" }, 630 + ); 631 + 632 + // Bob favorites Dave's Concrete Jungle 633 + await createRecord( 634 + bob, 635 + "social.grain.favorite", 636 + { subject: concreteJungle.uri, createdAt: ago(14) }, 637 + { rkey: "fav-concrete" }, 638 + ); 639 + 482 640 // Carol favorites Alice's gallery 483 641 await createRecord( 484 642 carol, ··· 493 651 "social.grain.favorite", 494 652 { subject: bobGallery.uri, createdAt: ago(20) }, 495 653 { rkey: "fav-forest-2" }, 654 + ); 655 + 656 + // Carol favorites Dave's Night Architecture 657 + await createRecord( 658 + carol, 659 + "social.grain.favorite", 660 + { subject: nightArch.uri, createdAt: ago(5) }, 661 + { rkey: "fav-night-arch" }, 662 + ); 663 + 664 + // Dave favorites Alice's gallery 665 + await createRecord( 666 + dave, 667 + "social.grain.favorite", 668 + { subject: aliceGallery.uri, createdAt: ago(16) }, 669 + { rkey: "fav-city-3" }, 670 + ); 671 + 672 + // Dave favorites Bob's Forest Trail 673 + await createRecord( 674 + dave, 675 + "social.grain.favorite", 676 + { subject: bobGallery.uri, createdAt: ago(14) }, 677 + { rkey: "fav-forest-3" }, 678 + ); 679 + 680 + // Dave favorites Bob's Mountain Dawn 681 + await createRecord( 682 + dave, 683 + "social.grain.favorite", 684 + { subject: mountainDawn.uri, createdAt: ago(11) }, 685 + { rkey: "fav-mountain" }, 686 + ); 687 + 688 + // Dave favorites Carol's Golden Hour Portraits 689 + await createRecord( 690 + dave, 691 + "social.grain.favorite", 692 + { subject: goldenPortraits.uri, createdAt: ago(4) }, 693 + { rkey: "fav-golden" }, 496 694 ); 497 695 498 696 // ── Comments ──
+256
server/feeds/foryou.ts
··· 1 + import { defineFeed } from "$hatk"; 2 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 + 5 + // ─── Scoring parameters (spacecowboy17's optimized A/B values) ─────── 6 + const HALF_LIFE_HOURS = 6; 7 + const SMOOTHING_FACTOR = 0.5; 8 + const POPULARITY_PENALTY = 0.3; 9 + const DIVISOR_POWER = 1.0; 10 + const CORATER_DECAY = 0; 11 + const TIME_SHIFT_HOURS = 24; 12 + const SEED_LIMIT = 500; 13 + const MAX_COLIKERS = 1000; 14 + const PAGE_SIZE = 50; 15 + 16 + export default defineFeed({ 17 + collection: "social.grain.gallery", 18 + label: "For You", 19 + 20 + hydrate: hydrateGalleries, 21 + 22 + async generate(ctx) { 23 + const actor = ctx.params.actor; 24 + if (!actor) return ctx.ok({ uris: [] }); 25 + 26 + const limit = Math.min(Number(ctx.params.limit) || PAGE_SIZE, PAGE_SIZE); 27 + const offset = Number(ctx.params.cursor) || 0; 28 + 29 + // Step 1: Get user's recent favorites (seed) 30 + const seedRows = (await ctx.db.query( 31 + `SELECT f.subject AS gallery_uri, f.created_at 32 + FROM "social.grain.favorite" f 33 + WHERE f.did = $1 34 + ORDER BY f.created_at DESC 35 + LIMIT $2`, 36 + [actor, SEED_LIMIT], 37 + )) as { gallery_uri: string; created_at: string }[]; 38 + 39 + // Cold start: fall back to popular galleries 40 + if (seedRows.length === 0) { 41 + return coldStartFeed(ctx, actor, limit, offset); 42 + } 43 + 44 + const seedUris = seedRows.map((r) => r.gallery_uri); 45 + 46 + // Build a map of seed gallery → when the user liked it 47 + const seedLikeTime = new Map<string, number>(); 48 + for (const row of seedRows) { 49 + seedLikeTime.set(row.gallery_uri, new Date(row.created_at).getTime()); 50 + } 51 + 52 + // Step 2: Find co-likers (people who favorited the same galleries) 53 + const placeholders = seedUris.map((_, i) => `$${i + 2}`).join(", "); 54 + const colikerRows = (await ctx.db.query( 55 + `SELECT f.did AS coliker, f.subject AS gallery_uri, f.created_at 56 + FROM "social.grain.favorite" f 57 + WHERE f.subject IN (${placeholders}) 58 + AND f.did != $1 59 + ORDER BY f.created_at DESC`, 60 + [actor, ...seedUris], 61 + )) as { coliker: string; gallery_uri: string; created_at: string }[]; 62 + 63 + // Filter co-likers: only those who liked before the user (unless ignore_order) 64 + // + apply time_shift window 65 + const validColikers = new Set<string>(); 66 + const colikerSeedPairs = new Map<string, Set<string>>(); // coliker → set of shared galleries 67 + 68 + for (const row of colikerRows) { 69 + const userLikeTime = seedLikeTime.get(row.gallery_uri); 70 + if (!userLikeTime) continue; 71 + 72 + const colikeTime = new Date(row.created_at).getTime(); 73 + // Only consider likes before the user's like (+ time_shift window) 74 + if (colikeTime <= userLikeTime + TIME_SHIFT_HOURS * 3600_000) { 75 + validColikers.add(row.coliker); 76 + if (!colikerSeedPairs.has(row.coliker)) { 77 + colikerSeedPairs.set(row.coliker, new Set()); 78 + } 79 + colikerSeedPairs.get(row.coliker)!.add(row.gallery_uri); 80 + } 81 + } 82 + 83 + if (validColikers.size === 0) { 84 + return coldStartFeed(ctx, actor, limit, offset); 85 + } 86 + 87 + // Step 3: Get co-likers' other favorites (candidates) + their total like counts 88 + // Cap co-likers, preferring those with more overlap 89 + const colikerList = [...validColikers] 90 + .sort((a, b) => (colikerSeedPairs.get(b)?.size ?? 0) - (colikerSeedPairs.get(a)?.size ?? 0)) 91 + .slice(0, MAX_COLIKERS); 92 + 93 + const colikerPlaceholders = colikerList.map((_, i) => `$${i + 2}`).join(", "); 94 + const seedPlaceholders = seedUris.map((_, i) => `$${i + 2 + colikerList.length}`).join(", "); 95 + 96 + const likeCountPlaceholders = colikerList.map((_, i) => `$${i + 1}`).join(", "); 97 + 98 + const [candidateRows, likeCounts] = await Promise.all([ 99 + ctx.db.query( 100 + `SELECT f.did AS coliker, f.subject AS gallery_uri, t.created_at AS gallery_created_at 101 + FROM "social.grain.favorite" f 102 + JOIN "social.grain.gallery" t ON t.uri = f.subject 103 + LEFT JOIN _repos r ON t.did = r.did 104 + WHERE f.did IN (${colikerPlaceholders}) 105 + AND f.subject NOT IN (${seedPlaceholders}) 106 + AND t.did != $1 107 + AND (r.status IS NULL OR r.status != 'takendown') 108 + AND ${hideLabelsFilter("t.uri")} 109 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 110 + [actor, ...colikerList, ...seedUris], 111 + ) as Promise<{ coliker: string; gallery_uri: string; gallery_created_at: string }[]>, 112 + 113 + ctx.db.query( 114 + `SELECT did, COUNT(*) as cnt 115 + FROM "social.grain.favorite" 116 + WHERE did IN (${likeCountPlaceholders}) 117 + GROUP BY did`, 118 + colikerList, 119 + ) as Promise<{ did: string; cnt: number }[]>, 120 + ]); 121 + 122 + // Build co-liker total likes map 123 + const colikerTotalLikes = new Map<string, number>(); 124 + for (const row of likeCounts) { 125 + colikerTotalLikes.set(row.did, Number(row.cnt)); 126 + } 127 + 128 + // Step 4: Score each candidate gallery 129 + const now = Date.now(); 130 + const galleryScores = new Map< 131 + string, 132 + { score: number; paths: number; created_at: string; coratersSeenCount: Map<string, number> } 133 + >(); 134 + 135 + // Get popularity (total favorites) for candidate galleries 136 + const candidateUris = [...new Set(candidateRows.map((r) => r.gallery_uri))]; 137 + 138 + const popularityMap = new Map<string, number>(); 139 + if (candidateUris.length > 0) { 140 + const popPlaceholders = candidateUris.map((_, i) => `$${i + 1}`).join(", "); 141 + const popRows = (await ctx.db.query( 142 + `SELECT subject, COUNT(*) as cnt 143 + FROM "social.grain.favorite" 144 + WHERE subject IN (${popPlaceholders}) 145 + GROUP BY subject`, 146 + candidateUris, 147 + )) as { subject: string; cnt: number }[]; 148 + for (const row of popRows) { 149 + popularityMap.set(row.subject, Number(row.cnt)); 150 + } 151 + } 152 + 153 + for (const row of candidateRows) { 154 + const totalLikes = colikerTotalLikes.get(row.coliker) || 1; 155 + 156 + let entry = galleryScores.get(row.gallery_uri); 157 + if (!entry) { 158 + entry = { 159 + score: 0, 160 + paths: 0, 161 + created_at: row.gallery_created_at, 162 + coratersSeenCount: new Map(), 163 + }; 164 + galleryScores.set(row.gallery_uri, entry); 165 + } 166 + 167 + // Track how many items we've seen from this corater (for decay) 168 + const seenCount = entry.coratersSeenCount.get(row.coliker) || 0; 169 + entry.coratersSeenCount.set(row.coliker, seenCount + 1); 170 + 171 + // Path score: 1 / total_likes^divisor_power 172 + let pathScore = 1 / Math.pow(totalLikes, DIVISOR_POWER); 173 + 174 + // Corater decay 175 + if (CORATER_DECAY > 0) { 176 + pathScore *= Math.pow(1 - CORATER_DECAY, seenCount); 177 + } 178 + 179 + entry.score += pathScore; 180 + entry.paths += 1; 181 + } 182 + 183 + // Apply smoothing, time decay, and popularity penalty 184 + const scored: { uri: string; score: number }[] = []; 185 + 186 + for (const [uri, entry] of galleryScores) { 187 + let score = entry.score; 188 + 189 + // Smoothing: boost multi-path galleries 190 + if (SMOOTHING_FACTOR > 0) { 191 + score *= Math.pow(entry.paths, SMOOTHING_FACTOR); 192 + } 193 + 194 + // Time decay 195 + if (HALF_LIFE_HOURS > 0) { 196 + const ageHours = (now - new Date(entry.created_at).getTime()) / 3_600_000; 197 + score *= Math.pow(0.5, ageHours / HALF_LIFE_HOURS); 198 + } 199 + 200 + // Popularity penalty 201 + if (POPULARITY_PENALTY > 0) { 202 + const popularity = popularityMap.get(uri) || 1; 203 + score /= Math.pow(popularity, POPULARITY_PENALTY); 204 + } 205 + 206 + scored.push({ uri, score }); 207 + } 208 + 209 + // Sort by score descending 210 + scored.sort((a, b) => b.score - a.score); 211 + 212 + // Paginate 213 + const page = scored.slice(offset, offset + limit); 214 + const nextCursor = offset + limit < scored.length ? String(offset + limit) : undefined; 215 + 216 + return ctx.ok({ 217 + uris: page.map((r) => r.uri), 218 + cursor: nextCursor, 219 + }); 220 + }, 221 + }); 222 + 223 + // ─── Cold start: most-favorited galleries from last 30 days ────────── 224 + async function coldStartFeed( 225 + ctx: Parameters<Parameters<typeof defineFeed>[0]["generate"]>[0], 226 + actor: string, 227 + limit: number, 228 + offset: number, 229 + ) { 230 + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 3600_000).toISOString(); 231 + 232 + const rows = (await ctx.db.query( 233 + `SELECT t.uri, COUNT(f.did) as fav_count 234 + FROM "social.grain.gallery" t 235 + LEFT JOIN "social.grain.favorite" f ON f.subject = t.uri 236 + LEFT JOIN _repos r ON t.did = r.did 237 + WHERE (r.status IS NULL OR r.status != 'takendown') 238 + AND t.did != $1 239 + AND t.created_at > $2 240 + AND ${hideLabelsFilter("t.uri")} 241 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 242 + GROUP BY t.uri 243 + ORDER BY fav_count DESC, t.created_at DESC 244 + LIMIT $3 OFFSET $4`, 245 + [actor, thirtyDaysAgo, limit, offset], 246 + )) as { uri: string; fav_count: number }[]; 247 + 248 + // Check if there are more results 249 + const hasMore = rows.length === limit; 250 + const nextCursor = hasMore ? String(offset + limit) : undefined; 251 + 252 + return ctx.ok({ 253 + uris: rows.map((r) => r.uri), 254 + cursor: nextCursor, 255 + }); 256 + }