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

Configure Feed

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

at main 164 lines 6.2 kB view raw
1import { defineQuery } from "$hatk"; 2import type { GrainActorProfile, Photo } from "$hatk"; 3import { views } from "$hatk"; 4import { NOT_ORPHANED } from "../hydrate/comments.ts"; 5import { blockFilter } from "../filters/blockMute.ts"; 6import { lookupHandles } from "../helpers/lookupHandles.ts"; 7 8export default defineQuery("social.grain.unspecced.getCommentThread", async (ctx) => { 9 const { ok, params, db, lookup, blobUrl, getRecords, viewer } = ctx; 10 const { subject, limit = 20, cursor } = params; 11 12 const viewerDid = viewer?.did; 13 14 // Build block filter — blocked comments are removed entirely 15 const countParams: any[] = [subject]; 16 let countBmParam = ""; 17 if (viewerDid) { 18 countParams.push(viewerDid); 19 countBmParam = `AND ${blockFilter("c.did", `$${countParams.length}`)}`; 20 } 21 22 // Count total comments for this subject, excluding orphaned replies 23 const countRows = (await db.query( 24 `SELECT count(*) as cnt FROM "social.grain.comment" c 25 WHERE c.subject = $1 AND ${NOT_ORPHANED} ${countBmParam}`, 26 countParams, 27 )) as { cnt: number }[]; 28 const totalCount = countRows[0]?.cnt ?? 0; 29 30 // Fetch comments with cursor-based pagination (oldest first), excluding orphaned replies 31 const queryParams: any[] = [subject]; 32 let query = `SELECT c.uri, c.did, c.cid, c.text, c.facets, c.focus, c.reply_to, c.created_at 33 FROM "social.grain.comment" c 34 WHERE c.subject = $1 AND ${NOT_ORPHANED}`; 35 36 if (cursor) { 37 query += ` AND c.created_at > $2`; 38 queryParams.push(cursor); 39 } 40 41 if (viewerDid) { 42 queryParams.push(viewerDid); 43 query += ` AND ${blockFilter("c.did", `$${queryParams.length}`)}`; 44 } 45 46 query += ` ORDER BY c.created_at ASC LIMIT $${queryParams.length + 1}`; 47 queryParams.push(limit + 1); // fetch one extra for cursor 48 49 const rows = (await db.query(query, queryParams)) as Array<{ 50 uri: string; 51 did: string; 52 cid: string; 53 text: string; 54 facets: string | null; 55 focus: string | null; 56 reply_to: string | null; 57 created_at: string; 58 }>; 59 60 const hasMore = rows.length > limit; 61 const items = hasMore ? rows.slice(0, limit) : rows; 62 const nextCursor = hasMore ? items[items.length - 1]?.created_at : undefined; 63 64 // Hydrate author profiles 65 const dids = [...new Set(items.map((r) => r.did))]; 66 const [profiles, handleMap] = await Promise.all([ 67 lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids), 68 lookupHandles(db, dids), 69 ]); 70 71 // Check which comment authors the viewer has muted 72 let mutedDids = new Set<string>(); 73 if (viewerDid && dids.length > 0) { 74 const ph = dids.map((_, i) => `$${i + 2}`).join(","); 75 const mutedRows = (await db.query( 76 `SELECT subject FROM _mutes WHERE did = $1 AND subject IN (${ph})`, 77 [viewerDid, ...dids], 78 )) as { subject: string }[]; 79 mutedDids = new Set(mutedRows.map((r) => r.subject)); 80 } 81 82 // Hydrate focus photos 83 const focusUris = items.map((r) => r.focus).filter(Boolean) as string[]; 84 const focusPhotos = 85 focusUris.length > 0 ? await getRecords<Photo>("social.grain.photo", focusUris) : new Map(); 86 87 // Hydrate comment favorite counts and viewer favorites 88 const commentUris = items.map((r) => r.uri); 89 const [favCounts, viewerFavs] = await Promise.all([ 90 commentUris.length > 0 91 ? ( 92 db.query( 93 `SELECT subject, COUNT(DISTINCT did) as count FROM "social.grain.favorite" 94 WHERE subject IN (${commentUris.map((_, i) => `$${i + 1}`).join(",")}) GROUP BY subject`, 95 commentUris, 96 ) as Promise<{ subject: string; count: number }[]> 97 ).then((rows) => { 98 const m = new Map<string, number>(); 99 for (const r of rows) m.set(r.subject, Number(r.count)); 100 return m; 101 }) 102 : Promise.resolve(new Map<string, number>()), 103 viewerDid && commentUris.length > 0 104 ? ( 105 db.query( 106 `SELECT subject, uri FROM "social.grain.favorite" 107 WHERE did = $1 AND subject IN (${commentUris.map((_, i) => `$${i + 2}`).join(",")})`, 108 [viewerDid, ...commentUris], 109 ) as Promise<{ subject: string; uri: string }[]> 110 ).then((rows) => { 111 const m = new Map<string, string>(); 112 for (const r of rows) m.set(r.subject, r.uri); 113 return m; 114 }) 115 : Promise.resolve(new Map<string, string>()), 116 ]); 117 118 const comments = items.map((row) => { 119 const author = profiles.get(row.did); 120 const parsedFacets = row.facets ? JSON.parse(row.facets) : undefined; 121 const focusPhoto = row.focus ? focusPhotos.get(row.focus) : null; 122 123 return { 124 ...views.commentView({ 125 uri: row.uri, 126 cid: row.cid, 127 text: row.text, 128 facets: parsedFacets, 129 replyTo: row.reply_to ?? undefined, 130 createdAt: row.created_at, 131 author: author 132 ? views.grainActorDefsProfileView({ 133 cid: author.cid, 134 did: author.did, 135 handle: author.handle ?? handleMap.get(author.did) ?? author.did, 136 displayName: author.value.displayName, 137 avatar: blobUrl(author.did, author.value.avatar) ?? undefined, 138 }) 139 : views.grainActorDefsProfileView({ 140 cid: row.cid, 141 did: row.did, 142 handle: handleMap.get(row.did) ?? row.did, 143 }), 144 ...(focusPhoto 145 ? { 146 focus: views.photoView({ 147 uri: focusPhoto.uri, 148 cid: focusPhoto.cid, 149 thumb: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_thumbnail") ?? "", 150 fullsize: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_fullsize") ?? "", 151 alt: focusPhoto.value.alt, 152 aspectRatio: focusPhoto.value.aspectRatio ?? { width: 4, height: 3 }, 153 }), 154 } 155 : {}), 156 }), 157 favCount: favCounts.get(row.uri) ?? 0, 158 ...(viewerFavs.has(row.uri) ? { viewer: { fav: viewerFavs.get(row.uri) } } : {}), 159 ...(mutedDids.has(row.did) ? { muted: true } : {}), 160 }; 161 }); 162 163 return ok({ comments, ...(nextCursor ? { cursor: nextCursor } : {}), totalCount }); 164});