feat: add telegram-style resource stream

This commit is contained in:
TerryM
2026-05-25 05:25:57 +08:00
parent aaebd7ccd1
commit a784f159fe
45 changed files with 3201 additions and 1160 deletions

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { autolink } from "./autolink";
describe("autolink", () => {
it("returns empty array for empty input", () => {
expect(autolink("")).toEqual([]);
});
it("returns plain text when no urls", () => {
const { container } = render(<>{autolink("普通文本,没有链接")}</>);
expect(container.textContent).toBe("普通文本,没有链接");
expect(container.querySelector("a")).toBeNull();
});
it("wraps a single https url in an anchor with safe attrs", () => {
const { container } = render(<>{autolink("点 https://x.com/path 看")}</>);
const anchor = container.querySelector("a");
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute("href")).toBe("https://x.com/path");
expect(anchor?.getAttribute("target")).toBe("_blank");
expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer");
expect(container.textContent).toBe("点 https://x.com/path 看");
});
it("handles multiple urls in one string", () => {
const { container } = render(
<>{autolink("a https://a.com b https://b.com c")}</>,
);
const anchors = container.querySelectorAll("a");
expect(anchors).toHaveLength(2);
expect(anchors[0].getAttribute("href")).toBe("https://a.com");
expect(anchors[1].getAttribute("href")).toBe("https://b.com");
});
it("trims trailing punctuation outside the url", () => {
const { container } = render(<>{autolink("see https://x.com.")}</>);
const anchor = container.querySelector("a");
expect(anchor?.getAttribute("href")).toBe("https://x.com");
expect(container.textContent).toBe("see https://x.com.");
});
});

View File

@@ -0,0 +1,42 @@
import { Fragment, type ReactNode } from "react";
const URL_REGEX = /(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]}'])/gi;
export function autolink(text: string): ReactNode[] {
if (!text) return [];
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(
<Fragment key={`t-${lastIndex}`}>
{text.slice(lastIndex, match.index)}
</Fragment>,
);
}
const url = match[0];
parts.push(
<a
key={`a-${match.index}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-ark-gold underline underline-offset-2 break-all hover:text-ark-gold2"
>
{url}
</a>,
);
lastIndex = match.index + url.length;
}
if (lastIndex < text.length) {
parts.push(
<Fragment key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Fragment>,
);
}
return parts;
}

View File

@@ -0,0 +1,56 @@
import {
FileText,
FileImage,
FileVideo,
FileArchive,
File as FileIcon,
Presentation,
type LucideIcon,
} from "lucide-react";
export type FileIconInfo = {
Icon: LucideIcon;
color: string;
};
const PDF = { Icon: FileText, color: "#ef4444" };
const AI = { Icon: FileImage, color: "#f97316" };
const PPT = { Icon: Presentation, color: "#dc2626" };
const DOC = { Icon: FileText, color: "#2563eb" };
const VIDEO = { Icon: FileVideo, color: "#8b5cf6" };
const IMAGE = { Icon: FileImage, color: "#10b981" };
const ARCHIVE = { Icon: FileArchive, color: "#a16207" };
const GENERIC = { Icon: FileIcon, color: "#6b7280" };
export function fileIcon(input: {
mime: string;
filename: string;
}): FileIconInfo {
const ext = input.filename.split(".").pop()?.toLowerCase() ?? "";
const mime = (input.mime || "").toLowerCase();
if (mime === "application/pdf" || ext === "pdf") return PDF;
if (ext === "ai" || mime === "application/illustrator") return AI;
if (
mime.includes("presentation") ||
ext === "ppt" ||
ext === "pptx" ||
ext === "key"
)
return PPT;
if (mime.includes("word") || ext === "doc" || ext === "docx") return DOC;
if (mime.startsWith("video/")) return VIDEO;
if (mime.startsWith("image/")) return IMAGE;
if (
mime.includes("zip") ||
mime.includes("rar") ||
mime.includes("tar") ||
ext === "zip" ||
ext === "rar" ||
ext === "7z" ||
ext === "tar" ||
ext === "gz"
)
return ARCHIVE;
return GENERIC;
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "./formatBytes";
describe("formatBytes", () => {
it("returns bytes under 1 KB unchanged", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1023)).toBe("1023 B");
});
it("formats KB with one decimal when small", () => {
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
});
it("formats MB with one decimal", () => {
expect(formatBytes(3_549_239)).toBe("3.4 MB");
expect(formatBytes(4_800_000)).toBe("4.6 MB");
});
it("drops decimals once value >= 100", () => {
expect(formatBytes(150 * 1024 * 1024)).toBe("150 MB");
});
it("handles GB and TB", () => {
expect(formatBytes(2 * 1024 ** 3)).toBe("2 GB");
expect(formatBytes(3 * 1024 ** 4)).toBe("3 TB");
});
it("guards against invalid input", () => {
expect(formatBytes(-1)).toBe("0 B");
expect(formatBytes(Number.NaN)).toBe("0 B");
});
});

View File

@@ -0,0 +1,15 @@
const UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
const rounded =
value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded} ${UNITS[unitIndex]}`;
}

View File

@@ -0,0 +1,27 @@
function localeFor(lang: string): string {
if (lang === "zh-TW") return "zh-TW";
if (lang === "zh-CN") return "zh-CN";
return "en-US";
}
function formatDate(iso: string, lang: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(localeFor(lang), {
year: "numeric",
month: lang === "en" ? "short" : "numeric",
day: "numeric",
}).format(d);
}
export function formatTime(iso: string, lang: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(localeFor(lang), {
hour: "numeric",
minute: "2-digit",
hour12: lang === "en",
}).format(d);
}
export function formatDateTime(iso: string, lang: string): string {
return `${formatDate(iso, lang)} ${formatTime(iso, lang)}`;
}