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);
};