xtap-pool / explorer /src /lib /format.ts
osolmaz's picture
osolmaz HF Staff
deploy: eaa4b86
879c715 verified
Raw
History Blame Contribute Delete
6.24 kB
import type { PooledTweet } from "@xtap-pool/shared";
export type TextSegment =
{ kind: "text"; text: string } | { kind: "link"; text: string; href: string };
const TOKEN_PATTERN = /(https?:\/\/[^\s]+)|(@[A-Za-z0-9_]{1,15})|(#[\p{L}0-9_]+)/gu;
/** Split tweet text into plain and linkified segments (URLs, @mentions, #hashtags). */
export function tokenizeTweetText(text: string): TextSegment[] {
const segments: TextSegment[] = [];
let last = 0;
for (const match of text.matchAll(TOKEN_PATTERN)) {
const index = match.index;
if (index > last) segments.push({ kind: "text", text: text.slice(last, index) });
const token = match[0];
segments.push({ kind: "link", text: token, href: hrefForToken(token) });
last = index + token.length;
}
if (last < text.length) segments.push({ kind: "text", text: text.slice(last) });
return segments;
}
function hrefForToken(token: string): string {
if (token.startsWith("@")) return `https://x.com/${token.slice(1)}`;
if (token.startsWith("#")) return `https://x.com/hashtag/${token.slice(1)}`;
return token;
}
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
/** Escape text for safe interpolation into HTML markup. */
export function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
}
/**
* Tweet text as escaped HTML with URLs, @mentions and #hashtags linkified.
* Same output as the old React-element rendering, represented as a string
* for safe direct rendering in a tweet card.
*/
export function tweetTextHtml(text: string): string {
return tokenizeTweetText(text)
.map((segment) =>
segment.kind === "link"
? `<a href="${escapeHtml(segment.href)}" target="_blank" rel="noopener noreferrer">` +
`${escapeHtml(segment.text)}</a>`
: escapeHtml(segment.text),
)
.join("");
}
/** X-style compact counts: 999, 1.2K, 3.4M. */
export function formatCount(count: number): string {
if (count < 1000) return String(count);
if (count < 1_000_000) return `${trimZero((count / 1000).toFixed(1))}K`;
return `${trimZero((count / 1_000_000).toFixed(1))}M`;
}
function trimZero(value: string): string {
return value.endsWith(".0") ? value.slice(0, -2) : value;
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const REFRESH_FUZZ_MS = 100;
const MIN_REFRESH_DELAY_MS = 1000;
function relativeLabel(ageMs: number): string | undefined {
if (ageMs < 0) return undefined;
if (ageMs < 60_000) return "now";
if (ageMs < 3_600_000) return `${String(Math.floor(ageMs / 60_000))}m`;
if (ageMs < 86_400_000) return `${String(Math.floor(ageMs / 3_600_000))}h`;
return undefined;
}
/** X-style timestamps: "now", "5m", "3h", then "May 21" / "May 21, 2024". */
export function formatTweetDate(iso: string, now: Date): string {
const date = new Date(iso);
const relative = relativeLabel(now.getTime() - date.getTime());
if (relative !== undefined) return relative;
const month = MONTHS[date.getUTCMonth()] ?? "";
const label = `${month} ${String(date.getUTCDate())}`;
return date.getUTCFullYear() === now.getUTCFullYear()
? label
: `${label}, ${String(date.getUTCFullYear())}`;
}
function refreshDelay(boundaryMs: number): number {
return Math.max(MIN_REFRESH_DELAY_MS, Math.ceil(boundaryMs) + REFRESH_FUZZ_MS);
}
/** Milliseconds until formatTweetDate can change, or undefined for stable absolute dates. */
export function nextTweetDateRefreshDelay(iso: string, now: Date): number | undefined {
const date = new Date(iso);
const ageMs = now.getTime() - date.getTime();
if (!Number.isFinite(ageMs)) return undefined;
if (ageMs < 0) return refreshDelay(-ageMs);
if (ageMs < 60_000) return refreshDelay(60_000 - ageMs);
if (ageMs < 3_600_000) return refreshDelay(60_000 - (ageMs % 60_000));
if (ageMs < 86_400_000) return refreshDelay(3_600_000 - (ageMs % 3_600_000));
return undefined;
}
export type PhotoMedia = { url: string; alt: string };
function toPhoto(entry: unknown): PhotoMedia | undefined {
if (typeof entry !== "object" || entry === null) return undefined;
const item = entry as Record<string, unknown>;
if (item["type"] !== "photo" || typeof item["url"] !== "string") return undefined;
return {
url: item["url"],
alt: typeof item["alt_text"] === "string" ? item["alt_text"] : "Tweet image",
};
}
/** Up to four photo entries from a tweet's media list. */
export function photoMedia(tweet: PooledTweet): PhotoMedia[] {
const media = tweet["media"];
if (!Array.isArray(media)) return [];
return media
.map(toPhoto)
.filter((photo): photo is PhotoMedia => photo !== undefined)
.slice(0, 4);
}
export type TweetMetrics = { replies: number; retweets: number; likes: number; views: number };
/** Numeric engagement metrics with zero defaults. */
export function tweetMetrics(tweet: PooledTweet): TweetMetrics {
const metrics = tweet["metrics"];
const source =
typeof metrics === "object" && metrics !== null ? (metrics as Record<string, unknown>) : {};
const num = (key: string): number => (typeof source[key] === "number" ? source[key] : 0);
return {
replies: num("replies"),
retweets: num("retweets"),
likes: num("likes"),
views: num("views"),
};
}
/** Deterministic avatar background color per username. */
export function avatarColor(username: string): string {
let hash = 0;
for (const char of username) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 360;
return `hsl(${String(hash)} 55% 45%)`;
}
export function displayName(tweet: PooledTweet): string {
const name = tweet.author.display_name;
return name === undefined || name === "" ? tweet.author.username : name;
}
export function isArticleTweet(tweet: PooledTweet): boolean {
return tweet["is_article"] === true;
}
export function isRetweet(tweet: PooledTweet): boolean {
return tweet["is_retweet"] === true;
}
export function quotedTweetUrl(tweet: PooledTweet): string | undefined {
const id = tweet["quoted_tweet_id"];
return typeof id === "string" && id.length > 0 ? `https://x.com/i/status/${id}` : undefined;
}