~james/core

core/web/src/lib/api/profile.ts -rw-r--r-- 3.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { ClientResponseError, ok } from "@atcute/client";
import { mainSchema as getRecordSchema } from "@atcute/atproto/types/repo/getRecord";
import { mainSchema as uploadBlobSchema } from "@atcute/atproto/types/repo/uploadBlob";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { createClient } from "#lib/auth/agent.ts";
import { FOLLOW_VIEWER, STAR_VIEWER } from "#lib/api/descriptors.ts";
import { enrich, target } from "#lib/api/enrich.ts";
import { jsonGet } from "#lib/api/_request.ts";
import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts";
import { nsidOf, recordKeyOf } from "#lib/api/syntax.ts";
import { putRecord, type WrittenRecord } from "#lib/api/write.ts";
import type * as OrgTangledActorGetTrending from "#lib/api/lexicons/types/org/tangled/actor/getTrending.ts";
import type { ProfileRecord } from "#lib/api/records.ts";

export const PROFILE_COLLECTION = "org.tangled.actor.profile";

export const PROFILE_LIMITS = { description: 256, pronouns: 30, location: 40 } as const;

const AVATAR_TYPES: Record<string, true> = { "image/png": true, "image/jpeg": true };
const MAX_AVATAR_BYTES = 1_000_000;

export const getTrending = (ctx: BobbinContext, init?: XrpcRequestInit) =>
	jsonGet<OrgTangledActorGetTrending.$output>(ctx, "org.tangled.actor.getTrending", {}, init);

export const getTrendingWithViewerState = (
	ctx: BobbinContext,
	viewer: string,
	init?: XrpcRequestInit
) =>
	enrich<OrgTangledActorGetTrending.$output>(
		ctx,
		{
			xrpc: "org.tangled.actor.getTrending",
			viewer,
			enrich: [target(FOLLOW_VIEWER, ["people[].did"]), target(STAR_VIEWER, ["repos[].did"])]
		},
		init
	);

export const avatarProblem = (image: Blob): string | undefined => {
	if (!AVATAR_TYPES[image.type]) return "Choose a PNG or JPEG image.";
	if (image.size > MAX_AVATAR_BYTES) return "Profile pictures must be 1 MB or smaller.";
	return undefined;
};

export const uploadProfileAvatar = async (
	agent: OAuthUserAgent,
	image: Blob
): Promise<NonNullable<ProfileRecord["avatar"]>> => {
	const problem = avatarProblem(image);
	if (problem) throw new Error(problem);

	const rpc = createClient(agent);
	const { blob } = await ok(rpc.call(uploadBlobSchema, { input: image }));
	return blob;
};

// the profile record lives at the fixed rkey `self`; put upserts it.
export const putProfile = async (
	agent: OAuthUserAgent,
	record: ProfileRecord
): Promise<WrittenRecord> => {
	return putRecord(agent, nsidOf(PROFILE_COLLECTION), recordKeyOf("self"), record);
};

// asks the account's own pds rather than bobbin: a fresh account is not indexed
// yet, and a stale index answer here would overwrite a profile the user edited
export const hasProfile = async (agent: OAuthUserAgent): Promise<boolean> => {
	try {
		await ok(
			createClient(agent).call(getRecordSchema, {
				params: { repo: agent.sub, collection: PROFILE_COLLECTION, rkey: "self" }
			})
		);
		return true;
	} catch (cause) {
		if (cause instanceof ClientResponseError && cause.error === "RecordNotFound") return false;
		throw cause;
	}
};

// the counterpart of the home page's getProfile check that sends new users to
// /welcome; accounts nobody logs into as themselves get their profile written here
export const ensureProfile = async (
	agent: OAuthUserAgent,
	record: ProfileRecord
): Promise<void> => {
	if (await hasProfile(agent)) return;
	await putProfile(agent, record);
};