Files
radicle-desktop/src/lib/utils.ts
T
Rūdolfs Ošiņš 982a568538 Polish author and committer popover
Show the email alongside each name. Co-authors are read from the commit
message trailers and listed as their own section. The trigger avatars
now stack and fan apart on hover, and cover everyone credited on the
commit rather than just the author and committer. Timestamps now show
both committed and authored times in the tooltip.
2026-09-09 10:49:50 +02:00

429 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) {
const hex = bs58.decode(match[2].substring(1));
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 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;
}