feat: add telegram-style resource stream
This commit is contained in:
42
src/components/messageStream/utils/autolink.test.tsx
Normal file
42
src/components/messageStream/utils/autolink.test.tsx
Normal 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.");
|
||||
});
|
||||
});
|
||||
42
src/components/messageStream/utils/autolink.tsx
Normal file
42
src/components/messageStream/utils/autolink.tsx
Normal 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;
|
||||
}
|
||||
56
src/components/messageStream/utils/fileIcon.ts
Normal file
56
src/components/messageStream/utils/fileIcon.ts
Normal 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;
|
||||
}
|
||||
34
src/components/messageStream/utils/formatBytes.test.ts
Normal file
34
src/components/messageStream/utils/formatBytes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
15
src/components/messageStream/utils/formatBytes.ts
Normal file
15
src/components/messageStream/utils/formatBytes.ts
Normal 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]}`;
|
||||
}
|
||||
27
src/components/messageStream/utils/formatTime.ts
Normal file
27
src/components/messageStream/utils/formatTime.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user