import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; import type { BobbinContext } from "#lib/api/client.ts"; import { httpStatusFor } from "#lib/api/load.ts"; import { PROFILE_COLLECTION, putProfile, uploadProfileAvatar } from "#lib/api/profile.ts"; import { getProfile, type ProfileRecord } from "#lib/api/records.ts"; import type { PinnedKey } from "#lib/api/pinning.ts"; import { bumpAvatar } from "#lib/avatar.ts"; type ProfileLink = NonNullable[number]; export const normalizeProfileLink = (raw: string): ProfileLink | undefined => { const trimmed = raw.trim(); if (!trimmed) return undefined; if (/\s/.test(trimmed)) throw new Error("Enter a valid URL, like example.com."); let url: URL; try { url = new URL(/^[a-z][a-z\d+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`); } catch { throw new Error("Enter a valid URL, like example.com."); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("Links must use http or https."); } if (!url.hostname.includes(".")) { throw new Error("Links need a full domain, like example.com."); } return url.toString() as ProfileLink; }; export interface ProfileForm { description: string; pronouns: string; location: string; links: string[]; pinned: PinnedKey[]; isOrganization: boolean; } export const LINK_SLOTS = 5; const isFilled = (link: string) => link.trim() !== ""; export const visibleLinkCount = (links: readonly string[], slots = LINK_SLOTS): number => { const lastFilled = links.findLastIndex(isFilled); return Math.min(slots, lastFilled + 2); }; export const compactLinks = (links: readonly string[], slots = LINK_SLOTS): string[] => { const filled = links.filter(isFilled); return [...filled, ...Array(Math.max(slots - filled.length, 0)).fill("")]; }; 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, pinned: [...new Set(profile?.pinnedRepositories)], 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, pinnedRepositories: _pinnedRepositories, isOrganization: _isOrganization, ...rest } = current ?? {}; return { ...rest, $type: PROFILE_COLLECTION, ...(form.isOrganization ? { isOrganization: true } : {}), ...(description ? { description } : {}), ...(pronouns ? { pronouns } : {}), ...(location ? { location } : {}), ...(links.length > 0 ? { links } : {}), ...(form.pinned.length > 0 ? { pinnedRepositories: form.pinned } : {}) }; }; 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, ...(avatar ? { avatar } : {}) }; const saved = await writeProfile(ctx, agent, record); bumpAvatar(agent.sub); return saved; };