WIP PWA for Grain
0
fork

Configure Feed

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

at main 1173 lines 36 kB view raw
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();