Files
radicle-explorer/src/utils.ts
T
2022-11-15 16:30:07 +01:00

574 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ethers } from "ethers";
import twemojiModule from "twemoji";
import md5 from "md5";
import { BigNumber } from "ethers";
import katex from "katex";
import type { Wallet } from "@app/wallet";
import { assert } from "@app/error";
import type { EnsProfile } from "@app/base/registrations/registrar";
import { getAddress, getResolver } from "@app/base/registrations/registrar";
import {
getAvatar,
getSeed,
getRegistration,
} from "@app/base/registrations/registrar";
import { ProfileType } from "@app/profile";
import { parseUnits } from "@ethersproject/units";
import * as cache from "@app/cache";
import type { marked } from "marked";
import emojis from "@app/emojis";
import config from "@app/config.json";
export enum AddressType {
Contract,
Org,
EOA,
}
export interface Token {
name: string;
symbol: string;
logo: string;
decimals: number;
balance: BigNumber;
}
export enum Status {
Signing,
Pending,
Success,
Failed,
}
export type State =
| { status: Status.Signing }
| { status: Status.Pending }
| { status: Status.Success }
| { status: Status.Failed; error: string };
export async function isReverseRecordSet(
address: string,
domain: string,
wallet: Wallet,
): Promise<boolean> {
const name = await lookupAddress(address, wallet);
return name === domain;
}
export async function toClipboard(text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
export function setOpenGraphMetaTag(
data: { prop: string; content: string; attr?: string }[],
): void {
const elements = Array.from<HTMLElement>(document.querySelectorAll(`meta`));
elements.forEach((element: any) => {
const foundElement = data.find(data => {
return data.prop === element.getAttribute(data.attr || "property");
});
if (foundElement) element.content = foundElement.content;
});
}
export function toWei(amount: string): BigNumber {
return parseUnits(amount);
}
export function isAddressEqual(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
export function formatSeedAddress(
id: string,
host: string,
port: number,
): string {
return `${id}@${host}:${port}`;
}
export function formatSeedHost(host: string): string {
if (isLocal(host)) {
return "radicle.local";
} else {
return host;
}
}
export function formatLocationHash(hash: string | null): number | null {
if (hash && hash.match(/^#L[0-9]+$/)) return parseInt(hash.slice(2));
return null;
}
export function formatSeedId(id: string): string {
return id.substring(0, 6) + "…" + id.substring(id.length - 6, id.length);
}
export function formatRadicleUrn(id: string): string {
assert(isRadicleId(id));
return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
}
export function formatBalance(n: BigNumber, decimals?: number): string {
return ethers.utils.commify(
parseFloat(ethers.utils.formatUnits(n, decimals)).toFixed(2),
);
}
// Returns a checksummed, shortened, without 0x prefix Ethereum address
export function formatAddress(input: string): string {
const addr = ethers.utils.getAddress(input).replace(/^0x/, "");
return (
addr.substring(0, 4) + " – " + addr.substring(addr.length - 4, addr.length)
);
}
export function formatCommit(oid: string): string {
return oid.substring(0, 7);
}
export function formatProfile(input: string, wallet: Wallet): string {
if (isAddress(input)) {
return ethers.utils.getAddress(input);
} else {
return parseEnsLabel(input, wallet);
}
}
export function capitalize(s: string): string {
if (s === "") return s;
return s[0].toUpperCase() + s.substring(1);
}
// Takes a domain name, eg. 'cloudhead.radicle.eth' and returns the label, eg. 'cloudhead'.
export function parseEnsLabel(name: string, wallet: Wallet): string {
const domain = wallet.registrar.domain.replace(".", "\\.");
const label = name.replace(new RegExp(`\\.${domain}$`), "");
return label;
}
// Get the mime type of an image, given a file path.
// Returns `null` if unknown.
export function getImageMime(path: string): string | null {
const mimes: Record<string, string> = {
apng: "image/apng",
png: "image/png",
svg: "image/svg+xml",
gif: "image/gif",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
};
const ext = path.split(".").pop();
if (ext) {
if (mimes[ext]) {
return mimes[ext];
}
}
return null;
}
// Takes a path, eg. "../images/image.png", and a base from where to start resolving, e.g. "static/images/index.html".
// Returns the resolved path.
export function canonicalize(
path: string,
base: string,
origin = document.location.origin,
): string {
path = path.replace(/^\//, ""); // Remove leading slash
const finalPath = base
.split("/")
.slice(0, -1) // Remove file name.
.concat([path]) // Add image file path.
.join("/");
// URL is used to resolve relative paths, eg. `../../assets/image.png`.
const url = new URL(finalPath, origin);
const pathname = url.pathname.replace(/^\//, "");
return pathname;
}
// Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead".
// Returns the original string if it was unable to extract the username.
export function parseUsername(input: string): string {
const parts = input.split("/");
return parts[parts.length - 1];
}
// Return the current unix time.
export function unixTime(): number {
return Math.floor(Date.now() / 1000);
}
export const formatTimestamp = (
timestamp: number,
current = new Date().getTime(),
): string => {
const units: Record<string, number> = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
// Multiplying timestamp with 1000 to convert from seconds to milliseconds
timestamp = timestamp * 1000;
const rtf = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
style: "long",
});
const elapsed = current - timestamp;
if (elapsed > units["year"]) {
return new Date(timestamp).toUTCString(); // If it's more than a year we return early showing a Datetime string
} else if (elapsed < 0) {
return "now"; // If elapsed is a negative number we are dealing with an item from the future, and we return "now"
}
for (const u in units) {
if (elapsed > units[u] || u === "second") {
// We convert the division result to a negative number to get "XX [unit] ago"
return rtf.format(
Math.round(elapsed / units[u]) * -1,
u as Intl.RelativeTimeFormatUnit,
);
}
}
return new Date(timestamp).toUTCString();
};
// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
}
// Check whether the input is a Radicle Peer ID.
export function isPeerId(input: string): boolean {
return /^h[a-zA-Z0-9]+$/.test(input);
}
// Check whether the input is a SHA1 commit.
export function isOid(input: string): boolean {
return /^[a-fA-F0-9]{40}$/.test(input);
}
// Check whether the input is a URL.
export function isUrl(input: string): boolean {
return /^https?:\/\//.test(input);
}
export function isENSName(input: string, wallet: Wallet): boolean {
const domain = wallet.registrar.domain.replace(".", "\\.");
const regEx = new RegExp(`^[a-zA-Z0-9]+.(${domain}|eth)$`);
return regEx.test(input);
}
// Check whether the input is an checksummed or all lowercase Ethereum address.
export function isAddress(input: string): boolean {
return ethers.utils.isAddress(input);
}
export function isFulfilled<T>(
input: PromiseSettledResult<T>,
): input is PromiseFulfilledResult<T> {
return input.status === "fulfilled";
}
// Get the explorer link of an address, eg. Etherscan.
export function explorerLink(addr: string, wallet: Wallet): string {
if (wallet.network.name === "goerli") {
return `https://goerli.etherscan.io/address/${addr}`;
}
return `https://etherscan.io/address/${addr}`;
}
// Format a name.
export function formatName(input: string, wallet: Wallet): string {
return parseEnsLabel(input, wallet);
}
// Parse a Radicle Id (URN).
export function parseRadicleId(urn: string): string {
return urn.replace(/^rad:[a-z]+:/, "");
}
// Get amount of days passed between two dates without including the end date
export function getDaysPassed(from: Date, to: Date): number {
return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
}
export function parseEmoji(input: string): string {
if (input in emojis) {
return emojis[input];
}
return input;
}
export function scrollIntoView(id: string) {
const lineElement = document.getElementById(id);
if (lineElement) lineElement.scrollIntoView();
}
export function getSeedEmoji(seedHost: string): string {
const seed = config.seeds.pinned.find(s => s.host === seedHost);
if (seed) {
return seed.emoji;
} else if (isLocal(seedHost)) {
return "🏠";
} else {
return "🌱";
}
}
// Identify an address by checking whether it's a contract or an externally-owned address.
export async function identifyAddress(
address: string,
wallet: Wallet,
): Promise<AddressType> {
const code = await getCode(address, wallet);
const bytes = ethers.utils.arrayify(code);
if (bytes.length > 0) {
return AddressType.Contract;
}
return AddressType.EOA;
}
// Resolves an ENS profile or return null
export async function resolveEnsProfile(
addressOrName: string,
profileType: ProfileType,
wallet: Wallet,
): Promise<EnsProfile | null> {
const name = ethers.utils.isAddress(addressOrName)
? await lookupAddress(addressOrName, wallet)
: addressOrName;
if (name) {
const resolver = await getResolver(name, wallet);
if (!resolver) {
return null;
}
if (profileType === ProfileType.Full) {
const registration = await getRegistration(name, wallet, resolver);
if (registration) {
return registration.profile;
}
} else {
const promises: [Promise<any>] = [getAvatar(name, wallet, resolver)];
if (addressOrName === name) {
promises.push(getAddress(resolver));
} else {
promises.push(Promise.resolve(addressOrName));
}
if (profileType === ProfileType.Project) {
promises.push(getSeed(name, wallet, resolver));
} else if (profileType === ProfileType.Minimal) {
promises.push(Promise.resolve(null));
}
const project = await Promise.allSettled(promises);
const [avatar, address, seed] =
// Just checking for r.value equal null and casting to undefined,
// since resolver functions return null.
project.filter(isFulfilled).map(r => (r.value ? r.value : null));
return {
name,
avatar,
address,
seed,
};
}
}
return null;
}
// Get token balances for an address.
export async function getTokens(
address: string,
wallet: Wallet,
): Promise<Array<Token>> {
const userBalances = await getRpcMethod(
"alchemy_getTokenBalances",
[address, "DEFAULT_TOKENS"],
wallet,
);
const balances = userBalances.tokenBalances
.filter((token: any) => {
// alchemy_getTokenBalances sometimes returns 0x and this does not work well with ethers.BigNumber
if (token.tokenBalance !== "0x") {
if (!BigNumber.from(token.tokenBalance).isZero()) {
return token;
}
}
})
.map(async (token: any) => {
const tokenMetaData = await getRpcMethod(
"alchemy_getTokenMetadata",
[token.contractAddress],
wallet,
);
return { ...tokenMetaData, balance: BigNumber.from(token.tokenBalance) };
});
return Promise.all(balances);
}
export const getRpcMethod = cache.cached(
async (method: string, props: string[], wallet: Wallet) => {
return await wallet.provider.send(method, props);
},
(method, props) => JSON.stringify([method, props]),
{ ttl: 2 * 60 * 1000, max: 1000 },
);
// Check whether the given path has a markdown file extension.
export function isMarkdownPath(path: string): boolean {
return /\.(md|mkd|markdown)$/i.test(path);
}
// Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz.
// Also accepts in dev env 0.0.0.0 as domain
export function isDomain(input: string): boolean {
return (
(/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
(!import.meta.env.PROD && /^0.0.0.0$/.test(input))
);
}
// Check whether the given address is a local host address.
export function isLocal(addr: string): boolean {
return addr === "127.0.0.1" || addr === "0.0.0.0";
}
// 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 const getCode = cache.cached(
async (address: string, wallet: Wallet) => {
return await wallet.provider.getCode(address);
},
address => address,
{ max: 1000 },
);
export const lookupAddress = cache.cached(
async (address: string, wallet: Wallet) => {
return await wallet.provider.lookupAddress(address);
},
address => address,
{ max: 1000 },
);
export const unreachable = (value: never): never => {
throw new Error(`Unreachable code: ${value}`);
};
const emojisMarkedExtension = {
name: "emoji",
level: "inline",
start: (src: string) => src.indexOf(":"),
tokenizer(src: string) {
const match = src.match(/^:([\w+-]+):/);
if (match) {
return {
type: "emoji",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer: (token: marked.Tokens.Generic) =>
`<span>${parseEmoji(token.text)}</span>`,
};
const katexMarkedExtension = {
name: "katex",
level: "inline",
start: (src: string) => src.indexOf("$"),
tokenizer(src: string) {
const match = src.match(/^\$+([^$\n]+?)\$+/);
if (match) {
return {
type: "katex",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer: (token: marked.Tokens.Generic) =>
katex.renderToString(token.text, {
throwOnError: false,
}),
};
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
// e.g. <a name="test"/> -> <a name="test"></a>
const anchorMarkedExtension = {
name: "sanitizedAnchor",
level: "block",
start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
tokenizer(src: string) {
const match = src.match(/^<a name="([\w]+)"\/>/);
if (match) {
return {
type: "sanitizedAnchor",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer: (token: marked.Tokens.Generic) => {
return `<a name="${token.text}"></a>`;
},
};
// Overwrites the rendering of heading tokens.
// Since there are possible non ASCII characters in headings,
// we escape them by replacing them with dashes and,
// trim eventual dashes on each side of the string.
export const renderer = {
heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6) {
const escapedText = text
.toLowerCase()
.replace(/[^\w]+/g, "-")
.replace(/^-|-$/g, "");
return `<h${level} id="${escapedText}">${text}</h${level}>`;
},
};
export function twemoji(node: HTMLElement) {
twemojiModule.parse(node, {
base: process.env.hashRouting ? "./" : "/",
folder: "twemoji",
ext: ".svg",
className: `txt-emoji`,
});
}
export const markdownExtensions = [
emojisMarkedExtension,
katexMarkedExtension,
anchorMarkedExtension,
];