import { config } from '../config.js'; import { recordCache } from './record-cache.js'; import { queryCache } from './query-cache.js'; import { auth } from './auth.js'; class GrainApiService { #endpoint = config.apiEndpoint; async getTimeline({ first = 10, after = null } = {}) { const query = ` query Timeline($first: Int, $after: String) { socialGrainGallery( first: $first after: $after sortBy: [{ field: createdAt, direction: DESC }] ) { edges { node { uri title description createdAt actorHandle socialGrainActorProfileByDid { avatar { url(preset: "avatar") } displayName } socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) { edges { node { itemResolved { ... on SocialGrainPhoto { uri alt aspectRatio { width height } photo { url(preset: "feed_thumbnail") } } } } } } socialGrainFavoriteViaSubject { totalCount } socialGrainCommentViaSubject { totalCount } viewerSocialGrainFavoriteViaSubject { uri } } } pageInfo { hasNextPage endCursor } } } `; const response = await this.#execute(query, { first, after }); return this.#transformTimelineResponse(response, { isPagination: !!after }); } #transformTimelineResponse(response, { isPagination = false } = {}) { const connection = response.data?.socialGrainGallery; if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } }; const galleries = connection.edges.map(edge => { const node = edge.node; const profile = node.socialGrainActorProfileByDid; const items = node.socialGrainGalleryItemViaGallery?.edges || []; const photos = items .map(i => { const photo = i.node.itemResolved; if (!photo) return null; return { url: photo.photo?.url || '', alt: photo.alt || '', aspectRatio: photo.aspectRatio ? photo.aspectRatio.width / photo.aspectRatio.height : 1 }; }) .filter(Boolean); return { uri: node.uri, title: node.title, description: node.description, createdAt: node.createdAt, handle: node.actorHandle, displayName: profile?.displayName || '', avatarUrl: profile?.avatar?.url || '', photos, favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0, commentCount: node.socialGrainCommentViaSubject?.totalCount || 0, viewerHasFavorited: !!node.viewerSocialGrainFavoriteViaSubject?.uri, viewerFavoriteUri: node.viewerSocialGrainFavoriteViaSubject?.uri || null }; }).filter(gallery => gallery.photos.length > 0); // Cache each gallery record by URI galleries.forEach(gallery => { recordCache.set(gallery.uri, gallery); }); // Cache the timeline query result (URIs only for list navigation) const cacheData = { uris: galleries.map(g => g.uri), cursor: connection.pageInfo?.endCursor || null, hasMore: connection.pageInfo?.hasNextPage ?? false }; if (isPagination) { queryCache.append('timeline', cacheData); } else { queryCache.set('timeline', cacheData); } return { galleries, pageInfo: connection.pageInfo }; } async searchGalleries(query, { first = 10, after = null } = {}) { const gqlQuery = ` query SearchGalleries($query: String!, $first: Int, $after: String) { socialGrainGallery( first: $first after: $after where: { or: [{ title: { contains: $query } }, { description: { contains: $query } }] } sortBy: [{ field: createdAt, direction: DESC }] ) { edges { node { uri title description createdAt actorHandle socialGrainActorProfileByDid { avatar { url(preset: "avatar") } displayName } socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) { edges { node { itemResolved { ... on SocialGrainPhoto { uri alt aspectRatio { width height } photo { url(preset: "feed_thumbnail") } } } } } } socialGrainFavoriteViaSubject { totalCount } socialGrainCommentViaSubject { totalCount } } } pageInfo { hasNextPage endCursor } } } `; const response = await this.#execute(gqlQuery, { query, first, after }); return this.#transformSearchResponse(response); } #transformSearchResponse(response) { const connection = response.data?.socialGrainGallery; if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } }; const galleries = connection.edges.map(edge => { const node = edge.node; const profile = node.socialGrainActorProfileByDid; const items = node.socialGrainGalleryItemViaGallery?.edges || []; const photos = items .map(i => { const photo = i.node.itemResolved; if (!photo) return null; return { url: photo.photo?.url || '', alt: photo.alt || '', aspectRatio: photo.aspectRatio ? photo.aspectRatio.width / photo.aspectRatio.height : 1 }; }) .filter(Boolean); return { uri: node.uri, title: node.title, description: node.description, createdAt: node.createdAt, handle: node.actorHandle, displayName: profile?.displayName || '', avatarUrl: profile?.avatar?.url || '', photos, favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0, commentCount: node.socialGrainCommentViaSubject?.totalCount || 0, viewerHasFavorited: false, viewerFavoriteUri: null }; }).filter(gallery => gallery.photos.length > 0); // Cache each gallery record by URI (but don't update timeline query cache) galleries.forEach(gallery => { recordCache.set(gallery.uri, gallery); }); return { galleries, pageInfo: connection.pageInfo }; } async searchProfiles(query, { first = 20, after = null } = {}) { const gqlQuery = ` query SearchProfiles($query: String!, $first: Int, $after: String) { socialGrainActorProfile( first: $first after: $after where: { actorHandle: { contains: $query } } ) { edges { node { actorHandle displayName description avatar { url(preset: "avatar") } } } pageInfo { hasNextPage endCursor } } } `; const response = await this.#execute(gqlQuery, { query, first, after }); const connection = response.data?.socialGrainActorProfile; if (!connection) return { profiles: [], pageInfo: { hasNextPage: false } }; const profiles = connection.edges.map(edge => ({ handle: edge.node.actorHandle, displayName: edge.node.displayName || '', description: edge.node.description || '', avatarUrl: edge.node.avatar?.url || '' })); return { profiles, pageInfo: connection.pageInfo }; } async #execute(query, variables = {}) { // Use authenticated client if available for viewer fields if (auth.isAuthenticated) { const client = auth.getClient(); const data = await client.query(query, variables); return { data }; } const response = await fetch(this.#endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) }); if (!response.ok) { throw new Error(`GraphQL request failed: ${response.status}`); } return response.json(); } setEndpoint(endpoint) { this.#endpoint = endpoint; } async getProfile(handle) { const query = ` query GetProfile($handle: String!) { socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { edges { node { did actorHandle displayName description createdAt avatar { url(preset: "avatar") } socialGrainGraphFollowByDid { totalCount } viewerSocialGrainGraphFollowViaSubject { uri } socialGrainGalleryByDid(sortBy: [{ field: createdAt, direction: DESC }]) { totalCount edges { node { uri title description createdAt socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) { totalCount edges { node { itemResolved { ... on SocialGrainPhoto { uri alt aspectRatio { width height } photo { url(preset: "feed_thumbnail") } } } } } } socialGrainFavoriteViaSubject { totalCount } socialGrainCommentViaSubject { totalCount } viewerSocialGrainFavoriteViaSubject { uri } } } } } } } } `; const response = await this.#execute(query, { handle }); const data = response.data; const profileEdge = data.socialGrainActorProfile?.edges?.[0]; const profile = profileEdge?.node; const galleriesConnection = profile?.socialGrainGalleryByDid; const galleries = galleriesConnection?.edges?.map(edge => { const node = edge.node; const itemsConnection = node.socialGrainGalleryItemViaGallery; const items = itemsConnection?.edges || []; const photos = items .map(i => { const photo = i.node.itemResolved; if (!photo) return null; return { uri: photo.uri, url: photo.photo?.url || '', alt: photo.alt || '', aspectRatio: photo.aspectRatio ? photo.aspectRatio.width / photo.aspectRatio.height : 1 }; }) .filter(Boolean); return { uri: node.uri, title: node.title, description: node.description || '', createdAt: node.createdAt, handle: profile?.actorHandle || handle, displayName: profile?.displayName || '', avatarUrl: profile?.avatar?.url || '', photos, photoCount: itemsConnection?.totalCount || photos.length, thumbnailUrl: photos[0]?.url || '', favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0, commentCount: node.socialGrainCommentViaSubject?.totalCount || 0, viewerHasFavorited: !!node.viewerSocialGrainFavoriteViaSubject?.uri, viewerFavoriteUri: node.viewerSocialGrainFavoriteViaSubject?.uri || null }; }) || []; // Cache each gallery with full photo data galleries.forEach(gallery => { recordCache.set(gallery.uri, gallery); }); // Cache the profile's gallery list queryCache.set(`profile:${handle}`, { uris: galleries.map(g => g.uri), cursor: null, hasMore: false }); // Get follower count in a separate query (people who follow this user) const followerCount = await this.#getFollowerCount(profile?.did); const profileData = { handle: profile?.actorHandle || handle, displayName: profile?.displayName || '', description: profile?.description || '', createdAt: profile?.createdAt || null, avatarUrl: profile?.avatar?.url || '', did: profile?.did || '', galleryCount: galleriesConnection?.totalCount || 0, followerCount, followingCount: profile?.socialGrainGraphFollowByDid?.totalCount || 0, galleries, viewerIsFollowing: !!profile?.viewerSocialGrainGraphFollowViaSubject?.uri, viewerFollowUri: profile?.viewerSocialGrainGraphFollowViaSubject?.uri || null }; // Cache the profile data recordCache.set(`profile:${handle}`, profileData); return profileData; } async #getFollowerCount(did) { if (!did) return 0; const query = ` query GetFollowerCount($did: String!) { socialGrainGraphFollow(where: { subject: { eq: $did } }) { totalCount } } `; const response = await this.#execute(query, { did }); return response.data?.socialGrainGraphFollow?.totalCount || 0; } async getFollowers(handle, { first = 20, after = null } = {}) { // First get the user's DID const profileQuery = ` query GetDid($handle: String!) { socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { edges { node { did } } } } `; const profileResponse = await this.#execute(profileQuery, { handle }); const did = profileResponse.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; if (!did) { return { profiles: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 }; } // Query followers (people who follow this user) const query = ` query GetFollowers($did: String!, $first: Int, $after: String) { socialGrainGraphFollow( first: $first after: $after where: { subject: { eq: $did } } sortBy: [{ field: createdAt, direction: DESC }] ) { edges { node { socialGrainActorProfileByDid { actorHandle displayName description avatar { url(preset: "avatar") } } } } pageInfo { hasNextPage endCursor } totalCount } } `; const response = await this.#execute(query, { did, first, after }); const connection = response.data?.socialGrainGraphFollow; const profiles = connection?.edges ?.map(edge => edge.node.socialGrainActorProfileByDid) ?.filter(Boolean) ?.map(profile => ({ handle: profile.actorHandle, displayName: profile.displayName || '', description: profile.description || '', avatarUrl: profile.avatar?.url || '' })) || []; return { profiles, pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null }, totalCount: connection?.totalCount || 0 }; } async getFollowing(handle, { first = 20, after = null } = {}) { // Query follows by actorHandle directly const query = ` query GetFollowing($handle: String!, $first: Int, $after: String) { socialGrainGraphFollow( first: $first after: $after where: { actorHandle: { eq: $handle } } sortBy: [{ field: createdAt, direction: DESC }] ) { edges { node { subject } } pageInfo { hasNextPage endCursor } totalCount } } `; const response = await this.#execute(query, { handle, first, after }); const connection = response.data?.socialGrainGraphFollow; const subjectDids = connection?.edges?.map(e => e.node.subject).filter(Boolean) || []; if (subjectDids.length === 0) { return { profiles: [], pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null }, totalCount: connection?.totalCount || 0 }; } // Fetch profiles for the subject DIDs const profilesQuery = ` query GetProfiles($dids: [String!]!) { socialGrainActorProfile(where: { did: { in: $dids } }) { edges { node { did actorHandle displayName description avatar { url(preset: "avatar") } } } } } `; const profilesResponse = await this.#execute(profilesQuery, { dids: subjectDids }); const profilesMap = new Map(); profilesResponse.data?.socialGrainActorProfile?.edges?.forEach(edge => { const node = edge.node; profilesMap.set(node.did, { handle: node.actorHandle, displayName: node.displayName || '', description: node.description || '', avatarUrl: node.avatar?.url || '' }); }); // Return profiles in order, with fallback for missing profiles const profiles = subjectDids.map(did => profilesMap.get(did) || { handle: did, displayName: '', description: '', avatarUrl: '' } ); return { profiles, pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null }, totalCount: connection?.totalCount || 0 }; } async getGalleryDetail(handle, rkey) { const query = ` query GetGalleryDetail($handle: String!, $rkey: String!) { socialGrainGallery( first: 1 where: { actorHandle: { eq: $handle }, uri: { contains: $rkey } } ) { edges { node { uri did actorHandle title description facets createdAt socialGrainActorProfileByDid { displayName avatar { url(preset: "avatar") } } socialGrainGalleryItemViaGallery(first: 50, sortBy: [{ field: position, direction: ASC }]) { edges { node { uri itemResolved { ... on SocialGrainPhoto { uri alt aspectRatio { width height } photo { url(preset: "feed_thumbnail") } } } } } } socialGrainFavoriteViaSubject { totalCount } socialGrainCommentViaSubject( first: 20 sortBy: [{ field: createdAt, direction: ASC }] ) { totalCount edges { node { uri text facets createdAt actorHandle replyTo focus focusResolved { ... on SocialGrainPhoto { uri alt photo { url(preset: "feed_thumbnail") } } } socialGrainActorProfileByDid { displayName avatar { url(preset: "avatar") } } } } } viewerSocialGrainFavoriteViaSubject { uri } } } } } `; const response = await this.#execute(query, { handle, rkey }); const galleryNode = response.data?.socialGrainGallery?.edges?.[0]?.node; if (!galleryNode) { throw new Error('Gallery not found'); } const profile = galleryNode.socialGrainActorProfileByDid; const galleryItems = galleryNode.socialGrainGalleryItemViaGallery?.edges ?.map(edge => edge.node) ?.filter(Boolean) || []; const photos = galleryItems .map(item => item.itemResolved) .filter(Boolean) .map(photo => ({ uri: photo.uri, url: photo.photo?.url || '', alt: photo.alt || '', aspectRatio: photo.aspectRatio ? photo.aspectRatio.width / photo.aspectRatio.height : 1 })); const galleryItemUris = galleryItems.map(item => item.uri).filter(Boolean); const photoUris = photos.map(p => p.uri).filter(Boolean); const comments = galleryNode.socialGrainCommentViaSubject?.edges?.map(edge => { const node = edge.node; const commentProfile = node.socialGrainActorProfileByDid; const focusPhoto = node.focusResolved; return { uri: node.uri, text: node.text, facets: node.facets || [], createdAt: node.createdAt, handle: node.actorHandle, displayName: commentProfile?.displayName || '', avatarUrl: commentProfile?.avatar?.url || '', replyToUri: node.replyTo || null, focusImageUrl: focusPhoto?.photo?.url || null, focusImageAlt: focusPhoto?.alt || '' }; }) || []; return { uri: galleryNode.uri, title: galleryNode.title, description: galleryNode.description, facets: galleryNode.facets || [], createdAt: galleryNode.createdAt, handle: galleryNode.actorHandle, displayName: profile?.displayName || '', avatarUrl: profile?.avatar?.url || '', photos, galleryItemUris, photoUris, favoriteCount: galleryNode.socialGrainFavoriteViaSubject?.totalCount || 0, commentCount: galleryNode.socialGrainCommentViaSubject?.totalCount || 0, comments, viewerHasFavorited: !!galleryNode.viewerSocialGrainFavoriteViaSubject?.uri, viewerFavoriteUri: galleryNode.viewerSocialGrainFavoriteViaSubject?.uri || null }; } async getNotifications(viewerDid, { first = 20, after = null } = {}) { const query = ` query Notifications($first: Int, $after: String) { notifications(viewerDid: "${viewerDid}", first: $first, after: $after) { edges { node { __typename ... on SocialGrainFavorite { uri did createdAt subject subjectResolved { ... on SocialGrainGallery { uri title actorHandle socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) { edges { node { itemResolved { ... on SocialGrainPhoto { photo { url(preset: "feed_thumbnail") } } } } } } } } socialGrainActorProfileByDid { displayName actorHandle avatar { url(preset: "avatar") } } } ... on SocialGrainGraphFollow { uri did createdAt socialGrainActorProfileByDid { displayName actorHandle avatar { url(preset: "avatar") } } } ... on SocialGrainComment { uri did createdAt text subject focus replyTo facets subjectResolved { ... on SocialGrainGallery { uri title actorHandle socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) { edges { node { itemResolved { ... on SocialGrainPhoto { photo { url(preset: "feed_thumbnail") } } } } } } } } focusResolved { ... on SocialGrainPhoto { uri alt photo { url(preset: "feed_thumbnail") } } } replyToResolved { ... on SocialGrainComment { uri text actorHandle } } socialGrainActorProfileByDid { displayName actorHandle avatar { url(preset: "avatar") } } } ... on SocialGrainGallery { uri did createdAt title description facets actorHandle socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) { edges { node { itemResolved { ... on SocialGrainPhoto { photo { url(preset: "feed_thumbnail") } } } } } } socialGrainActorProfileByDid { displayName actorHandle avatar { url(preset: "avatar") } } } } } pageInfo { hasNextPage endCursor } } } `; const response = await this.#execute(query, { first, after }); return this.#transformNotificationsResponse(response, viewerDid); } #transformNotificationsResponse(response, viewerDid) { const connection = response.data?.notifications; if (!connection) return { notifications: [], pageInfo: { hasNextPage: false, endCursor: null } }; const notifications = connection.edges .map(edge => { const node = edge.node; const reason = this.#getNotificationReason(node, viewerDid); if (!reason) return null; const profile = node.socialGrainActorProfileByDid; const author = { handle: profile?.actorHandle || '', displayName: profile?.displayName || '', avatarUrl: profile?.avatar?.url || '' }; const base = { uri: node.uri, createdAt: node.createdAt, reason, author }; switch (node.__typename) { case 'SocialGrainFavorite': { const gallery = node.subjectResolved; const thumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url; return { ...base, gallery: gallery ? { uri: gallery.uri, title: gallery.title, handle: gallery.actorHandle, thumbnailUrl: thumb || '' } : null }; } case 'SocialGrainGraphFollow': return base; case 'SocialGrainComment': { const gallery = node.subjectResolved; const galleryThumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url; const focusPhoto = node.focusResolved; const replyTo = node.replyToResolved; return { ...base, text: node.text, gallery: gallery ? { uri: gallery.uri, title: gallery.title, handle: gallery.actorHandle, thumbnailUrl: galleryThumb || '' } : null, focusPhoto: focusPhoto ? { uri: focusPhoto.uri, alt: focusPhoto.alt, thumbnailUrl: focusPhoto.photo?.url || '' } : null, replyTo: replyTo ? { uri: replyTo.uri, text: replyTo.text, handle: replyTo.actorHandle } : null }; } case 'SocialGrainGallery': { const thumb = node.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url; return { ...base, gallery: { uri: node.uri, title: node.title, description: node.description, handle: node.actorHandle, thumbnailUrl: thumb || '' } }; } default: return null; } }) .filter(Boolean); return { notifications, pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null } }; } #getNotificationReason(node, viewerDid) { switch (node.__typename) { case 'SocialGrainFavorite': return 'gallery-favorite'; case 'SocialGrainGraphFollow': return 'follow'; case 'SocialGrainComment': if (this.#hasMentionFacet(node.facets, viewerDid)) { return 'gallery-comment-mention'; } if (node.replyTo) { return 'reply'; } return 'gallery-comment'; case 'SocialGrainGallery': if (this.#hasMentionFacet(node.facets, viewerDid)) { return 'gallery-mention'; } return null; default: return null; } } #hasMentionFacet(facets, viewerDid) { if (!Array.isArray(facets)) return false; return facets.some(facet => { const features = facet.features; if (!Array.isArray(features)) return false; return features.some(f => f.$type === 'app.bsky.richtext.facet#mention' && f.did === viewerDid ); }); } async getCurrentProfile(client) { const result = await client.query(` query { viewer { did handle socialGrainActorProfileByDid { displayName description avatar { url ref mimeType size } } } } `); const viewer = result.viewer; const profile = viewer?.socialGrainActorProfileByDid; const avatar = profile?.avatar; return { did: viewer?.did || '', handle: viewer?.handle || '', displayName: profile?.displayName || '', description: profile?.description || '', avatarUrl: avatar?.url || '', avatarBlob: avatar ? { $type: 'blob', ref: { $link: avatar.ref }, mimeType: avatar.mimeType, size: avatar.size } : null }; } async getComments(galleryUri, { first = 20, after = null } = {}) { const query = ` query GetComments($galleryUri: String!, $first: Int, $after: String) { socialGrainComment( first: $first after: $after where: { subject: { eq: $galleryUri } } sortBy: [{ field: createdAt, direction: ASC }] ) { edges { node { uri text facets createdAt actorHandle replyTo focus focusResolved { ... on SocialGrainPhoto { uri alt photo { url(preset: "feed_thumbnail") } } } socialGrainActorProfileByDid { displayName avatar { url(preset: "avatar") } } } } pageInfo { hasNextPage endCursor } totalCount } } `; const response = await this.#execute(query, { galleryUri, first, after }); const connection = response.data?.socialGrainComment; if (!connection) { return { comments: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 }; } const comments = connection.edges.map(edge => { const node = edge.node; const profile = node.socialGrainActorProfileByDid; const focusPhoto = node.focusResolved; return { uri: node.uri, text: node.text, facets: node.facets || [], createdAt: node.createdAt, handle: node.actorHandle, displayName: profile?.displayName || '', avatarUrl: profile?.avatar?.url || '', replyToUri: node.replyTo || null, focusImageUrl: focusPhoto?.photo?.url || null, focusImageAlt: focusPhoto?.alt || '' }; }); return { comments, pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null }, totalCount: connection.totalCount || 0 }; } async resolveHandle(handle) { const query = ` query ResolveHandle($handle: String!) { socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { edges { node { did } } } } `; const response = await this.#execute(query, { handle }); const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; if (!did) { throw new Error(`Handle not found: ${handle}`); } return did; } async hasGrainProfile(client) { const result = await client.query(` query { viewer { socialGrainActorProfileByDid { displayName } } } `); return !!result.viewer?.socialGrainActorProfileByDid; } async getBlueskyProfile(client) { const result = await client.query(` query { viewer { did handle appBskyActorProfileByDid { displayName description avatar { url ref mimeType size } } } } `); const viewer = result.viewer; const profile = viewer?.appBskyActorProfileByDid; const avatar = profile?.avatar; return { did: viewer?.did || '', handle: viewer?.handle || '', displayName: profile?.displayName || '', description: profile?.description || '', avatarUrl: avatar?.url || '', avatarBlob: avatar ? { $type: 'blob', ref: { $link: avatar.ref }, mimeType: avatar.mimeType, size: avatar.size } : null }; } } export const grainApi = new GrainApiService();