[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { AtUri } from "@atp/syntax";
2import { DataPlane } from "../data-plane/index.ts";
3import * as com from "../lex/com.ts";
4import * as so from "../lex/so.ts";
5import { ParsedLabelers } from "../util.ts";
6import {
7 HydrationMap,
8 Merges,
9 parseRecord,
10 parseString,
11 RecordInfo,
12} from "./util.ts";
13
14export type Label = com.atproto.label.defs.Label;
15export type LabelerRecord = so.sprk.labeler.service.Main;
16
17export type SubjectLabels = {
18 isImpersonation: boolean;
19 isTakendown: boolean;
20 needsReview: boolean;
21 labels: HydrationMap<Label>; // src + val -> label
22};
23
24export class Labels extends HydrationMap<SubjectLabels> implements Merges {
25 static key(label: Label) {
26 return `${label.src}::${label.val}`;
27 }
28 override merge(map: Labels): this {
29 map.forEach((theirs, key) => {
30 if (!theirs) return;
31 const mine = this.get(key);
32 if (mine) {
33 mine.isTakendown = mine.isTakendown || theirs.isTakendown;
34 mine.labels = mine.labels.merge(theirs.labels);
35 } else {
36 this.set(key, theirs);
37 }
38 });
39 return this;
40 }
41 getBySubject(sub: string): Label[] {
42 const it = this.get(sub)?.labels.values();
43 if (!it) return [];
44 const labels: Label[] = [];
45 for (const label of it) {
46 if (label) labels.push(label);
47 }
48 return labels;
49 }
50}
51
52export type LabelerAgg = {
53 likes: number;
54};
55
56export type LabelerAggs = HydrationMap<LabelerAgg>;
57
58export type Labeler = RecordInfo<LabelerRecord>;
59export type Labelers = HydrationMap<Labeler>;
60
61export type LabelerViewerState = {
62 like?: string;
63};
64
65export type LabelerViewerStates = HydrationMap<LabelerViewerState>;
66
67export class LabelHydrator {
68 constructor(public dataplane: DataPlane) {}
69
70 async getLabelsForSubjects(
71 subjects: string[],
72 labelers: ParsedLabelers,
73 ): Promise<Labels> {
74 if (!subjects.length || !labelers.dids.length) return new Labels();
75 const res = await this.dataplane.labels.getLabels(
76 subjects,
77 labelers.dids,
78 );
79
80 return res.labels.reduce((acc, cur) => {
81 const label = cur as unknown as Label;
82 if (!label || label.neg) return acc;
83 const { sig: _, ...labelWithoutSig } = label;
84 let entry = acc.get(label.uri);
85 if (!entry) {
86 entry = {
87 isImpersonation: false,
88 isTakendown: false,
89 needsReview: false,
90 labels: new HydrationMap(),
91 };
92 acc.set(label.uri, entry);
93 }
94
95 const isActionableNeedsReview = label.val === NEEDS_REVIEW_LABEL &&
96 !label.neg &&
97 labelers.redact.has(label.src);
98
99 // we action needs review labels on backend for now so don't send to client until client has proper logic for them
100 if (!isActionableNeedsReview) {
101 entry.labels.set(Labels.key(labelWithoutSig), labelWithoutSig);
102 }
103
104 if (
105 TAKEDOWN_LABELS.includes(label.val) &&
106 !label.neg &&
107 labelers.redact.has(label.src)
108 ) {
109 entry.isTakendown = true;
110 }
111 if (isActionableNeedsReview) {
112 entry.needsReview = true;
113 }
114 if (
115 label.val === IMPERSONATION_LABEL &&
116 !label.neg &&
117 labelers.redact.has(label.src)
118 ) {
119 entry.isImpersonation = true;
120 }
121
122 return acc;
123 }, new Labels());
124 }
125
126 async getLabelers(
127 dids: string[],
128 includeTakedowns = false,
129 ): Promise<Labelers> {
130 const uris = dids.map(labelerDidToUri);
131 const res = await this.dataplane.records.getRecords(uris);
132 return dids.reduce((acc, did, i) => {
133 const record = parseRecord<LabelerRecord>(
134 so.sprk.labeler.service.main,
135 res.records[i],
136 includeTakedowns,
137 );
138 return acc.set(did, record ?? null);
139 }, new HydrationMap<Labeler>());
140 }
141
142 async getLabelerViewerStates(
143 dids: string[],
144 viewer: string,
145 ): Promise<LabelerViewerStates> {
146 const refs = dids.map((did) => ({ uri: labelerDidToUri(did) }));
147 const likes = await this.dataplane.likes.byActorAndSubjects(viewer, refs);
148 return dids.reduce((acc, did, i) => {
149 return acc.set(did, {
150 like: parseString(likes.uris[i]),
151 });
152 }, new HydrationMap<LabelerViewerState>());
153 }
154
155 async getLabelerAggregates(
156 dids: string[],
157 _viewer: string | null,
158 ): Promise<LabelerAggs> {
159 const refs = dids.map((did) => ({ uri: labelerDidToUri(did) }));
160 const counts = await this.dataplane.interactions.getInteractionCounts(refs);
161 return dids.reduce((acc, did, i) => {
162 return acc.set(did, {
163 likes: counts.likes[i] ?? 0,
164 });
165 }, new HydrationMap<LabelerAgg>());
166 }
167}
168
169const labelerDidToUri = (did: string): string => {
170 return AtUri.make(did, so.sprk.labeler.service.$type, "self").toString();
171};
172
173const IMPERSONATION_LABEL = "impersonation";
174const TAKEDOWN_LABELS = ["!takedown", "!suspend"];
175const NEEDS_REVIEW_LABEL = "needs-review";