0
2
import { views } from "$hatk";
import type { GrainActorProfile, Photo, Gallery, Label, GroupRef } from "$hatk";
import type { PhotoView, GalleryView, ExifView } from "$hatk";
import type { BaseContext, Row } from "$hatk";
import { countComments } from "./comments.ts";
import { blockFilter } from "../filters/blockMute.ts";
import { formatStoredLocation } from "../helpers/formatLocation.ts";
import { lookupHandles } from "../helpers/lookupHandles.ts";
const SCALE = 1_000_000;
/** Max facepile avatars returned per gallery for "favorited by people you follow". */
const FACEPILE_LIMIT = 3;
interface ExifRow {
uri: string;
cid: string;
photo: string;
created_at: string;
date_time_original?: string;
exposure_time?: number;
f_number?: number;
flash?: string;
focal_length_in35mm_format?: number;
i_s_o?: number;
lens_make?: string;
lens_model?: string;
make?: string;
model?: string;
}
function formatExposureTime(scaled: number): string {
const val = scaled / SCALE;
if (val >= 1) return `${val}s`;
return `1/${Math.round(1 / val)}s`;
}
function formatFNumber(scaled: number): string {
return `f/${(scaled / SCALE).toFixed(1)}`;
}
function formatFocalLength(scaled: number): string {
return `${Math.round(scaled / SCALE)}mm`;
}
function buildExifView(row: ExifRow): ExifView {
return views.exifView({
uri: row.uri,
cid: row.cid,
photo: row.photo,
createdAt: row.created_at,
...(row.date_time_original ? { dateTimeOriginal: row.date_time_original } : {}),
...(row.exposure_time ? { exposureTime: formatExposureTime(row.exposure_time) } : {}),
...(row.f_number ? { fNumber: formatFNumber(row.f_number) } : {}),
...(row.flash ? { flash: row.flash } : {}),
...(row.focal_length_in35mm_format
? { focalLengthIn35mmFormat: formatFocalLength(row.focal_length_in35mm_format) }
: {}),
...(row.i_s_o ? { iSO: Math.round(row.i_s_o / SCALE) } : {}),
...(row.lens_make ? { lensMake: row.lens_make } : {}),
...(row.lens_model ? { lensModel: row.lens_model } : {}),
...(row.make ? { make: row.make } : {}),
...(row.model ? { model: row.model } : {}),
});
}
/**
* Look up Bluesky cross-posts for a set of grain URIs by searching bsky post text.
*
* A post may name the gallery by the author's handle or by DID. Matching on
* the `/gallery/<rkey>` tail within the author's own posts covers both: an
* rkey is unique inside one account's collection, and only that account's
* posts are searched.
*/
export async function lookupCrossPosts(
db: BaseContext["db"],
items: Array<{ uri: string; did: string }>,
collection: "gallery" | "story",
): Promise<Map<string, string>> {
const map = new Map<string, string>();
for (const item of items) {
const rkey = item.uri.split("/").pop();
const tail = `/${collection}/${rkey}`;
const rows = (await db.query(
`SELECT uri FROM "app.bsky.feed.post" WHERE did = $1 AND "text" LIKE '%/profile/%' || $2 || '%' LIMIT 1`,
[item.did, tail],
)) as Array<{ uri: string }>;
if (rows.length) {
const postRkey = rows[0].uri.split("/").pop();
map.set(item.uri, `https://bsky.app/profile/${item.did}/post/${postRkey}`);
}
}
return map;
}
/** Shared hydration for gallery feeds — resolves photos, profiles, fav/comment counts. */
export async function hydrateGalleries(
ctx: BaseContext,
items: Row<Gallery>[],
): Promise<GalleryView[]> {
const galleryUris = items.map((item) => item.uri);
// Resolve viewer favorites
const viewerFavs = new Map<string, string>();
if (ctx.viewer?.did && galleryUris.length > 0) {
const favRows = (await ctx.db.query(
`SELECT subject, uri FROM "social.grain.favorite"
WHERE did = $1 AND subject IN (${galleryUris.map((_, i) => `$${i + 2}`).join(",")})`,
[ctx.viewer.did, ...galleryUris],
)) as { subject: string; uri: string }[];
for (const row of favRows) viewerFavs.set(row.subject, row.uri);
}
// Resolve the "favorited by people you follow" facepile. ROW_NUMBER caps the
// result at FACEPILE_LIMIT rows *per gallery* — without it a viral gallery
// plus a large follow graph returns thousands of rows to render 3 avatars.
//
// The inner GROUP BY collapses to one row per (subject, did) BEFORE ranking.
// Two things duplicate a did for a gallery otherwise: an account can hold
// more than one favorite record for the same subject (which is why favCount
// counts DISTINCT did), and the follow join fans out when the viewer holds
// more than one follow record for that account. Both occur in prod. A
// repeated did here renders two avatars keyed alike, and Facepile's keyed
// {#each} throws each_key_duplicate, which blanks the whole feed.
const favedByFollowing = new Map<string, string[]>();
if (ctx.viewer?.did && galleryUris.length > 0) {
const placeholders = galleryUris.map((_, i) => `$${i + 2}`).join(",");
const rows = (await ctx.db.query(
`SELECT subject, did FROM (
SELECT subject, did,
ROW_NUMBER() OVER (PARTITION BY subject ORDER BY last_fav_at DESC) AS rn
FROM (
SELECT f.subject AS subject, f.did AS did, MAX(f.created_at) AS last_fav_at
FROM "social.grain.favorite" f
JOIN "social.grain.graph.follow" fo ON fo.did = $1 AND fo.subject = f.did
LEFT JOIN _repos r ON r.did = f.did
WHERE f.subject IN (${placeholders}) AND f.did <> $1
AND (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
AND ${blockFilter("f.did", "$1")}
GROUP BY f.subject, f.did
) distinct_favers
) ranked
WHERE rn <= ${FACEPILE_LIMIT}`,
[ctx.viewer.did, ...galleryUris],
)) as { subject: string; did: string }[];
for (const row of rows) {
const list = favedByFollowing.get(row.subject) ?? [];
list.push(row.did);
favedByFollowing.set(row.subject, list);
}
}
// Which pool, if any, each gallery sits in. A gallery in two pools shows
// the most recent; the card has one line for it.
const groupByGallery = new Map<string, { did: string; item: string }>();
if (galleryUris.length > 0) {
const rows = (await ctx.db.query(
`SELECT gi.gallery, gi.did, gi.uri FROM "social.grain.group.item" gi
JOIN "social.grain.gallery" g ON g.uri = gi.gallery
WHERE gi.gallery IN (${galleryUris.map((_, i) => `$${i + 1}`).join(",")})
ORDER BY gi.created_at ASC`,
galleryUris,
)) as { gallery: string; did: string; uri: string }[];
for (const row of rows) groupByGallery.set(row.gallery, { did: row.did, item: row.uri });
}
// Creators, facepile members and pooling groups — one batched profile
// lookup covers all three.
const dids = [
...new Set([
...items.map((item) => item.did).filter(Boolean),
...[...favedByFollowing.values()].flat(),
...[...groupByGallery.values()].map((g) => g.did),
]),
];
const [profiles, handleMap, favCounts, commentCounts, labelsByUri, galleryItemRows, crossPosts] =
await Promise.all([
ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids),
lookupHandles(ctx.db, dids),
galleryUris.length > 0
? (
ctx.db.query(
`SELECT subject, COUNT(DISTINCT did) as count FROM "social.grain.favorite"
WHERE subject IN (${galleryUris.map((_, i) => `$${i + 1}`).join(",")}) GROUP BY subject`,
galleryUris,
) as Promise<{ subject: string; count: number }[]>
).then((rows) => {
const m = new Map<string, number>();
for (const r of rows) m.set(r.subject, Number(r.count));
return m;
})
: Promise.resolve(new Map<string, number>()),
countComments(ctx.db, galleryUris),
ctx.labels(galleryUris).then(async (raw) => {
const externalLabels = raw as Map<string, Label[]>;
if (galleryUris.length === 0) return externalLabels;
const selfLabelRows = (await ctx.db.query(
`SELECT parent_uri, val FROM "social.grain.gallery__labels_self_labels"
WHERE parent_uri IN (${galleryUris.map((_, i) => `$${i + 1}`).join(",")})`,
galleryUris,
)) as { parent_uri: string; val: string }[];
for (const row of selfLabelRows) {
const label: Label = {
src: row.parent_uri.split("/")[2],
uri: row.parent_uri,
val: row.val,
cts: new Date().toISOString(),
};
const existing = externalLabels.get(row.parent_uri) ?? [];
existing.push(label);
externalLabels.set(row.parent_uri, existing);
}
return externalLabels;
}) as Promise<Map<string, Label[]>>,
galleryUris.length > 0
? (ctx.db.query(
`SELECT uri, did, cid, gallery, item, position, created_at
FROM "social.grain.gallery.item"
WHERE gallery IN (${galleryUris.map((_, i) => `$${i + 1}`).join(",")})
ORDER BY position ASC`,
galleryUris,
) as Promise<
Array<{
uri: string;
did: string;
cid: string;
gallery: string;
item: string;
position: number;
created_at: string;
}>
>)
: Promise.resolve([]),
lookupCrossPosts(ctx.db, items, "gallery"),
]);
// Group gallery items by gallery URI
const itemsByGallery = new Map<
string,
Array<{ photoUri: string; position: number; itemUri: string; itemCreatedAt: string }>
>();
for (const row of galleryItemRows) {
if (!itemsByGallery.has(row.gallery)) itemsByGallery.set(row.gallery, []);
itemsByGallery.get(row.gallery)!.push({
photoUri: row.item,
position: row.position ?? 0,
itemUri: row.uri,
itemCreatedAt: row.created_at,
});
}
// Fetch all referenced photos + EXIF data
const allPhotoUris = [...new Set(galleryItemRows.map((r) => r.item))];
const [photos, exifByPhoto] = await Promise.all([
allPhotoUris.length > 0
? ctx.getRecords<Photo>("social.grain.photo", allPhotoUris)
: Promise.resolve(new Map<string, Row<Photo>>()),
allPhotoUris.length > 0
? (
ctx.db.query(
`SELECT uri, cid, photo, created_at, date_time_original, exposure_time, f_number,
flash, focal_length_in35mm_format, i_s_o, lens_make, lens_model, make, model
FROM "social.grain.photo.exif"
WHERE photo IN (${allPhotoUris.map((_, i) => `$${i + 1}`).join(",")})`,
allPhotoUris,
) as Promise<ExifRow[]>
).then((rows) => {
const map = new Map<string, ExifRow>();
for (const row of rows) map.set(row.photo, row);
return map;
})
: Promise.resolve(new Map<string, ExifRow>()),
]);
return items.map((item) => {
const author = profiles.get(item.did);
const galleryItems = itemsByGallery.get(item.uri) ?? [];
const facepile = (favedByFollowing.get(item.uri) ?? []).map((did) => {
const p = profiles.get(did);
return views.grainActorDefsProfileView({
cid: p?.cid ?? "",
did,
handle: p?.handle ?? handleMap.get(did) ?? did,
displayName: p?.value.displayName,
avatar: p ? (ctx.blobUrl(did, p.value.avatar, "avatar") ?? undefined) : undefined,
});
});
const photoViews: PhotoView[] = galleryItems
.map((gi) => {
const photo = photos.get(gi.photoUri);
if (!photo) return null;
const val = photo.value;
const exifRow = exifByPhoto.get(photo.uri);
return views.photoView({
uri: photo.uri,
cid: photo.cid,
thumb: ctx.blobUrl(photo.did, val.photo, "feed_thumbnail") ?? "",
fullsize: ctx.blobUrl(photo.did, val.photo, "feed_fullsize") ?? "",
alt: val.alt,
aspectRatio: val.aspectRatio ?? { width: 4, height: 3 },
...(exifRow ? { exif: buildExifView(exifRow) } : {}),
gallery: {
item: gi.itemUri,
itemCreatedAt: gi.itemCreatedAt,
itemPosition: gi.position,
},
});
})
.filter((v): v is PhotoView => v !== null);
const pooled = groupByGallery.get(item.uri);
const group: GroupRef | undefined = pooled
? {
did: pooled.did,
handle: profiles.get(pooled.did)?.handle ?? handleMap.get(pooled.did) ?? pooled.did,
displayName: profiles.get(pooled.did)?.value.displayName,
item: pooled.item,
}
: undefined;
return views.galleryView({
uri: item.uri,
cid: item.cid,
record: item.value,
indexedAt: item.indexed_at ?? item.value.createdAt,
title: item.value.title,
description: item.value.description,
createdAt: item.value.createdAt,
favCount: favCounts.get(item.uri) ?? 0,
commentCount: commentCounts.get(item.uri) ?? 0,
creator: author
? views.grainActorDefsProfileView({
cid: author.cid,
did: author.did,
handle: author.handle ?? handleMap.get(author.did) ?? author.did,
displayName: author.value.displayName,
avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined,
})
: views.grainActorDefsProfileView({
cid: item.cid,
did: item.did,
handle: handleMap.get(item.did) ?? item.did,
}),
items: photoViews,
...(item.value.location
? {
location: {
name: item.value.location.name,
value: item.value.location.value,
},
locationDisplay: formatStoredLocation(item.value.location, item.value.address),
...(item.value.address ? { address: item.value.address } : {}),
}
: {}),
...(labelsByUri.has(item.uri) ? { labels: labelsByUri.get(item.uri) } : {}),
...(facepile.length > 0 ? { favedByFollowing: facepile } : {}),
...(viewerFavs.has(item.uri) ? { viewer: { fav: viewerFavs.get(item.uri) } } : {}),
...(crossPosts.has(item.uri) ? { crossPost: { url: crossPosts.get(item.uri)! } } : {}),
...(group ? { group } : {}),
});
});
}