grain.social is a photo sharing platform built on atproto.
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}