[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import * as so from "../lex/so.ts";
2import { uriToDid as didFromUri } from "../utils/uris.ts";
3import {
4 HydrationMap,
5 ItemRef,
6 parseRecord,
7 parseString,
8 RecordInfo,
9 split,
10} from "./util.ts";
11import { DataPlane } from "../data-plane/index.ts";
12
13export type FeedGenRecord = so.sprk.feed.generator.Main;
14export type LikeRecord = so.sprk.feed.like.Main;
15export type PostRecord = so.sprk.feed.post.Main;
16export type ReplyRecord = so.sprk.feed.reply.Main;
17export type RepostRecord = so.sprk.feed.repost.Main;
18export type AudioRecord = so.sprk.sound.audio.Main;
19
20export type Post = RecordInfo<PostRecord>;
21export type Posts = HydrationMap<Post>;
22export type Reply = RecordInfo<ReplyRecord>;
23export type Replies = HydrationMap<Reply>;
24export type Sound = RecordInfo<AudioRecord>;
25export type Sounds = HydrationMap<Sound>;
26
27export type SoundAgg = {
28 uses: number;
29};
30
31export type SoundAggs = HydrationMap<SoundAgg>;
32
33export type PostViewerState = {
34 like?: string;
35 repost?: string;
36};
37
38export type PostViewerStates = HydrationMap<PostViewerState>;
39
40export type ThreadContext = {
41 // Whether the root author has liked the post.
42 like?: string;
43};
44
45export type ThreadContexts = HydrationMap<ThreadContext>;
46
47export type PostAgg = {
48 likes: number;
49 replies: number;
50 reposts: number;
51};
52
53export type PostAggs = HydrationMap<PostAgg>;
54
55export type ReplyAgg = {
56 likes: number;
57 replies: number;
58};
59
60export type ReplyAggs = HydrationMap<ReplyAgg>;
61
62export type Like = RecordInfo<LikeRecord>;
63export type Likes = HydrationMap<Like>;
64
65export type Repost = RecordInfo<RepostRecord>;
66export type Reposts = HydrationMap<Repost>;
67
68export type FeedGenAgg = {
69 likes: number;
70};
71
72export type FeedGenAggs = HydrationMap<FeedGenAgg>;
73
74export type FeedGen = RecordInfo<FeedGenRecord>;
75export type FeedGens = HydrationMap<FeedGen>;
76
77export type FeedGenViewerState = {
78 like?: string;
79};
80
81export type FeedGenViewerStates = HydrationMap<FeedGenViewerState>;
82
83export type KnownInteractionState = {
84 type: "like" | "repost" | "reply";
85 by: string; // DID of the person who interacted
86 uri: string;
87 cid: string;
88 indexedAt: Date;
89 text?: string; // Only for replies
90};
91
92export type KnownInteractionsStates = HydrationMap<
93 KnownInteractionState[] | undefined
94>;
95
96export type ThreadRef = ItemRef & { threadRoot: string };
97
98export type FeedItem = {
99 post: ItemRef;
100};
101
102export class FeedHydrator {
103 constructor(public dataplane: DataPlane) {}
104
105 async getPosts(
106 uris: string[],
107 includeTakedowns = false,
108 given = new HydrationMap<Post>(),
109 ): Promise<Posts> {
110 const [have, need] = split(uris, (uri) => given.has(uri));
111 const base = have.reduce(
112 (acc, uri) => acc.set(uri, given.get(uri) ?? null),
113 new HydrationMap<Post>(),
114 );
115 if (!need.length) return base;
116 const res = await this.dataplane.records.getPostRecords(need);
117
118 return need.reduce((acc, uri, i) => {
119 const record = parseRecord<PostRecord>(
120 so.sprk.feed.post.main,
121 res.records[i],
122 includeTakedowns,
123 );
124 return acc.set(
125 uri,
126 record ? record : null,
127 );
128 }, base);
129 }
130
131 async getReplies(
132 uris: string[],
133 includeTakedowns = false,
134 given = new HydrationMap<Reply>(),
135 ): Promise<Replies> {
136 const [have, need] = split(uris, (uri) => given.has(uri));
137 const base = have.reduce(
138 (acc, uri) => acc.set(uri, given.get(uri) ?? null),
139 new HydrationMap<Reply>(),
140 );
141 if (!need.length) return base;
142 const res = await this.dataplane.records.getReplyRecords(need);
143
144 return need.reduce((acc, uri, i) => {
145 const record = parseRecord<ReplyRecord>(
146 so.sprk.feed.reply.main,
147 res.records[i],
148 includeTakedowns,
149 );
150 return acc.set(
151 uri,
152 record ? record : null,
153 );
154 }, base);
155 }
156
157 async getSounds(
158 uris: string[],
159 includeTakedowns = false,
160 given = new HydrationMap<Sound>(),
161 ): Promise<Sounds> {
162 const [have, need] = split(uris, (uri) => given.has(uri));
163 const base = have.reduce(
164 (acc, uri) => acc.set(uri, given.get(uri) ?? null),
165 new HydrationMap<Sound>(),
166 );
167 if (!need.length) return base;
168 const res = await this.dataplane.records.getRecords(need);
169
170 return need.reduce((acc, uri, i) => {
171 const record = parseRecord<AudioRecord>(
172 so.sprk.sound.audio.main,
173 res.records[i],
174 includeTakedowns,
175 );
176 return acc.set(
177 uri,
178 record ? record : null,
179 );
180 }, base);
181 }
182
183 async getPostViewerStates(
184 refs: ThreadRef[],
185 viewer: string,
186 ): Promise<PostViewerStates> {
187 if (!refs.length) return new HydrationMap<PostViewerState>();
188 const [likes, reposts] = await Promise.all([
189 await this.dataplane.likes.byActorAndSubjects(viewer, refs),
190 await this.dataplane.reposts.byActorAndSubjects(
191 viewer,
192 refs,
193 ),
194 ]);
195 return refs.reduce((acc, { uri }, i) => {
196 return acc.set(uri, {
197 like: parseString(likes.uris[i]),
198 repost: parseString(reposts.uris[i]),
199 });
200 }, new HydrationMap<PostViewerState>());
201 }
202
203 async getThreadContexts(refs: ThreadRef[]): Promise<ThreadContexts> {
204 if (!refs.length) return new HydrationMap<ThreadContext>();
205
206 const refsByRootAuthor = refs.reduce((acc, ref) => {
207 const { threadRoot } = ref;
208 const rootAuthor = didFromUri(threadRoot);
209 const existingValue = acc.get(rootAuthor) ?? [];
210 return acc.set(rootAuthor, [...existingValue, ref]);
211 }, new Map<string, ThreadRef[]>());
212 const refsByRootAuthorEntries = Array.from(refsByRootAuthor.entries());
213
214 const likesPromises = refsByRootAuthorEntries.map(
215 ([rootAuthor, refsForAuthor]) =>
216 this.dataplane.likes.byActorAndSubjects(
217 rootAuthor,
218 refsForAuthor.map(({ uri, cid }) => ({ uri, cid })),
219 ),
220 );
221
222 const rootAuthorsLikes = await Promise.all(likesPromises);
223
224 const likesByUri = refsByRootAuthorEntries.reduce(
225 (acc, [_rootAuthor, refsForAuthor], i) => {
226 const likesForRootAuthor = rootAuthorsLikes[i];
227 refsForAuthor.forEach(({ uri }, j) => {
228 acc.set(uri, likesForRootAuthor.uris[j]);
229 });
230 return acc;
231 },
232 new Map<string, string>(),
233 );
234
235 return refs.reduce((acc, { uri }) => {
236 return acc.set(uri, {
237 like: parseString(likesByUri.get(uri)),
238 });
239 }, new HydrationMap<ThreadContext>());
240 }
241
242 async getPostAggregates(
243 refs: ItemRef[],
244 ): Promise<PostAggs> {
245 if (!refs.length) return new HydrationMap<PostAgg>();
246 const counts = await this.dataplane.interactions.getInteractionCounts(refs);
247 return refs.reduce((acc, { uri }, i) => {
248 return acc.set(uri, {
249 likes: counts.likes[i] ?? 0,
250 reposts: counts.reposts[i] ?? 0,
251 replies: counts.replies[i] ?? 0,
252 });
253 }, new HydrationMap<PostAgg>());
254 }
255
256 async getReplyAggregates(
257 refs: ItemRef[],
258 ): Promise<ReplyAggs> {
259 if (!refs.length) return new HydrationMap<ReplyAgg>();
260 const counts = await this.dataplane.interactions.getInteractionCounts(refs);
261 return refs.reduce((acc, { uri }, i) => {
262 return acc.set(uri, {
263 likes: counts.likes[i] ?? 0,
264 replies: counts.replies[i] ?? 0,
265 });
266 }, new HydrationMap<ReplyAgg>());
267 }
268
269 async getSoundAggregates(
270 refs: ItemRef[],
271 ): Promise<SoundAggs> {
272 if (!refs.length) return new HydrationMap<SoundAgg>();
273 const uris = refs.map((ref) => ref.uri);
274 const counts = await this.dataplane.interactions.getSoundUsageCounts(uris);
275 return refs.reduce((acc, { uri }, i) => {
276 return acc.set(uri, {
277 uses: counts.uses[i] ?? 0,
278 });
279 }, new HydrationMap<SoundAgg>());
280 }
281
282 async getFeedGens(
283 uris: string[],
284 includeTakedowns = false,
285 ): Promise<FeedGens> {
286 if (!uris.length) return new HydrationMap<FeedGen>();
287 const res = await this.dataplane.records.getFeedGeneratorRecords(uris);
288 return uris.reduce((acc, uri, i) => {
289 const record = parseRecord<FeedGenRecord>(
290 so.sprk.feed.generator.main,
291 res.records[i],
292 includeTakedowns,
293 );
294 return acc.set(uri, record ?? null);
295 }, new HydrationMap<FeedGen>());
296 }
297
298 async getFeedGenViewerStates(
299 uris: string[],
300 viewer: string,
301 ): Promise<FeedGenViewerStates> {
302 if (!uris.length) return new HydrationMap<FeedGenViewerState>();
303 const likes = await this.dataplane.likes.byActorAndSubjects(
304 viewer,
305 uris.map((uri) => ({ uri })),
306 );
307 return uris.reduce((acc, uri, i) => {
308 return acc.set(uri, {
309 like: parseString(likes.uris[i]),
310 });
311 }, new HydrationMap<FeedGenViewerState>());
312 }
313
314 async getFeedGenAggregates(
315 refs: ItemRef[],
316 ): Promise<FeedGenAggs> {
317 if (!refs.length) return new HydrationMap<FeedGenAgg>();
318 const counts = await this.dataplane.interactions.getInteractionCounts(refs);
319 return refs.reduce((acc, { uri }, i) => {
320 return acc.set(uri, {
321 likes: counts.likes[i] ?? 0,
322 });
323 }, new HydrationMap<FeedGenAgg>());
324 }
325
326 async getLikes(uris: string[], includeTakedowns = false): Promise<Likes> {
327 if (!uris.length) return new HydrationMap<Like>();
328 const res = await this.dataplane.records.getLikeRecords(uris);
329 return uris.reduce((acc, uri, i) => {
330 const record = parseRecord<LikeRecord>(
331 so.sprk.feed.like.main,
332 res.records[i],
333 includeTakedowns,
334 );
335 return acc.set(uri, record ?? null);
336 }, new HydrationMap<Like>());
337 }
338
339 async getReposts(uris: string[], includeTakedowns = false): Promise<Reposts> {
340 if (!uris.length) return new HydrationMap<Repost>();
341 const res = await this.dataplane.records.getRepostRecords(uris);
342 return uris.reduce((acc, uri, i) => {
343 const record = parseRecord<RepostRecord>(
344 so.sprk.feed.repost.main,
345 res.records[i],
346 includeTakedowns,
347 );
348 return acc.set(uri, record ?? null);
349 }, new HydrationMap<Repost>());
350 }
351
352 async getKnownInteractions(
353 refs: ItemRef[],
354 viewer: string | null,
355 ): Promise<KnownInteractionsStates> {
356 if (!viewer || !refs.length) {
357 return new HydrationMap<KnownInteractionState[] | undefined>();
358 }
359
360 const subjectUris = refs.map((ref) => ref.uri);
361 const { results } = await this.dataplane.interactions.getKnownInteractions(
362 viewer,
363 subjectUris,
364 );
365
366 return refs.reduce((acc, { uri }) => {
367 const interactions = results.get(uri);
368 return acc.set(
369 uri,
370 interactions && interactions.length > 0
371 ? interactions.map((i) => ({
372 type: i.type,
373 by: i.authorDid,
374 uri: i.uri,
375 cid: i.cid,
376 indexedAt: new Date(i.indexedAt),
377 text: i.text,
378 }))
379 : undefined,
380 );
381 }, new HydrationMap<KnownInteractionState[] | undefined>());
382 }
383}