grain.social is a photo sharing platform built on atproto.
grain.social
atproto
photography
appview
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});