WIP PWA for Grain
1import { config } from '../config.js';
2import { recordCache } from './record-cache.js';
3import { queryCache } from './query-cache.js';
4import { auth } from './auth.js';
5
6class GrainApiService {
7 #endpoint = config.apiEndpoint;
8
9 async getTimeline({ first = 10, after = null } = {}) {
10 const query = `
11 query Timeline($first: Int, $after: String) {
12 socialGrainGallery(
13 first: $first
14 after: $after
15 sortBy: [{ field: createdAt, direction: DESC }]
16 ) {
17 edges {
18 node {
19 uri
20 title
21 description
22 createdAt
23 actorHandle
24 socialGrainActorProfileByDid {
25 avatar { url(preset: "avatar") }
26 displayName
27 }
28 socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) {
29 edges {
30 node {
31 itemResolved {
32 ... on SocialGrainPhoto {
33 uri
34 alt
35 aspectRatio { width height }
36 photo { url(preset: "feed_thumbnail") }
37 }
38 }
39 }
40 }
41 }
42 socialGrainFavoriteViaSubject {
43 totalCount
44 }
45 socialGrainCommentViaSubject {
46 totalCount
47 }
48 viewerSocialGrainFavoriteViaSubject {
49 uri
50 }
51 }
52 }
53 pageInfo {
54 hasNextPage
55 endCursor
56 }
57 }
58 }
59 `;
60
61 const response = await this.#execute(query, { first, after });
62 return this.#transformTimelineResponse(response, { isPagination: !!after });
63 }
64
65 #transformTimelineResponse(response, { isPagination = false } = {}) {
66 const connection = response.data?.socialGrainGallery;
67 if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } };
68
69 const galleries = connection.edges.map(edge => {
70 const node = edge.node;
71 const profile = node.socialGrainActorProfileByDid;
72 const items = node.socialGrainGalleryItemViaGallery?.edges || [];
73
74 const photos = items
75 .map(i => {
76 const photo = i.node.itemResolved;
77 if (!photo) return null;
78 return {
79 url: photo.photo?.url || '',
80 alt: photo.alt || '',
81 aspectRatio: photo.aspectRatio
82 ? photo.aspectRatio.width / photo.aspectRatio.height
83 : 1
84 };
85 })
86 .filter(Boolean);
87
88 return {
89 uri: node.uri,
90 title: node.title,
91 description: node.description,
92 createdAt: node.createdAt,
93 handle: node.actorHandle,
94 displayName: profile?.displayName || '',
95 avatarUrl: profile?.avatar?.url || '',
96 photos,
97 favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0,
98 commentCount: node.socialGrainCommentViaSubject?.totalCount || 0,
99 viewerHasFavorited: !!node.viewerSocialGrainFavoriteViaSubject?.uri,
100 viewerFavoriteUri: node.viewerSocialGrainFavoriteViaSubject?.uri || null
101 };
102 }).filter(gallery => gallery.photos.length > 0);
103
104 // Cache each gallery record by URI
105 galleries.forEach(gallery => {
106 recordCache.set(gallery.uri, gallery);
107 });
108
109 // Cache the timeline query result (URIs only for list navigation)
110 const cacheData = {
111 uris: galleries.map(g => g.uri),
112 cursor: connection.pageInfo?.endCursor || null,
113 hasMore: connection.pageInfo?.hasNextPage ?? false
114 };
115
116 if (isPagination) {
117 queryCache.append('timeline', cacheData);
118 } else {
119 queryCache.set('timeline', cacheData);
120 }
121
122 return {
123 galleries,
124 pageInfo: connection.pageInfo
125 };
126 }
127
128 async searchGalleries(query, { first = 10, after = null } = {}) {
129 const gqlQuery = `
130 query SearchGalleries($query: String!, $first: Int, $after: String) {
131 socialGrainGallery(
132 first: $first
133 after: $after
134 where: { or: [{ title: { contains: $query } }, { description: { contains: $query } }] }
135 sortBy: [{ field: createdAt, direction: DESC }]
136 ) {
137 edges {
138 node {
139 uri
140 title
141 description
142 createdAt
143 actorHandle
144 socialGrainActorProfileByDid {
145 avatar { url(preset: "avatar") }
146 displayName
147 }
148 socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) {
149 edges {
150 node {
151 itemResolved {
152 ... on SocialGrainPhoto {
153 uri
154 alt
155 aspectRatio { width height }
156 photo { url(preset: "feed_thumbnail") }
157 }
158 }
159 }
160 }
161 }
162 socialGrainFavoriteViaSubject {
163 totalCount
164 }
165 socialGrainCommentViaSubject {
166 totalCount
167 }
168 }
169 }
170 pageInfo {
171 hasNextPage
172 endCursor
173 }
174 }
175 }
176 `;
177
178 const response = await this.#execute(gqlQuery, { query, first, after });
179 return this.#transformSearchResponse(response);
180 }
181
182 #transformSearchResponse(response) {
183 const connection = response.data?.socialGrainGallery;
184 if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } };
185
186 const galleries = connection.edges.map(edge => {
187 const node = edge.node;
188 const profile = node.socialGrainActorProfileByDid;
189 const items = node.socialGrainGalleryItemViaGallery?.edges || [];
190
191 const photos = items
192 .map(i => {
193 const photo = i.node.itemResolved;
194 if (!photo) return null;
195 return {
196 url: photo.photo?.url || '',
197 alt: photo.alt || '',
198 aspectRatio: photo.aspectRatio
199 ? photo.aspectRatio.width / photo.aspectRatio.height
200 : 1
201 };
202 })
203 .filter(Boolean);
204
205 return {
206 uri: node.uri,
207 title: node.title,
208 description: node.description,
209 createdAt: node.createdAt,
210 handle: node.actorHandle,
211 displayName: profile?.displayName || '',
212 avatarUrl: profile?.avatar?.url || '',
213 photos,
214 favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0,
215 commentCount: node.socialGrainCommentViaSubject?.totalCount || 0,
216 viewerHasFavorited: false,
217 viewerFavoriteUri: null
218 };
219 }).filter(gallery => gallery.photos.length > 0);
220
221 // Cache each gallery record by URI (but don't update timeline query cache)
222 galleries.forEach(gallery => {
223 recordCache.set(gallery.uri, gallery);
224 });
225
226 return {
227 galleries,
228 pageInfo: connection.pageInfo
229 };
230 }
231
232 async searchProfiles(query, { first = 20, after = null } = {}) {
233 const gqlQuery = `
234 query SearchProfiles($query: String!, $first: Int, $after: String) {
235 socialGrainActorProfile(
236 first: $first
237 after: $after
238 where: { actorHandle: { contains: $query } }
239 ) {
240 edges {
241 node {
242 actorHandle
243 displayName
244 description
245 avatar { url(preset: "avatar") }
246 }
247 }
248 pageInfo {
249 hasNextPage
250 endCursor
251 }
252 }
253 }
254 `;
255
256 const response = await this.#execute(gqlQuery, { query, first, after });
257 const connection = response.data?.socialGrainActorProfile;
258
259 if (!connection) return { profiles: [], pageInfo: { hasNextPage: false } };
260
261 const profiles = connection.edges.map(edge => ({
262 handle: edge.node.actorHandle,
263 displayName: edge.node.displayName || '',
264 description: edge.node.description || '',
265 avatarUrl: edge.node.avatar?.url || ''
266 }));
267
268 return {
269 profiles,
270 pageInfo: connection.pageInfo
271 };
272 }
273
274 async #execute(query, variables = {}) {
275 // Use authenticated client if available for viewer fields
276 if (auth.isAuthenticated) {
277 const client = auth.getClient();
278 const data = await client.query(query, variables);
279 return { data };
280 }
281
282 const response = await fetch(this.#endpoint, {
283 method: 'POST',
284 headers: {
285 'Content-Type': 'application/json'
286 },
287 body: JSON.stringify({ query, variables })
288 });
289
290 if (!response.ok) {
291 throw new Error(`GraphQL request failed: ${response.status}`);
292 }
293
294 return response.json();
295 }
296
297 setEndpoint(endpoint) {
298 this.#endpoint = endpoint;
299 }
300
301 async getProfile(handle) {
302 const query = `
303 query GetProfile($handle: String!) {
304 socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) {
305 edges {
306 node {
307 did
308 actorHandle
309 displayName
310 description
311 createdAt
312 avatar { url(preset: "avatar") }
313 socialGrainGraphFollowByDid {
314 totalCount
315 }
316 viewerSocialGrainGraphFollowViaSubject {
317 uri
318 }
319 socialGrainGalleryByDid(sortBy: [{ field: createdAt, direction: DESC }]) {
320 totalCount
321 edges {
322 node {
323 uri
324 title
325 description
326 createdAt
327 socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) {
328 totalCount
329 edges {
330 node {
331 itemResolved {
332 ... on SocialGrainPhoto {
333 uri
334 alt
335 aspectRatio { width height }
336 photo { url(preset: "feed_thumbnail") }
337 }
338 }
339 }
340 }
341 }
342 socialGrainFavoriteViaSubject {
343 totalCount
344 }
345 socialGrainCommentViaSubject {
346 totalCount
347 }
348 viewerSocialGrainFavoriteViaSubject {
349 uri
350 }
351 }
352 }
353 }
354 }
355 }
356 }
357 }
358 `;
359
360 const response = await this.#execute(query, { handle });
361 const data = response.data;
362
363 const profileEdge = data.socialGrainActorProfile?.edges?.[0];
364 const profile = profileEdge?.node;
365 const galleriesConnection = profile?.socialGrainGalleryByDid;
366
367 const galleries = galleriesConnection?.edges?.map(edge => {
368 const node = edge.node;
369 const itemsConnection = node.socialGrainGalleryItemViaGallery;
370 const items = itemsConnection?.edges || [];
371
372 const photos = items
373 .map(i => {
374 const photo = i.node.itemResolved;
375 if (!photo) return null;
376 return {
377 uri: photo.uri,
378 url: photo.photo?.url || '',
379 alt: photo.alt || '',
380 aspectRatio: photo.aspectRatio
381 ? photo.aspectRatio.width / photo.aspectRatio.height
382 : 1
383 };
384 })
385 .filter(Boolean);
386
387 return {
388 uri: node.uri,
389 title: node.title,
390 description: node.description || '',
391 createdAt: node.createdAt,
392 handle: profile?.actorHandle || handle,
393 displayName: profile?.displayName || '',
394 avatarUrl: profile?.avatar?.url || '',
395 photos,
396 photoCount: itemsConnection?.totalCount || photos.length,
397 thumbnailUrl: photos[0]?.url || '',
398 favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0,
399 commentCount: node.socialGrainCommentViaSubject?.totalCount || 0,
400 viewerHasFavorited: !!node.viewerSocialGrainFavoriteViaSubject?.uri,
401 viewerFavoriteUri: node.viewerSocialGrainFavoriteViaSubject?.uri || null
402 };
403 }) || [];
404
405 // Cache each gallery with full photo data
406 galleries.forEach(gallery => {
407 recordCache.set(gallery.uri, gallery);
408 });
409
410 // Cache the profile's gallery list
411 queryCache.set(`profile:${handle}`, {
412 uris: galleries.map(g => g.uri),
413 cursor: null,
414 hasMore: false
415 });
416
417 // Get follower count in a separate query (people who follow this user)
418 const followerCount = await this.#getFollowerCount(profile?.did);
419
420 const profileData = {
421 handle: profile?.actorHandle || handle,
422 displayName: profile?.displayName || '',
423 description: profile?.description || '',
424 createdAt: profile?.createdAt || null,
425 avatarUrl: profile?.avatar?.url || '',
426 did: profile?.did || '',
427 galleryCount: galleriesConnection?.totalCount || 0,
428 followerCount,
429 followingCount: profile?.socialGrainGraphFollowByDid?.totalCount || 0,
430 galleries,
431 viewerIsFollowing: !!profile?.viewerSocialGrainGraphFollowViaSubject?.uri,
432 viewerFollowUri: profile?.viewerSocialGrainGraphFollowViaSubject?.uri || null
433 };
434
435 // Cache the profile data
436 recordCache.set(`profile:${handle}`, profileData);
437
438 return profileData;
439 }
440
441 async #getFollowerCount(did) {
442 if (!did) return 0;
443
444 const query = `
445 query GetFollowerCount($did: String!) {
446 socialGrainGraphFollow(where: { subject: { eq: $did } }) {
447 totalCount
448 }
449 }
450 `;
451
452 const response = await this.#execute(query, { did });
453 return response.data?.socialGrainGraphFollow?.totalCount || 0;
454 }
455
456 async getFollowers(handle, { first = 20, after = null } = {}) {
457 // First get the user's DID
458 const profileQuery = `
459 query GetDid($handle: String!) {
460 socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) {
461 edges {
462 node { did }
463 }
464 }
465 }
466 `;
467 const profileResponse = await this.#execute(profileQuery, { handle });
468 const did = profileResponse.data?.socialGrainActorProfile?.edges?.[0]?.node?.did;
469
470 if (!did) {
471 return { profiles: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 };
472 }
473
474 // Query followers (people who follow this user)
475 const query = `
476 query GetFollowers($did: String!, $first: Int, $after: String) {
477 socialGrainGraphFollow(
478 first: $first
479 after: $after
480 where: { subject: { eq: $did } }
481 sortBy: [{ field: createdAt, direction: DESC }]
482 ) {
483 edges {
484 node {
485 socialGrainActorProfileByDid {
486 actorHandle
487 displayName
488 description
489 avatar { url(preset: "avatar") }
490 }
491 }
492 }
493 pageInfo {
494 hasNextPage
495 endCursor
496 }
497 totalCount
498 }
499 }
500 `;
501
502 const response = await this.#execute(query, { did, first, after });
503 const connection = response.data?.socialGrainGraphFollow;
504
505 const profiles = connection?.edges
506 ?.map(edge => edge.node.socialGrainActorProfileByDid)
507 ?.filter(Boolean)
508 ?.map(profile => ({
509 handle: profile.actorHandle,
510 displayName: profile.displayName || '',
511 description: profile.description || '',
512 avatarUrl: profile.avatar?.url || ''
513 })) || [];
514
515 return {
516 profiles,
517 pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null },
518 totalCount: connection?.totalCount || 0
519 };
520 }
521
522 async getFollowing(handle, { first = 20, after = null } = {}) {
523 // Query follows by actorHandle directly
524 const query = `
525 query GetFollowing($handle: String!, $first: Int, $after: String) {
526 socialGrainGraphFollow(
527 first: $first
528 after: $after
529 where: { actorHandle: { eq: $handle } }
530 sortBy: [{ field: createdAt, direction: DESC }]
531 ) {
532 edges {
533 node {
534 subject
535 }
536 }
537 pageInfo {
538 hasNextPage
539 endCursor
540 }
541 totalCount
542 }
543 }
544 `;
545
546 const response = await this.#execute(query, { handle, first, after });
547 const connection = response.data?.socialGrainGraphFollow;
548 const subjectDids = connection?.edges?.map(e => e.node.subject).filter(Boolean) || [];
549
550 if (subjectDids.length === 0) {
551 return {
552 profiles: [],
553 pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null },
554 totalCount: connection?.totalCount || 0
555 };
556 }
557
558 // Fetch profiles for the subject DIDs
559 const profilesQuery = `
560 query GetProfiles($dids: [String!]!) {
561 socialGrainActorProfile(where: { did: { in: $dids } }) {
562 edges {
563 node {
564 did
565 actorHandle
566 displayName
567 description
568 avatar { url(preset: "avatar") }
569 }
570 }
571 }
572 }
573 `;
574
575 const profilesResponse = await this.#execute(profilesQuery, { dids: subjectDids });
576 const profilesMap = new Map();
577 profilesResponse.data?.socialGrainActorProfile?.edges?.forEach(edge => {
578 const node = edge.node;
579 profilesMap.set(node.did, {
580 handle: node.actorHandle,
581 displayName: node.displayName || '',
582 description: node.description || '',
583 avatarUrl: node.avatar?.url || ''
584 });
585 });
586
587 // Return profiles in order, with fallback for missing profiles
588 const profiles = subjectDids.map(did =>
589 profilesMap.get(did) || { handle: did, displayName: '', description: '', avatarUrl: '' }
590 );
591
592 return {
593 profiles,
594 pageInfo: connection?.pageInfo || { hasNextPage: false, endCursor: null },
595 totalCount: connection?.totalCount || 0
596 };
597 }
598
599 async getGalleryDetail(handle, rkey) {
600 const query = `
601 query GetGalleryDetail($handle: String!, $rkey: String!) {
602 socialGrainGallery(
603 first: 1
604 where: { actorHandle: { eq: $handle }, uri: { contains: $rkey } }
605 ) {
606 edges {
607 node {
608 uri
609 did
610 actorHandle
611 title
612 description
613 facets
614 createdAt
615 socialGrainActorProfileByDid {
616 displayName
617 avatar { url(preset: "avatar") }
618 }
619 socialGrainGalleryItemViaGallery(first: 50, sortBy: [{ field: position, direction: ASC }]) {
620 edges {
621 node {
622 uri
623 itemResolved {
624 ... on SocialGrainPhoto {
625 uri
626 alt
627 aspectRatio { width height }
628 photo { url(preset: "feed_thumbnail") }
629 }
630 }
631 }
632 }
633 }
634 socialGrainFavoriteViaSubject {
635 totalCount
636 }
637 socialGrainCommentViaSubject(
638 first: 20
639 sortBy: [{ field: createdAt, direction: ASC }]
640 ) {
641 totalCount
642 edges {
643 node {
644 uri
645 text
646 facets
647 createdAt
648 actorHandle
649 replyTo
650 focus
651 focusResolved {
652 ... on SocialGrainPhoto {
653 uri
654 alt
655 photo { url(preset: "feed_thumbnail") }
656 }
657 }
658 socialGrainActorProfileByDid {
659 displayName
660 avatar { url(preset: "avatar") }
661 }
662 }
663 }
664 }
665 viewerSocialGrainFavoriteViaSubject {
666 uri
667 }
668 }
669 }
670 }
671 }
672 `;
673
674 const response = await this.#execute(query, { handle, rkey });
675 const galleryNode = response.data?.socialGrainGallery?.edges?.[0]?.node;
676
677 if (!galleryNode) {
678 throw new Error('Gallery not found');
679 }
680
681 const profile = galleryNode.socialGrainActorProfileByDid;
682
683 const galleryItems = galleryNode.socialGrainGalleryItemViaGallery?.edges
684 ?.map(edge => edge.node)
685 ?.filter(Boolean) || [];
686
687 const photos = galleryItems
688 .map(item => item.itemResolved)
689 .filter(Boolean)
690 .map(photo => ({
691 uri: photo.uri,
692 url: photo.photo?.url || '',
693 alt: photo.alt || '',
694 aspectRatio: photo.aspectRatio
695 ? photo.aspectRatio.width / photo.aspectRatio.height
696 : 1
697 }));
698
699 const galleryItemUris = galleryItems.map(item => item.uri).filter(Boolean);
700 const photoUris = photos.map(p => p.uri).filter(Boolean);
701
702 const comments = galleryNode.socialGrainCommentViaSubject?.edges?.map(edge => {
703 const node = edge.node;
704 const commentProfile = node.socialGrainActorProfileByDid;
705 const focusPhoto = node.focusResolved;
706 return {
707 uri: node.uri,
708 text: node.text,
709 facets: node.facets || [],
710 createdAt: node.createdAt,
711 handle: node.actorHandle,
712 displayName: commentProfile?.displayName || '',
713 avatarUrl: commentProfile?.avatar?.url || '',
714 replyToUri: node.replyTo || null,
715 focusImageUrl: focusPhoto?.photo?.url || null,
716 focusImageAlt: focusPhoto?.alt || ''
717 };
718 }) || [];
719
720 return {
721 uri: galleryNode.uri,
722 title: galleryNode.title,
723 description: galleryNode.description,
724 facets: galleryNode.facets || [],
725 createdAt: galleryNode.createdAt,
726 handle: galleryNode.actorHandle,
727 displayName: profile?.displayName || '',
728 avatarUrl: profile?.avatar?.url || '',
729 photos,
730 galleryItemUris,
731 photoUris,
732 favoriteCount: galleryNode.socialGrainFavoriteViaSubject?.totalCount || 0,
733 commentCount: galleryNode.socialGrainCommentViaSubject?.totalCount || 0,
734 comments,
735 viewerHasFavorited: !!galleryNode.viewerSocialGrainFavoriteViaSubject?.uri,
736 viewerFavoriteUri: galleryNode.viewerSocialGrainFavoriteViaSubject?.uri || null
737 };
738 }
739
740 async getNotifications(viewerDid, { first = 20, after = null } = {}) {
741 const query = `
742 query Notifications($first: Int, $after: String) {
743 notifications(viewerDid: "${viewerDid}", first: $first, after: $after) {
744 edges {
745 node {
746 __typename
747 ... on SocialGrainFavorite {
748 uri
749 did
750 createdAt
751 subject
752 subjectResolved {
753 ... on SocialGrainGallery {
754 uri
755 title
756 actorHandle
757 socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) {
758 edges {
759 node {
760 itemResolved {
761 ... on SocialGrainPhoto {
762 photo { url(preset: "feed_thumbnail") }
763 }
764 }
765 }
766 }
767 }
768 }
769 }
770 socialGrainActorProfileByDid {
771 displayName
772 actorHandle
773 avatar { url(preset: "avatar") }
774 }
775 }
776 ... on SocialGrainGraphFollow {
777 uri
778 did
779 createdAt
780 socialGrainActorProfileByDid {
781 displayName
782 actorHandle
783 avatar { url(preset: "avatar") }
784 }
785 }
786 ... on SocialGrainComment {
787 uri
788 did
789 createdAt
790 text
791 subject
792 focus
793 replyTo
794 facets
795 subjectResolved {
796 ... on SocialGrainGallery {
797 uri
798 title
799 actorHandle
800 socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) {
801 edges {
802 node {
803 itemResolved {
804 ... on SocialGrainPhoto {
805 photo { url(preset: "feed_thumbnail") }
806 }
807 }
808 }
809 }
810 }
811 }
812 }
813 focusResolved {
814 ... on SocialGrainPhoto {
815 uri
816 alt
817 photo { url(preset: "feed_thumbnail") }
818 }
819 }
820 replyToResolved {
821 ... on SocialGrainComment {
822 uri
823 text
824 actorHandle
825 }
826 }
827 socialGrainActorProfileByDid {
828 displayName
829 actorHandle
830 avatar { url(preset: "avatar") }
831 }
832 }
833 ... on SocialGrainGallery {
834 uri
835 did
836 createdAt
837 title
838 description
839 facets
840 actorHandle
841 socialGrainGalleryItemViaGallery(first: 1, sortBy: [{ field: position, direction: ASC }]) {
842 edges {
843 node {
844 itemResolved {
845 ... on SocialGrainPhoto {
846 photo { url(preset: "feed_thumbnail") }
847 }
848 }
849 }
850 }
851 }
852 socialGrainActorProfileByDid {
853 displayName
854 actorHandle
855 avatar { url(preset: "avatar") }
856 }
857 }
858 }
859 }
860 pageInfo {
861 hasNextPage
862 endCursor
863 }
864 }
865 }
866 `;
867
868 const response = await this.#execute(query, { first, after });
869 return this.#transformNotificationsResponse(response, viewerDid);
870 }
871
872 #transformNotificationsResponse(response, viewerDid) {
873 const connection = response.data?.notifications;
874 if (!connection) return { notifications: [], pageInfo: { hasNextPage: false, endCursor: null } };
875
876 const notifications = connection.edges
877 .map(edge => {
878 const node = edge.node;
879 const reason = this.#getNotificationReason(node, viewerDid);
880 if (!reason) return null;
881
882 const profile = node.socialGrainActorProfileByDid;
883 const author = {
884 handle: profile?.actorHandle || '',
885 displayName: profile?.displayName || '',
886 avatarUrl: profile?.avatar?.url || ''
887 };
888
889 const base = {
890 uri: node.uri,
891 createdAt: node.createdAt,
892 reason,
893 author
894 };
895
896 switch (node.__typename) {
897 case 'SocialGrainFavorite': {
898 const gallery = node.subjectResolved;
899 const thumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url;
900 return {
901 ...base,
902 gallery: gallery ? {
903 uri: gallery.uri,
904 title: gallery.title,
905 handle: gallery.actorHandle,
906 thumbnailUrl: thumb || ''
907 } : null
908 };
909 }
910 case 'SocialGrainGraphFollow':
911 return base;
912 case 'SocialGrainComment': {
913 const gallery = node.subjectResolved;
914 const galleryThumb = gallery?.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url;
915 const focusPhoto = node.focusResolved;
916 const replyTo = node.replyToResolved;
917 return {
918 ...base,
919 text: node.text,
920 gallery: gallery ? {
921 uri: gallery.uri,
922 title: gallery.title,
923 handle: gallery.actorHandle,
924 thumbnailUrl: galleryThumb || ''
925 } : null,
926 focusPhoto: focusPhoto ? {
927 uri: focusPhoto.uri,
928 alt: focusPhoto.alt,
929 thumbnailUrl: focusPhoto.photo?.url || ''
930 } : null,
931 replyTo: replyTo ? {
932 uri: replyTo.uri,
933 text: replyTo.text,
934 handle: replyTo.actorHandle
935 } : null
936 };
937 }
938 case 'SocialGrainGallery': {
939 const thumb = node.socialGrainGalleryItemViaGallery?.edges?.[0]?.node?.itemResolved?.photo?.url;
940 return {
941 ...base,
942 gallery: {
943 uri: node.uri,
944 title: node.title,
945 description: node.description,
946 handle: node.actorHandle,
947 thumbnailUrl: thumb || ''
948 }
949 };
950 }
951 default:
952 return null;
953 }
954 })
955 .filter(Boolean);
956
957 return {
958 notifications,
959 pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null }
960 };
961 }
962
963 #getNotificationReason(node, viewerDid) {
964 switch (node.__typename) {
965 case 'SocialGrainFavorite':
966 return 'gallery-favorite';
967 case 'SocialGrainGraphFollow':
968 return 'follow';
969 case 'SocialGrainComment':
970 if (this.#hasMentionFacet(node.facets, viewerDid)) {
971 return 'gallery-comment-mention';
972 }
973 if (node.replyTo) {
974 return 'reply';
975 }
976 return 'gallery-comment';
977 case 'SocialGrainGallery':
978 if (this.#hasMentionFacet(node.facets, viewerDid)) {
979 return 'gallery-mention';
980 }
981 return null;
982 default:
983 return null;
984 }
985 }
986
987 #hasMentionFacet(facets, viewerDid) {
988 if (!Array.isArray(facets)) return false;
989 return facets.some(facet => {
990 const features = facet.features;
991 if (!Array.isArray(features)) return false;
992 return features.some(f =>
993 f.$type === 'app.bsky.richtext.facet#mention' && f.did === viewerDid
994 );
995 });
996 }
997
998 async getCurrentProfile(client) {
999 const result = await client.query(`
1000 query {
1001 viewer {
1002 did
1003 handle
1004 socialGrainActorProfileByDid {
1005 displayName
1006 description
1007 avatar { url ref mimeType size }
1008 }
1009 }
1010 }
1011 `);
1012
1013 const viewer = result.viewer;
1014 const profile = viewer?.socialGrainActorProfileByDid;
1015 const avatar = profile?.avatar;
1016
1017 return {
1018 did: viewer?.did || '',
1019 handle: viewer?.handle || '',
1020 displayName: profile?.displayName || '',
1021 description: profile?.description || '',
1022 avatarUrl: avatar?.url || '',
1023 avatarBlob: avatar ? {
1024 $type: 'blob',
1025 ref: { $link: avatar.ref },
1026 mimeType: avatar.mimeType,
1027 size: avatar.size
1028 } : null
1029 };
1030 }
1031
1032 async getComments(galleryUri, { first = 20, after = null } = {}) {
1033 const query = `
1034 query GetComments($galleryUri: String!, $first: Int, $after: String) {
1035 socialGrainComment(
1036 first: $first
1037 after: $after
1038 where: { subject: { eq: $galleryUri } }
1039 sortBy: [{ field: createdAt, direction: ASC }]
1040 ) {
1041 edges {
1042 node {
1043 uri
1044 text
1045 facets
1046 createdAt
1047 actorHandle
1048 replyTo
1049 focus
1050 focusResolved {
1051 ... on SocialGrainPhoto {
1052 uri
1053 alt
1054 photo { url(preset: "feed_thumbnail") }
1055 }
1056 }
1057 socialGrainActorProfileByDid {
1058 displayName
1059 avatar { url(preset: "avatar") }
1060 }
1061 }
1062 }
1063 pageInfo {
1064 hasNextPage
1065 endCursor
1066 }
1067 totalCount
1068 }
1069 }
1070 `;
1071
1072 const response = await this.#execute(query, { galleryUri, first, after });
1073 const connection = response.data?.socialGrainComment;
1074
1075 if (!connection) {
1076 return { comments: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 };
1077 }
1078
1079 const comments = connection.edges.map(edge => {
1080 const node = edge.node;
1081 const profile = node.socialGrainActorProfileByDid;
1082 const focusPhoto = node.focusResolved;
1083 return {
1084 uri: node.uri,
1085 text: node.text,
1086 facets: node.facets || [],
1087 createdAt: node.createdAt,
1088 handle: node.actorHandle,
1089 displayName: profile?.displayName || '',
1090 avatarUrl: profile?.avatar?.url || '',
1091 replyToUri: node.replyTo || null,
1092 focusImageUrl: focusPhoto?.photo?.url || null,
1093 focusImageAlt: focusPhoto?.alt || ''
1094 };
1095 });
1096
1097 return {
1098 comments,
1099 pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null },
1100 totalCount: connection.totalCount || 0
1101 };
1102 }
1103
1104 async resolveHandle(handle) {
1105 const query = `
1106 query ResolveHandle($handle: String!) {
1107 socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) {
1108 edges {
1109 node { did }
1110 }
1111 }
1112 }
1113 `;
1114
1115 const response = await this.#execute(query, { handle });
1116 const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did;
1117
1118 if (!did) {
1119 throw new Error(`Handle not found: ${handle}`);
1120 }
1121
1122 return did;
1123 }
1124
1125 async hasGrainProfile(client) {
1126 const result = await client.query(`
1127 query {
1128 viewer {
1129 socialGrainActorProfileByDid {
1130 displayName
1131 }
1132 }
1133 }
1134 `);
1135 return !!result.viewer?.socialGrainActorProfileByDid;
1136 }
1137
1138 async getBlueskyProfile(client) {
1139 const result = await client.query(`
1140 query {
1141 viewer {
1142 did
1143 handle
1144 appBskyActorProfileByDid {
1145 displayName
1146 description
1147 avatar { url ref mimeType size }
1148 }
1149 }
1150 }
1151 `);
1152
1153 const viewer = result.viewer;
1154 const profile = viewer?.appBskyActorProfileByDid;
1155 const avatar = profile?.avatar;
1156
1157 return {
1158 did: viewer?.did || '',
1159 handle: viewer?.handle || '',
1160 displayName: profile?.displayName || '',
1161 description: profile?.description || '',
1162 avatarUrl: avatar?.url || '',
1163 avatarBlob: avatar ? {
1164 $type: 'blob',
1165 ref: { $link: avatar.ref },
1166 mimeType: avatar.mimeType,
1167 size: avatar.size
1168 } : null
1169 };
1170 }
1171}
1172
1173export const grainApi = new GrainApiService();