Files
radicle-explorer/src/lib/utils.ts
T
Rūdolfs Ošiņš 17e7a29cb9 Treat RIDs with non-base58 characters as invalid
parseRepositoryId threw instead of returning undefined, so the seed form
crashed on a typo instead of saying the RID is invalid.
2026-10-01 18:20:24 +02:00

439 lines
13 KiB
TypeScript

import type { Author } from "@bindings/cob/Author";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Review } from "@bindings/cob/patch/Review";
import type { Config } from "@bindings/config/Config";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { ComponentProps } from "svelte";
import bs58 from "bs58";
import escape from "lodash/escape.js";
import md5 from "md5";
import twemojiModule from "twemoji";
import NodeId from "@app/components/NodeId.svelte";
export const unreachable = (value: never): never => {
throw new Error(`Unreachable code: ${value}`);
};
export const REFS_HEADS = "refs/heads/";
// The single place to change once the API stops carrying the default branch
// in the project payload, which Heartwood no longer requires.
export function defaultBranch(repo: RepoInfo): string | undefined {
return repo.payloads["xyz.radicle.project"]?.data.defaultBranch;
}
export function unqualifyBranch(refname: string): string {
return refname.startsWith(REFS_HEADS)
? refname.slice(REFS_HEADS.length)
: refname;
}
export function formatRepositoryId(id: string): string {
const parsedId = parseRepositoryId(id);
if (parsedId) {
return `${parsedId.prefix}${truncateId(parsedId.pubkey)}`;
}
return id;
}
export function parseRepositoryId(
rid: string,
): { prefix: string; pubkey: string } | undefined {
const match = /^(rad:)?(z[a-zA-Z0-9]+)$/.exec(rid);
if (match) {
let hex: Uint8Array;
try {
hex = bs58.decode(match[2].substring(1));
} catch {
return undefined;
}
if (hex.byteLength !== 20) {
return undefined;
}
return { prefix: match[1] || "rad:", pubkey: match[2] };
}
return undefined;
}
export function truncateId(pubkey: string): string {
return `${pubkey.substring(0, 6)}…${pubkey.slice(-6)}`;
}
export function truncateDid(did: string): string {
return `did:key:${truncateId(publicKeyFromDid(did))}`;
}
export function didFromPublicKey(publicKey: string) {
return `did:key:${publicKey}`;
}
export function publicKeyFromDid(did: string) {
return did.replace("did:key:", "");
}
export function isCommit(input: string): boolean {
return /^[a-f0-9]{40}$/.test(input);
}
export function formatOid(id: string): string {
return id.substring(0, 7);
}
export const formatTimestamp = (
timestamp: number,
current = new Date().getTime(),
): string => {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = (365 * DAY) / 12;
const YEAR = 365 * DAY;
const elapsed = current - timestamp;
if (elapsed < MINUTE) return "now";
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m`;
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h`;
if (elapsed < MONTH) return `${Math.floor(elapsed / DAY)}d`;
if (elapsed < YEAR) return `${Math.floor(elapsed / MONTH)}mo`;
return `${Math.floor(elapsed / YEAR)}y`;
};
// Svelte action that swallows mousedown so the activating element does not
// steal focus from whatever is currently focused. Useful for click targets
// that should not collapse a focused input.
//
// Usage: <div use:preserveFocus>...</div>
export function preserveFocus(node: HTMLElement) {
const handler = (e: MouseEvent) => e.preventDefault();
node.addEventListener("mousedown", handler);
return {
destroy() {
node.removeEventListener("mousedown", handler);
},
};
}
// Svelte action for replacing emojis within an element with Twemoji SVGs.
// This action is non-reactive; it only runs when the element is mounted.
//
// Usage: <span use:twemoji>👍</span>
export function twemoji(
node: HTMLElement,
{ exclude }: { exclude: string[] } = { exclude: [] },
) {
twemojiModule.parse(node, {
callback: (icon, options) => {
const { base, size, ext } = options as Record<string, string>;
if (!exclude.includes(icon)) {
return `${base}${size}/${icon}${ext}`;
}
return false;
},
base: "/",
folder: "twemoji",
ext: ".svg",
className: `txt-emoji`,
});
}
// Converts a single emoji character to an <img> tag using Twemoji SVG assets.
// This function is useful in reactive contexts where Svelte actions won't
// re-run automatically.
//
// Usage: <span>{@html emojiToTwemoji("👍")}</span>
export function emojiToTwemoji(emoji: string, exclude?: string[]) {
const filename = emoji.codePointAt(0)?.toString(16);
if (!filename || exclude?.includes(filename)) {
return "";
}
return `<img alt="${escape(emoji)}" src="/twemoji/${filename}.svg" class="txt-emoji">`;
}
export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
const lineElement = document.getElementById(id);
if (lineElement) {
lineElement.scrollIntoView(options);
}
}
export const issueStatusColor: Record<Issue["state"]["status"], string> = {
open: "var(--color-text-open)",
closed: "var(--color-text-closed)",
};
export const issueStatusBackgroundColor: Record<
Issue["state"]["status"],
string
> = {
open: "var(--color-surface-open)",
closed: "var(--color-surface-closed)",
};
export const issueStatusIcon = {
open: "issue",
closed: "issue-closed",
} as const satisfies Record<Issue["state"]["status"], string>;
export const issueStatusLabel: Record<Issue["state"]["status"], string> = {
open: "Open Issues",
closed: "Closed Issues",
};
export const patchStatusColor: Record<Patch["state"]["status"], string> = {
draft: "var(--color-text-draft)",
open: "var(--color-text-open)",
archived: "var(--color-text-archived)",
merged: "var(--color-text-merged)",
};
export const patchStatusBackgroundColor: Record<
Patch["state"]["status"],
string
> = {
draft: "var(--color-surface-draft)",
open: "var(--color-surface-open)",
archived: "var(--color-surface-archived)",
merged: "var(--color-surface-merged)",
};
export const patchStatusLabel: Record<Patch["state"]["status"], string> = {
draft: "Draft Patches",
open: "Open Patches",
archived: "Archived Patches",
merged: "Merged Patches",
};
export function authorForNodeId(author: Author): ComponentProps<typeof NodeId> {
return { publicKey: publicKeyFromDid(author.did), alias: author.alias };
}
export function absoluteTimestamp(timestamp: number) {
return new Date(Number(timestamp)).toLocaleString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function formatEditedCaption(author: Author, timestamp: number) {
return `${author.alias ? author.alias : truncateDid(author.did)} edited ${absoluteTimestamp(timestamp)}`;
}
export function formatResolvedCaption(author: Author, timestamp: number) {
return `${author.alias ? author.alias : truncateDid(author.did)} resolved this ${absoluteTimestamp(timestamp)}`;
}
export function pluralize(singular: string, count: number): string {
if (count === 1) return singular;
if (/(?:ch|sh|s|x|z)$/i.test(singular)) {
return `${singular}es`;
}
return `${singular}s`;
}
export function isMac() {
if (
(navigator.platform && navigator.platform.includes("Mac")) ||
navigator.userAgent.includes("OS X")
) {
return true;
} else {
return false;
}
}
export function modifierKey() {
return isMac() ? "⌘" : "ctrl";
}
export function parseNodeId(
nid: string,
): { prefix: string; pubkey: string } | undefined {
const match = /^(did:key:)?(z[a-zA-Z0-9]+)$/.exec(nid);
if (match) {
let hex: Uint8Array;
try {
hex = bs58.decode(match[2].substring(1));
} catch (error) {
console.error("utils.parseNodId: Not able to decode received NID", error);
return undefined;
}
// This checks also that the first 2 bytes are equal
// to the ed25519 public key type used.
if (hex && !(hex.byteLength === 34 && hex[0] === 0xed && hex[1] === 1)) {
return undefined;
}
return { prefix: match[1] || "did:key:", pubkey: match[2] };
}
return undefined;
}
// Get the gravatar URL of an email.
export function gravatarURL(email: string): string {
const address = email.trim().toLowerCase();
const hash = md5(address);
return `https://www.gravatar.com/avatar/${hash}`;
}
export function verdictIcon(verdict: Review["verdict"]) {
if (verdict === "accept") {
return "thumbs-up";
} else if (verdict === "reject") {
return "stop";
} else {
return "comment";
}
}
// `revisions_by_patch` returns revisions in the patch's timeline order — the
// causal order the COB recorded them in — and that order is what "revision N of
// M" means throughout the UI. Do not re-sort by `timestamp` to recover it: a
// revision's timestamp is supplied by whoever authored it, so a peer with a
// skewed clock would silently renumber everyone's revisions.
export function revisionPosition(
revisions: { id: string }[],
revisionId: string,
): number | undefined {
const index = revisions.findIndex(r => r.id === revisionId);
return index === -1 ? undefined : index + 1;
}
// A review without a verdict is a comment-only review — what `rad patch review`
// produces with neither -a nor -r. The protocol has no "needs changes" or
// "request changes" state, so neither label may imply one: doing so would put
// words into the mouth of every reviewer who ever left a plain comment.
export function verdictAction(verdict: Review["verdict"]): string {
if (verdict === "accept") {
return "Accept";
} else if (verdict === "reject") {
return "Reject";
} else {
return "Comment";
}
}
export function verdictBadge(verdict: Review["verdict"]): {
label: string;
variant: "accept" | "reject" | "comment";
} {
if (verdict === "accept") {
return { label: "Accepted", variant: "accept" };
} else if (verdict === "reject") {
return { label: "Rejected", variant: "reject" };
} else {
return { label: "Reviewed", variant: "comment" };
}
}
export function safeHttpUrl(url: string): string | undefined {
try {
const parsed = new URL(url);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.toString();
}
} catch {
// fall through
}
return undefined;
}
// Seed to fall back to when `config.preferredSeeds` is empty.
const DEFAULT_SEED = "rosa.radicle.network";
// The host of the first entry in `config.preferredSeeds`, if any, parsed from
// its "<NID>@<host>:<port>" address form. Used to pick a seed that's actually
// known to replicate the user's repos, instead of a hardcoded default.
function preferredSeedHost(config: Config): string | undefined {
const address = config.preferredSeeds[0];
if (!address) {
return undefined;
}
return address.split("@")[1]?.split(":")[0] ?? address;
}
// The hostname of the configured public explorer, e.g. "radicle.network".
// Used to label links with the site they actually open.
export function explorerHost(config: Config): string {
try {
return new URL(config.publicExplorer).host;
} catch {
return config.publicExplorer;
}
}
// Build an explorer URL, honouring the user's configured `publicExplorer`
// template and preferred seed. The template mirrors radicle's `Explorer`
// type: "<base>/nodes/$host/$rid$path". `path` is everything after the seed
// host, e.g. `<rid>/issues/<id>` or `users/<did>`.
export function explorerUrl(path: string, config: Config): string {
const seed = preferredSeedHost(config) ?? DEFAULT_SEED;
return config.publicExplorer
.replace("$host", seed)
.replace("$rid$path", path);
}
export interface GitIdentity {
name: string;
email: string;
}
// Two git identities are the same person when they share an address, compared
// case-insensitively the way `gravatarURL` normalises it. An identity may carry
// no address at all, so those fall back to the name rather than collapsing into
// each other.
export function identityKey(who: GitIdentity): string {
const email = who.email.trim().toLowerCase();
return email ? `email:${email}` : `name:${who.name.trim().toLowerCase()}`;
}
// A commit carries an author date and a commit date, which come apart on a
// rebase or an amend. Views show the commit date, so the tooltip only names the
// two apart when there is something to tell apart.
export function commitTimes(commit: Commit): string {
const committed = absoluteTimestamp(commit.committer.time * 1000);
if (commit.author.time === commit.committer.time) {
return committed;
}
return [
`Authored — ${absoluteTimestamp(commit.author.time * 1000)}`,
`Committed — ${committed}`,
].join("\n");
}
// Extract `Co-authored-by:` trailers from a commit message.
//
// Git only treats the final paragraph of a message as trailers, so a line
// appearing anywhere earlier — quoted in prose, or in a nested patch — is not
// one and is ignored here too. Matching is case-insensitive because git's own
// trailer handling is. The name may be empty, since a git identity only
// requires an address.
export function coAuthors(message: string): GitIdentity[] {
const paragraphs = message.trimEnd().split(/(?:\r?\n){2,}/);
const trailers = paragraphs[paragraphs.length - 1] ?? "";
const authors: GitIdentity[] = [];
for (const line of trailers.split("\n")) {
const match = /^co-authored-by:\s*(.*?)\s*<([^<>\s]+)>$/i.exec(line.trim());
if (match) {
authors.push({ name: match[1], email: match[2] });
}
}
return authors;
}