view who was fronting when a record was made
1import {
2 parseResourceUri,
3 safeParse,
4 type InferOutput,
5} from "@atcute/lexicons";
6import { Client as AtpClient, simpleFetchHandler } from "@atcute/client";
7import {
8 ActorIdentifier,
9 Did,
10 GenericUri,
11 Handle,
12 isHandle,
13 Nsid,
14 RecordKey,
15 type AtprotoDid,
16 type ResourceUri,
17} from "@atcute/lexicons/syntax";
18import { err, ok, Result } from "./result";
19import * as v from "@atcute/lexicons/validations";
20import {
21 CompositeDidDocumentResolver,
22 CompositeHandleResolver,
23 DohJsonHandleResolver,
24 PlcDidDocumentResolver,
25 WebDidDocumentResolver,
26 WellKnownHandleResolver,
27} from "@atcute/identity-resolver";
28import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity";
29import { PersistentCache } from "./cache";
30import { AppBskyNotificationListNotifications } from "@atcute/bluesky";
31
32export type Subject = {
33 handle?: Handle;
34 did: AtprotoDid;
35 rkey: RecordKey;
36};
37
38export type Fronter = {
39 members: {
40 uri?: MemberUri;
41 name: string;
42 }[];
43 handle: Handle | null;
44 did: AtprotoDid;
45 subject?: Subject;
46 replyTo?: ResourceUri;
47};
48
49export type FronterView = Fronter & { rkey: RecordKey } & (
50 | {
51 type: "thread_reply";
52 }
53 | {
54 type: "thread_post";
55 displayName: string;
56 depth: number;
57 }
58 | {
59 type: "post";
60 }
61 | {
62 type: "like";
63 }
64 | {
65 type: "repost";
66 }
67 | {
68 type: "notification";
69 reason: InferOutput<AppBskyNotificationListNotifications.notificationSchema>["reason"];
70 }
71 | {
72 type: "post_repost_entry";
73 displayName: string;
74 }
75 | {
76 type: "post_like_entry";
77 displayName: string;
78 }
79 );
80export type FronterType = FronterView["type"];
81
82export const fronterSchema = v.record(
83 v.string(),
84 v.object({
85 $type: v.literal("systems.gaze.atfronter.fronter"),
86 subject: v.resourceUriString(),
87 members: v.array(
88 v.object({
89 name: v.string(),
90 uri: v.optional(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?)
91 }),
92 ),
93 }),
94);
95export type FronterSchema = InferOutput<typeof fronterSchema>;
96
97export type MemberUri =
98 | { type: "at"; recordUri: ResourceUri }
99 | { type: "pk"; systemId: string; memberId: string }
100 | { type: "sp"; systemId: string; memberId: string };
101
102export const parseMemberId = (memberId: GenericUri): MemberUri => {
103 const uri = new URL(memberId);
104 switch (uri.protocol) {
105 case "pk:": {
106 const split = uri.pathname.split("/").slice(1);
107 return { type: "pk", systemId: split[0], memberId: split[1] };
108 }
109 case "sp:": {
110 const split = uri.pathname.split("/").slice(1);
111 return { type: "sp", systemId: split[0], memberId: split[1] };
112 }
113 case "at:": {
114 return { type: "at", recordUri: memberId as ResourceUri };
115 }
116 default: {
117 throw new Error(`Invalid member ID: ${memberId}`);
118 }
119 }
120};
121export const memberUriString = (memberUri: MemberUri): GenericUri => {
122 switch (memberUri.type) {
123 case "pk": {
124 return `pk://api.pluralkit.me/${memberUri.memberId}`;
125 }
126 case "sp": {
127 return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`;
128 }
129 case "at": {
130 return memberUri.recordUri;
131 }
132 }
133};
134export const getMemberPublicUri = (memberUri: MemberUri) => {
135 switch (memberUri.type) {
136 case "pk": {
137 return `https://dash.pluralkit.me/profile/m/${memberUri.memberId}`;
138 }
139 case "sp": {
140 return null;
141 }
142 case "at": {
143 return `https://pdsls.dev/${memberUri.recordUri}`;
144 }
145 }
146};
147
148// Member cache instance
149const memberCache = new PersistentCache("member_cache", 24);
150
151export const fetchMember = async (
152 memberUri: MemberUri,
153): Promise<string | undefined> => {
154 const s = memberUriString(memberUri);
155 const cached = await memberCache.get(s);
156 switch (memberUri.type) {
157 case "sp": {
158 if (cached) return cached.content.name;
159 const token = await storage.getItem<string>("sync:sp_token");
160 if (!token) return;
161 const resp = await fetch(
162 `https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`,
163 {
164 headers: {
165 authorization: token,
166 },
167 },
168 );
169 if (!resp.ok) return;
170 const member = await resp.json();
171 await memberCache.set(s, member);
172 return member.content.name;
173 }
174 case "pk": {
175 if (cached) return cached.name;
176 const resp = await fetch(
177 `https://api.pluralkit.me/v2/members/${memberUri.memberId}`,
178 );
179 if (!resp.ok) return;
180 const member = await resp.json();
181 await memberCache.set(s, member);
182 return member.name;
183 }
184 }
185};
186
187export const getFronterNames = async (
188 members: { name?: string; uri?: MemberUri }[],
189) => {
190 const promises = await Promise.allSettled(
191 members.map(async (m): Promise<Fronter["members"][0] | null> => {
192 if (!m.uri) return Promise.resolve({ uri: undefined, name: m.name! });
193 if (m.name) return Promise.resolve({ uri: m.uri, name: m.name });
194 const name = await fetchMember(m.uri);
195 return name ? { uri: m.uri, name } : null;
196 }),
197 );
198 return promises
199 .filter((p) => p.status === "fulfilled")
200 .flatMap((p) => p.value ?? []);
201};
202
203export const handleResolver = new CompositeHandleResolver({
204 strategy: "race",
205 methods: {
206 dns: new DohJsonHandleResolver({
207 dohUrl: "https://mozilla.cloudflare-dns.com/dns-query",
208 }),
209 http: new WellKnownHandleResolver(),
210 },
211});
212export const docResolver = new CompositeDidDocumentResolver({
213 methods: {
214 plc: new PlcDidDocumentResolver(),
215 web: new WebDidDocumentResolver(),
216 },
217});
218
219const resolveRepo = async (repo: ActorIdentifier) => {
220 let handle: Handle | null;
221 let did = repo;
222 if (isHandle(repo)) {
223 handle = repo;
224 did = await handleResolver.resolve(repo);
225 } else {
226 const didDoc = await docResolver.resolve(repo as AtprotoDid);
227 handle = getAtprotoHandle(didDoc) ?? null;
228 }
229 return { did: did as AtprotoDid, handle };
230};
231
232const getAtpClient = async (repo: AtprotoDid) => {
233 const didDoc = await docResolver.resolve(repo);
234 const pdsUrl = getPdsEndpoint(didDoc);
235 if (pdsUrl === undefined) throw `no pds found`;
236 const handler = simpleFetchHandler({ service: pdsUrl });
237 return new AtpClient({ handler });
238};
239
240export const frontersCache = new PersistentCache<Fronter | null>(
241 "cachedFronters",
242 24,
243);
244
245export const getFronter = async <Uri extends ResourceUri>(
246 recordUri: Uri,
247): Promise<Result<Fronter, string>> => {
248 const parsedRecordUri = parseResourceUri(recordUri);
249 if (!parsedRecordUri.ok) return err(parsedRecordUri.error);
250
251 // resolve repo
252 const { did, handle } = await resolveRepo(parsedRecordUri.value.repo);
253
254 // make client
255 const atpClient = await getAtpClient(did);
256
257 // fetch
258 let maybeRecord = await atpClient.get("com.atproto.repo.getRecord", {
259 params: {
260 repo: did,
261 collection: fronterSchema.object.shape.$type.expected,
262 rkey: `${parsedRecordUri.value.collection}_${parsedRecordUri.value.rkey}`,
263 },
264 });
265 if (!maybeRecord.ok)
266 return err(maybeRecord.data.message ?? maybeRecord.data.error);
267
268 // parse
269 const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value);
270 if (!maybeTyped.ok) return err(maybeTyped.message);
271
272 let members: Fronter["members"];
273 try {
274 members = maybeTyped.value.members.map((m) => ({
275 name: m.name,
276 uri: m.uri ? parseMemberId(m.uri) : undefined,
277 }));
278 } catch (error) {
279 return err(`error fetching fronter names: ${error}`);
280 }
281
282 return ok({
283 members,
284 handle,
285 did,
286 });
287};
288
289export const putFronter = async (
290 subject: FronterSchema["subject"],
291 members: { name?: string; uri?: MemberUri }[],
292 authToken: string,
293): Promise<Result<Fronter, string>> => {
294 const parsedRecordUri = parseResourceUri(subject);
295 if (!parsedRecordUri.ok) return err(parsedRecordUri.error);
296 const { repo, collection, rkey } = parsedRecordUri.value;
297
298 // resolve repo
299 const { did, handle } = await resolveRepo(repo);
300
301 // make client
302 const atpClient = await getAtpClient(did);
303
304 let filteredMembers: Fronter["members"];
305 try {
306 filteredMembers = await getFronterNames(members);
307 } catch (error) {
308 return err(`error fetching fronter names: ${error}`);
309 }
310
311 // put
312 let maybeRecord = await atpClient.post("com.atproto.repo.putRecord", {
313 input: {
314 repo: did,
315 collection: fronterSchema.object.shape.$type.expected,
316 rkey: `${collection}_${rkey}`,
317 record: {
318 subject,
319 members: filteredMembers.map((member) => ({
320 name: member.name,
321 uri: member.uri ? memberUriString(member.uri) : undefined,
322 })),
323 },
324 validate: false,
325 },
326 headers: { authorization: `Bearer ${authToken}` },
327 });
328 if (!maybeRecord.ok)
329 return err(maybeRecord.data.message ?? maybeRecord.data.error);
330
331 return ok({
332 did,
333 handle,
334 members: filteredMembers,
335 });
336};
337
338export const deleteFronter = async (
339 did: AtprotoDid,
340 collection: Nsid,
341 rkey: RecordKey,
342 authToken: string,
343): Promise<Result<boolean, string>> => {
344 // make client
345 const atpClient = await getAtpClient(did);
346
347 // delete
348 let maybeRecord = await atpClient.post("com.atproto.repo.deleteRecord", {
349 input: {
350 repo: did,
351 collection: fronterSchema.object.shape.$type.expected,
352 rkey: `${collection}_${rkey}`,
353 },
354 headers: { authorization: `Bearer ${authToken}` },
355 });
356 if (!maybeRecord.ok)
357 return err(maybeRecord.data.message ?? maybeRecord.data.error);
358
359 return ok(true);
360};
361
362export const getSpFronters = async (): Promise<
363 Parameters<typeof putFronter>["1"]
364> => {
365 const spToken = await storage.getItem<string>("sync:sp_token");
366 if (!spToken) return [];
367 const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, {
368 headers: {
369 authorization: spToken,
370 },
371 });
372 if (!resp.ok) return [];
373 const spFronters = (await resp.json()) as any[];
374 return spFronters.map((fronter) => ({
375 name: undefined,
376 uri: {
377 type: "sp",
378 memberId: fronter.content.member,
379 systemId: fronter.content.uid,
380 },
381 }));
382};
383
384export const getPkFronters = async (): Promise<
385 Parameters<typeof putFronter>["1"]
386> => {
387 const pkSystemId = await storage.getItem<string>("sync:pk-system");
388 if (!pkSystemId) return [];
389 const resp = await fetch(
390 `https://api.pluralkit.me/v2/systems/${pkSystemId}/fronters`,
391 );
392 if (!resp.ok) return [];
393 const pkFronters = await resp.json();
394 return (pkFronters.members as any[]).map((member) => ({
395 name: member.display_name ?? member.name,
396 uri: {
397 type: "pk",
398 memberId: member.id,
399 systemId: member.system,
400 },
401 }));
402};
403
404export const fronterGetSocialAppHrefs = (view: FronterView) => {
405 if (view.type === "repost" && view.subject) {
406 const subject = view.subject;
407 const handle = subject?.handle;
408 return [
409 handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}#repost`] : [],
410 `${fronterGetSocialAppHref(subject.did, subject.rkey)}#repost`,
411 ].flat();
412 } else if (view.type === "notification" && view.subject) {
413 const subject = view.subject;
414 const handle = subject?.handle;
415 return [
416 handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}`] : [],
417 `${fronterGetSocialAppHref(subject.did, subject.rkey)}`,
418 ].flat();
419 } else if (
420 view.type === "post_repost_entry" ||
421 view.type === "post_like_entry"
422 ) {
423 return [
424 view.handle ? [`/profile/${view.handle}`] : [],
425 `/profile/${view.did}`,
426 ].flat();
427 }
428 const depth = view.type === "thread_post" ? view.depth : undefined;
429 return [
430 view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [],
431 fronterGetSocialAppHref(view.did, view.rkey, depth),
432 ].flat();
433};
434
435export const fronterGetSocialAppHref = (
436 repo: string,
437 rkey: RecordKey,
438 depth?: number,
439) => {
440 return depth === 0 ? `/profile/${repo}` : `/profile/${repo}/post/${rkey}`;
441};
442
443export const parseSocialAppPostUrl = (url: string) => {
444 const match = url.match(/https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/);
445 if (!match) return;
446 const [website, actorIdentifier, rkey] = match;
447 return { actorIdentifier, rkey };
448};
449
450export const displayNameCache = new PersistentCache<string>(
451 "displayNameCache",
452 1,
453);