import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import type { BobbinContext } from "$lib/api/client"; import { httpStatusFor } from "$lib/api/load"; import { PROFILE_COLLECTION, putProfile, uploadProfileAvatar } from "$lib/api/profile"; import { getProfile, type ProfileRecord } from "$lib/api/records"; type ProfileLink = NonNullable[number]; export const normalizeProfileLink = (raw: string): ProfileLink | undefined => { const trimmed = raw.trim(); if (!trimmed) return undefined; let url: URL; try { url = new URL(/^[a-z][a-z\d+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`); } catch { throw new Error("Links must be valid URLs."); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("Links must use http or https."); } return url.toString() as ProfileLink; }; export interface ProfileForm { description: string; pronouns: string; location: string; links: string[]; bluesky: boolean; isOrganization: boolean; } export const LINK_SLOTS = 3; export const loadProfile = (ctx: BobbinContext, did: string): Promise => getProfile(ctx, did) .then((view) => view.value) .catch((cause) => { if (httpStatusFor(cause) === 404) return null; throw cause; }); export const formFromProfile = (profile: ProfileRecord | null): ProfileForm => { const links: string[] = [...(profile?.links ?? [])]; while (links.length < LINK_SLOTS) links.push(""); return { description: profile?.description ?? "", pronouns: profile?.pronouns ?? "", location: profile?.location ?? "", links, bluesky: profile?.bluesky ?? false, isOrganization: profile?.isOrganization ?? false }; }; export const profileFromForm = ( form: ProfileForm, current: ProfileRecord | null ): ProfileRecord => { const description = form.description.trim(); const pronouns = form.pronouns.trim(); const location = form.location.trim(); const links = form.links.map(normalizeProfileLink).filter((link) => link !== undefined); const { description: _description, pronouns: _pronouns, location: _location, links: _links, isOrganization: _isOrganization, ...rest } = current ?? {}; return { ...rest, $type: PROFILE_COLLECTION, bluesky: form.bluesky, ...(form.isOrganization ? { isOrganization: true } : {}), ...(description ? { description } : {}), ...(pronouns ? { pronouns } : {}), ...(location ? { location } : {}), ...(links.length > 0 ? { links } : {}) }; }; export const writeProfile = async ( ctx: BobbinContext, agent: OAuthUserAgent, record: ProfileRecord ): Promise => { const written = await putProfile(agent, record); await awaitIndexedOrSaved(ctx, written); return record; }; export const saveAvatar = async ( ctx: BobbinContext, agent: OAuthUserAgent, image: Blob | null, current: ProfileRecord | null ): Promise => { const avatar = image ? await uploadProfileAvatar(agent, image) : undefined; const { avatar: _avatar, ...rest } = current ?? {}; const record: ProfileRecord = { ...rest, $type: PROFILE_COLLECTION, bluesky: current?.bluesky ?? false, ...(avatar ? { avatar } : {}) }; return writeProfile(ctx, agent, record); };