grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

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

at a456d5309ca6cd8b11f00b53d8fc01e1ad8d9052 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}