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<ProfileRecord["links"]>[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<string>(Math.max(slots - filled.length, 0)).fill("")];
};
export const loadProfile = (ctx: BobbinContext, did: string): Promise<ProfileRecord | null> =>
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<ProfileRecord> => {
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<ProfileRecord> => {
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;
};