~james/core

core/web/src/lib/api/profileSettings.ts -rw-r--r-- 4.1 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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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;
};