export { cn } from "@agent-native/toolkit/utils";
import {
decodeCommonHtmlEntities,
escapeHtml,
normalizeMarkdownHardBreaks,
renderInlineMarkdown,
} from "@shared/markdown";
import { format, isToday, isYesterday, isThisYear } from "date-fns";
export function formatEmailDate(dateStr: string): string {
const date = new Date(dateStr);
if (isToday(date)) return format(date, "h:mm a");
if (isYesterday(date)) return "Yesterday";
if (isThisYear(date)) return format(date, "MMM d");
return format(date, "MMM d, yyyy");
}
export function formatEmailDateFull(dateStr: string): string {
return format(new Date(dateStr), "EEE, MMM d, yyyy 'at' h:mm a");
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export function getAvatarColor(name: string): string {
const colors = [
"bg-blue-500",
"bg-sky-500",
"bg-green-500",
"bg-orange-500",
"bg-pink-500",
"bg-teal-500",
"bg-indigo-500",
"bg-rose-500",
"bg-amber-500",
"bg-cyan-500",
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
export function markdownToHtml(markdown: string): string {
const normalized = decodeCommonHtmlEntities(
markdown.replace(/\r\n/g, "\n"),
).trim();
if (!normalized) return "
";
const blocks = normalized.split(/\n{2,}/).map((block) => block.trim());
const html = blocks
.map((block) => {
if (block.startsWith("```") && block.endsWith("```")) {
const code = block.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "");
return `${escapeHtml(code)}
`;
}
const heading = block.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
return `${renderInlineMarkdown(heading[2])}`;
}
if (/^(-|\*|\+)\s+/m.test(block)) {
const items = block
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^(-|\*|\+)\s+/, ""))
.map((line) => `${renderInlineMarkdown(line)}`)
.join("");
return ``;
}
if (/^\d+\.\s+/m.test(block)) {
const items = block
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^\d+\.\s+/, ""))
.map((line) => `${renderInlineMarkdown(line)}`)
.join("");
return `${items}
`;
}
const cleanBlock = normalizeMarkdownHardBreaks(block);
return `${renderInlineMarkdown(cleanBlock).replace(/\n/g, "
")}
`;
})
.join("");
return `${html}
`;
}
/**
* Split a compose body at the reply/forward quote separator.
* Returns null for non-reply bodies (no separator found).
* Mirrors `splitReplyQuote` in server/handlers/emails.ts.
*/
function splitReplyQuote(body: string): {
newContent: string;
attribution: string;
quotedBody: string;
} | null {
const replyMatch = body.match(/\n*— On (.+? wrote):\n/);
const fwdMatch = body.match(/\n*(— Forwarded message —)\n/);
const match = replyMatch || fwdMatch;
if (!match || match.index === undefined) return null;
const newContent = body.slice(0, match.index);
const attribution = replyMatch ? `On ${match[1]}:` : "Forwarded message";
const afterSeparator = body.slice(match.index + match[0].length);
return { newContent, attribution, quotedBody: afterSeparator };
}
/**
* Convert quoted content into Gmail-compatible HTML blockquote.
* Strips leading `> ` prefixes from each line before converting to HTML.
* Mirrors `quotedContentToHtml` in server/handlers/emails.ts.
*/
function quotedContentToHtml(attribution: string, quotedBody: string): string {
const stripped = quotedBody
.split("\n")
.map((line) => {
if (line.startsWith("> ")) return line.slice(2);
if (line === ">") return "";
return line;
})
.join("\n");
const innerHtml = markdownToHtml(stripped);
return (
`` +
`
${escapeHtml(attribution)}
` +
`
` +
innerHtml +
`
`
);
}
/**
* Convert a compose body to HTML, properly formatting reply/forward quotes
* with Gmail-compatible blockquote structure. Mirrors `bodyToHtml` in
* server/handlers/emails.ts so the optimistic reply preview renders
* identically to the real sent message.
*/
export function bodyToHtml(body: string): string {
const split = splitReplyQuote(body);
if (split) {
const newHtml = markdownToHtml(split.newContent);
const quoteHtml = quotedContentToHtml(split.attribution, split.quotedBody);
return newHtml + quoteHtml;
}
return markdownToHtml(body);
}
export function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
}
export function isMac(): boolean {
return (
typeof navigator !== "undefined" &&
navigator.platform.toUpperCase().indexOf("MAC") >= 0
);
}
export function formatShortcut(key: string): string {
const isMacPlatform = isMac();
const mod = isMacPlatform ? "⌘" : "Ctrl";
return key
.split("+")
.map((part) => {
const token = part.trim();
const lower = token.toLowerCase();
if (lower === "cmd" || lower === "meta") return mod;
if (lower === "ctrl" || lower === "control") return "Ctrl";
if (lower === "alt" || lower === "option")
return isMacPlatform ? "⌥" : "Alt";
if (lower === "shift") return isMacPlatform ? "⇧" : "Shift";
if (lower === "enter") return "Enter";
if (lower === "space") return "Space";
return token.length === 1 ? token.toUpperCase() : token;
})
.join("+");
}