Skip to content
129 linesCodeBlameRaw
1/**
2 * The card that opens over a person's name or avatar: who they are, as
3 * their public profile says, and how they relate to what the viewer is
4 * looking at. Only what the viewer may see: workspaces through
5 * `profile_workspaces` (those the viewer shares with them, and those they
6 * made a public project in), and commits to a repository only when the
7 * viewer may read it. Never an address.
8 *
9 * The body is snake_case, like every API body g1t answers with.
10 */
11import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts";
12
13/** How recently someone committed to the repository the card opened in. */
14export type CommittedWithin = "day" | "week" | "month";
15
16export type UserCard = {
17 kind: "user";
18 username: string;
19 name: string | null;
20 pronouns: string | null;
21 bio: string | null;
22 location: string | null;
23 /** The IANA time zone they gave, such as `America/Denver`; the card shows their local time from it. */
24 timezone: string | null;
25 avatar: string | null;
26 /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */
27 workspaces: { slug: string; name: string; avatar: string | null }[];
28 /** Workspaces past those listed. */
29 more_workspaces: number;
30 /** Their latest commit to the repository, by how long ago; null when none or not asked. */
31 committed: CommittedWithin | null;
32};
33
34/** g1t itself: no profile, a card of its own. */
35export type G1tCard = { kind: "g1t"; username: "g1t" };
36
37export type Card = UserCard | G1tCard;
38
39export const MAX_WORKSPACES = 3;
40
41const DAY_MS = 86_400_000;
42
43/** `day`, `week` or `month` for a commit at `at` (RFC 3339), else null. */
44export function committedWithin(at: string | null | undefined, now: number): CommittedWithin | null {
45 if (!at) return null;
46 const ago = now - Date.parse(at);
47 if (!Number.isFinite(ago) || ago < -DAY_MS) return null;
48 if (ago <= DAY_MS) return "day";
49 if (ago <= 7 * DAY_MS) return "week";
50 if (ago <= 31 * DAY_MS) return "month";
51 return null;
52}
53
54/** The line the card shows for it. */
55export function committedLabel(within: CommittedWithin): string {
56 return `Committed to this repository in the past ${within}`;
57}
58
59/** `owner/name` from the card's `repo` parameter, or null. */
60export function parseRepo(value: string | null | undefined): RepoPath | null {
61 const match = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})\/([A-Za-z0-9._-]{1,100})$/.exec(value?.trim() ?? "");
62 return match ? { namespace: match[1]!, name: match[2]! } : null;
63}
64
65/** What building a card reads, so tests can stand in for the services. */
66export type CardSources = {
67 profile(username: string): Promise<Profile | null>;
68 /** The account's id, to find where it has public projects. */
69 accountId(username: string): Promise<string | null>;
70 publicNamespaces(userId: string): Promise<string[]>;
71 profileWorkspaces(username: string, viewer: Viewer, publicIn: string[]): Promise<ProfileWorkspace[]>;
72 contributors(path: RepoPath, viewer: Viewer): Promise<Result<Contributors>>;
73};
74
75/**
76 * The card for `username` as `viewer` sees it, opened in `repo` if any;
77 * null when there is nobody by that name (or for ghost, a deleted
78 * account, which has no card).
79 */
80export async function buildCard(
81 username: string,
82 viewer: Viewer,
83 repo: RepoPath | null,
84 sources: CardSources,
85 now: number,
86): Promise<Card | null> {
87 const name = username.trim().toLowerCase();
88 if (name === "g1t") return { kind: "g1t", username: "g1t" };
89 if (!/^[a-z0-9-]{1,39}$/.test(name) || name === "ghost") return null;
90 const profile = await sources.profile(name).catch(() => null);
91 if (!profile) return null;
92 const workspaces = sources
93 .accountId(profile.username)
94 .then((id) => (id ? sources.publicNamespaces(id) : []))
95 .catch(() => [] as string[])
96 .then((publicIn) => sources.profileWorkspaces(profile.username, viewer, publicIn))
97 .catch(() => [] as ProfileWorkspace[]);
98 // The repository answers for the viewer: one they cannot read says nothing.
99 const committed = repo
100 ? sources
101 .contributors(repo, viewer)
102 .then((found) => {
103 if (!found.ok) return null;
104 const them = found.value.contributors.find((one) => one.username?.toLowerCase() === profile.username.toLowerCase());
105 return committedWithin(them?.lastAt, now);
106 })
107 .catch(() => null)
108 : Promise.resolve(null);
109 const [shown, within] = await Promise.all([workspaces, committed]);
110 return {
111 kind: "user",
112 username: profile.username,
113 name: profile.name,
114 pronouns: profile.pronouns,
115 bio: profile.bio,
116 location: profile.location,
117 timezone: profile.timezone ?? null,
118 avatar: profile.avatar,
119 workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })),
120 more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES),
121 committed: within,
122 };
123}
124
125/** Where the card for `username` is read from, opened in `repo` if any. */
126export function cardHref(username: string, repo: string | null): string {
127 const path = `/-/hovercard/user/${encodeURIComponent(username.toLowerCase())}`;
128 return repo ? `${path}?repo=${encodeURIComponent(repo)}` : path;
129}