| 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; |
|
|
| |
| 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> = { |
| "&": "&", |
| "<": "<", |
| ">": ">", |
| '"': """, |
| "'": "'", |
| }; |
|
|
| |
| export function escapeHtml(text: string): string { |
| return text.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char); |
| } |
|
|
| |
| |
| |
| |
| |
| 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(""); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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", |
| }; |
| } |
|
|
| |
| 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 }; |
|
|
| |
| 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"), |
| }; |
| } |
|
|
| |
| 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; |
| } |
|
|