| 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 | */ |
| 11 | import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts"; |
| 12 | |
| 13 | /** How recently someone committed to the repository the card opened in. */ |
| 14 | export type CommittedWithin = "day" | "week" | "month"; |
| 15 | |
| 16 | export 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. */ |
| 35 | export type G1tCard = { kind: "g1t"; username: "g1t" }; |
| 36 | |
| 37 | export type Card = UserCard | G1tCard; |
| 38 | |
| 39 | export const MAX_WORKSPACES = 3; |
| 40 | |
| 41 | const DAY_MS = 86_400_000; |
| 42 | |
| 43 | /** `day`, `week` or `month` for a commit at `at` (RFC 3339), else null. */ |
| 44 | export 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. */ |
| 55 | export 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. */ |
| 60 | export 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. */ |
| 66 | export 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 | */ |
| 80 | export 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. */ |
| 126 | export 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 | } |