grain.social is a photo sharing platform built on atproto.
at main 446 lines 12 kB view raw
1import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts"; 2import { Label } from "$lexicon/types/com/atproto/label/defs.ts"; 3import { Record as TangledProfile } from "$lexicon/types/sh/tangled/actor/profile.ts"; 4import { 5 ProfileView, 6 ProfileViewDetailed, 7 ViewerState, 8} from "$lexicon/types/social/grain/actor/defs.ts"; 9import { Record as GrainProfile } from "$lexicon/types/social/grain/actor/profile.ts"; 10import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 11import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 12import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 13import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 14import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; 15import { $Typed } from "$lexicon/util.ts"; 16import { BffContext, WithBffMeta } from "@bigmoves/bff"; 17import { 18 galleryToView, 19 getGalleryCameras, 20 getGalleryItemsAndPhotos, 21} from "./gallery.ts"; 22import { getFollow, getFollowersCount, getFollowsCount } from "./graph.ts"; 23import { photoToView, photoUrl } from "./photo.ts"; 24import type { SocialNetwork } from "./timeline.ts"; 25 26export function getActorProfile(did: string, ctx: BffContext) { 27 const actor = ctx.indexService.getActor(did); 28 if (!actor) return null; 29 const profileRecord = ctx.indexService.getRecord<WithBffMeta<GrainProfile>>( 30 `at://${did}/social.grain.actor.profile/self`, 31 ); 32 return profileRecord ? profileToView(profileRecord, actor.handle) : null; 33} 34 35export function getActorProfileDetailed(did: string, ctx: BffContext) { 36 const actor = ctx.indexService.getActor(did); 37 if (!actor) return null; 38 const profileRecord = ctx.indexService.getRecord<WithBffMeta<GrainProfile>>( 39 `at://${did}/social.grain.actor.profile/self`, 40 ); 41 const followersCount = getFollowersCount(did, ctx); 42 const followsCount = getFollowsCount(did, ctx); 43 const galleries = getActorGalleries(did, ctx); 44 const cameras = Array.from( 45 new Set( 46 galleries.flatMap((g) => 47 getGalleryCameras(g.items?.filter(isPhotoView) ?? []) 48 ), 49 ), 50 ).sort((a, b) => a.localeCompare(b)); 51 52 let followedBy: string | undefined = ""; 53 let following: string | undefined = ""; 54 if (ctx.currentUser) { 55 followedBy = getFollow(ctx.currentUser.did, did, ctx)?.uri; 56 following = getFollow(did, ctx.currentUser.did, ctx)?.uri; 57 } 58 59 return profileRecord 60 ? profileDetailedToView({ 61 record: profileRecord, 62 handle: actor.handle, 63 cameras, 64 followersCount, 65 followsCount, 66 galleryCount: galleries.length, 67 viewer: { 68 followedBy, 69 following, 70 }, 71 }) 72 : null; 73} 74 75export function profileToView( 76 record: WithBffMeta<GrainProfile>, 77 handle: string, 78): $Typed<ProfileView> { 79 return { 80 $type: "social.grain.actor.defs#profileView", 81 cid: record.cid, 82 did: record.did, 83 handle, 84 displayName: record.displayName, 85 description: record.description, 86 avatar: record?.avatar 87 ? photoUrl(record.did, record.avatar.ref.toString(), "thumbnail") 88 : undefined, 89 }; 90} 91 92export function profileDetailedToView(params: { 93 record: WithBffMeta<GrainProfile>; 94 handle: string; 95 followersCount: number; 96 followsCount: number; 97 galleryCount: number; 98 viewer: ViewerState; 99 cameras?: string[]; 100}): $Typed<ProfileViewDetailed> { 101 const { 102 record, 103 handle, 104 followersCount, 105 followsCount, 106 galleryCount, 107 viewer, 108 cameras, 109 } = params; 110 return { 111 $type: "social.grain.actor.defs#profileViewDetailed", 112 cid: record.cid, 113 did: record.did, 114 handle, 115 displayName: record.displayName, 116 description: record.description, 117 avatar: record?.avatar 118 ? photoUrl(record.did, record.avatar.ref.toString(), "thumbnail") 119 : undefined, 120 followersCount, 121 followsCount, 122 galleryCount, 123 viewer, 124 cameras, 125 }; 126} 127 128export function getActorPhotos(handleOrDid: string, ctx: BffContext) { 129 let did: string; 130 131 if (handleOrDid.includes("did:")) { 132 did = handleOrDid; 133 } else { 134 const actor = ctx.indexService.getActorByHandle(handleOrDid); 135 if (!actor) return []; 136 did = actor.did; 137 } 138 139 const photos = ctx.indexService.getRecords<WithBffMeta<Photo>>( 140 "social.grain.photo", 141 { 142 where: [{ field: "did", equals: did }], 143 orderBy: [{ field: "createdAt", direction: "desc" }], 144 }, 145 ); 146 const exif = ctx.indexService.getRecords<WithBffMeta<PhotoExif>>( 147 "social.grain.photo.exif", 148 { 149 where: [{ field: "photo", in: photos.items.map((p) => p.uri) }], 150 }, 151 ); 152 const exifMap = new Map<string, WithBffMeta<PhotoExif>>(); 153 exif.items.forEach((e) => { 154 exifMap.set(e.photo, e); 155 }); 156 return photos.items.map((photo) => { 157 const exifData = exifMap.get(photo.uri); 158 return photoToView(photo.did, photo, exifData); 159 }); 160} 161 162export function getActorGalleries(handleOrDid: string, ctx: BffContext) { 163 let did: string; 164 165 if (handleOrDid.includes("did:")) { 166 did = handleOrDid; 167 } else { 168 const actor = ctx.indexService.getActorByHandle(handleOrDid); 169 if (!actor) return []; 170 did = actor.did; 171 } 172 173 const { items: galleries } = ctx.indexService.getRecords< 174 WithBffMeta<Gallery> 175 >("social.grain.gallery", { 176 where: [{ field: "did", equals: did }], 177 orderBy: [{ field: "createdAt", direction: "desc" }], 178 }); 179 180 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 181 const creator = getActorProfile(did, ctx); 182 const labelMap = new Map<string, Label[]>(); 183 for (const gallery of galleries) { 184 const labels = ctx.indexService.queryLabels({ subjects: [gallery.uri] }); 185 labelMap.set(gallery.uri, labels); 186 } 187 188 if (!creator) return []; 189 190 return galleries.map((gallery) => 191 galleryToView({ 192 record: gallery, 193 creator, 194 items: galleryPhotosMap.get(gallery.uri) ?? [], 195 labels: labelMap.get(gallery.uri) ?? [], 196 }) 197 ); 198} 199 200export function getActorGalleryFavs(handleOrDid: string, ctx: BffContext) { 201 let did: string; 202 203 if (handleOrDid.includes("did:")) { 204 did = handleOrDid; 205 } else { 206 const actor = ctx.indexService.getActorByHandle(handleOrDid); 207 if (!actor) return []; 208 did = actor.did; 209 } 210 211 const { items: favRecords } = ctx.indexService.getRecords< 212 WithBffMeta<Favorite> 213 >( 214 "social.grain.favorite", 215 { 216 where: [{ field: "did", equals: did }], 217 orderBy: [{ field: "createdAt", direction: "desc" }], 218 }, 219 ); 220 221 if (!favRecords.length) return []; 222 223 const galleryUris = favRecords.map((fav) => fav.subject); 224 225 const { items: galleries } = ctx.indexService.getRecords< 226 WithBffMeta<Gallery> 227 >( 228 "social.grain.gallery", 229 { 230 where: [{ field: "uri", in: galleryUris }], 231 }, 232 ); 233 234 // Map gallery uri to gallery object for fast lookup 235 const galleryMap = new Map(galleries.map((g) => [g.uri, g])); 236 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 237 const creators = new Map<string, ReturnType<typeof getActorProfile>>(); 238 const uniqueDids = Array.from( 239 new Set(galleries.map((gallery) => gallery.did)), 240 ); 241 242 const labelMap = new Map<string, Label[]>(); 243 for (const gallery of galleries) { 244 const labels = ctx.indexService.queryLabels({ subjects: [gallery.uri] }); 245 labelMap.set(gallery.uri, labels); 246 } 247 248 const { items: profiles } = ctx.indexService.getRecords< 249 WithBffMeta<GrainProfile> 250 >( 251 "social.grain.actor.profile", 252 { 253 where: [{ field: "did", in: uniqueDids }], 254 }, 255 ); 256 257 for (const profile of profiles) { 258 const handle = ctx.indexService.getActor(profile.did)?.handle ?? ""; 259 creators.set(profile.did, profileToView(profile, handle)); 260 } 261 262 // Order galleries by the order of favRecords (favorited at) 263 return favRecords 264 .map((fav) => { 265 const gallery = galleryMap.get(fav.subject); 266 if (!gallery) return null; 267 const creator = creators.get(gallery.did); 268 if (!creator) return null; 269 return galleryToView({ 270 record: gallery, 271 creator, 272 items: galleryPhotosMap.get(gallery.uri) ?? [], 273 labels: labelMap.get(gallery.uri) ?? [], 274 }); 275 }) 276 .filter((g) => g !== null); 277} 278 279export function getActorProfiles( 280 handleOrDid: string, 281 ctx: BffContext, 282): SocialNetwork[] { 283 let did: string; 284 285 if (handleOrDid.includes("did:")) { 286 did = handleOrDid; 287 } else { 288 const actor = ctx.indexService.getActorByHandle(handleOrDid); 289 if (!actor) return []; 290 did = actor.did; 291 } 292 293 const { items: grainProfiles } = ctx.indexService.getRecords< 294 WithBffMeta<GrainProfile> 295 >( 296 "social.grain.actor.profile", 297 { 298 where: { 299 AND: [ 300 { field: "did", equals: did }, 301 { field: "uri", contains: "self" }, 302 ], 303 }, 304 }, 305 ); 306 307 const { items: tangledProfiles } = ctx.indexService.getRecords< 308 WithBffMeta<TangledProfile> 309 >( 310 "sh.tangled.actor.profile", 311 { 312 where: { 313 AND: [ 314 { field: "did", equals: did }, 315 { field: "uri", contains: "self" }, 316 ], 317 }, 318 }, 319 ); 320 321 const { items: bskyProfiles } = ctx.indexService.getRecords< 322 WithBffMeta<BskyProfile> 323 >( 324 "app.bsky.actor.profile", 325 { 326 where: { 327 AND: [ 328 { field: "did", equals: did }, 329 { field: "uri", contains: "self" }, 330 ], 331 }, 332 }, 333 ); 334 335 const profiles: SocialNetwork[] = []; 336 if (grainProfiles.length) profiles.push("grain"); 337 if (bskyProfiles.length) profiles.push("bluesky"); 338 if (tangledProfiles.length) profiles.push("tangled"); 339 return profiles; 340} 341 342export function getActorProfilesBulk( 343 dids: string[], 344 ctx: BffContext, 345) { 346 const { items: profiles } = ctx.indexService.getRecords< 347 WithBffMeta<GrainProfile> 348 >( 349 "social.grain.actor.profile", 350 { 351 where: { 352 AND: [ 353 { field: "did", in: dids }, 354 ], 355 }, 356 }, 357 ); 358 359 return profiles.map((profile) => { 360 const handle = ctx.indexService.getActor(profile.did)?.handle ?? ""; 361 return profileToView(profile, handle); 362 }); 363} 364 365export function searchActors(query: string, ctx: BffContext) { 366 const actors = ctx.indexService.searchActors(query); 367 368 const { items } = ctx.indexService.getRecords<WithBffMeta<GrainProfile>>( 369 "social.grain.actor.profile", 370 { 371 where: { 372 OR: [ 373 ...(actors.length > 0 374 ? [{ 375 field: "did", 376 in: actors.map((actor) => actor.did), 377 }] 378 : []), 379 { 380 field: "displayName", 381 contains: query, 382 }, 383 { 384 field: "did", 385 contains: query, 386 }, 387 ], 388 }, 389 }, 390 ); 391 392 const profileMap = new Map<string, WithBffMeta<GrainProfile>>(); 393 for (const item of items) { 394 profileMap.set(item.did, item); 395 } 396 397 const actorMap = new Map(); 398 actors.forEach((actor) => { 399 actorMap.set(actor.did, actor); 400 }); 401 402 const profileViews = []; 403 404 for (const actor of actors) { 405 if (profileMap.has(actor.did)) { 406 const profile = profileMap.get(actor.did)!; 407 profileViews.push(profileToView(profile, actor.handle)); 408 } 409 } 410 411 for (const profile of items) { 412 if (!actorMap.has(profile.did)) { 413 const handle = ctx.indexService.getActor(profile.did)?.handle; 414 if (!handle) continue; 415 profileViews.push(profileToView(profile, handle)); 416 } 417 } 418 419 return profileViews; 420} 421 422export async function updateActorProfile( 423 did: string, 424 ctx: BffContext, 425 params: { 426 displayName?: string; 427 description?: string; 428 avatar?: GrainProfile["avatar"]; 429 }, 430) { 431 const record = ctx.indexService.getRecord<WithBffMeta<GrainProfile>>( 432 `at://${did}/social.grain.actor.profile/self`, 433 ); 434 if (!record) return null; 435 436 const updated = await ctx.updateRecord<GrainProfile>( 437 "social.grain.actor.profile", 438 "self", 439 { 440 displayName: params.displayName ?? record.displayName, 441 description: params.description ?? record.description, 442 avatar: params.avatar ?? record.avatar, 443 }, 444 ); 445 return updated; 446}