feat: add telegram-style resource stream
This commit is contained in:
9
src/components/messageStream/DaySeparator.tsx
Normal file
9
src/components/messageStream/DaySeparator.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function DaySeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="sticky top-[58px] z-[5] flex justify-center py-2">
|
||||
<span className="rounded-full bg-ark-panel/80 px-3 py-1 text-xs text-neutral-300 backdrop-blur">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/messageStream/FilterChips.tsx
Normal file
80
src/components/messageStream/FilterChips.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useI18n } from "../../i18n";
|
||||
import { typeFilterLabel } from "../../resourceTypeLabels";
|
||||
|
||||
const TYPE_FILTERS = [
|
||||
"all",
|
||||
"image",
|
||||
"video",
|
||||
"ppt",
|
||||
"pdf",
|
||||
"text",
|
||||
"link",
|
||||
"archive",
|
||||
] as const;
|
||||
|
||||
const LANG_FILTERS = ["", "zh-TW", "zh-CN", "en"] as const;
|
||||
|
||||
function langLabel(t: (k: string) => string, code: string) {
|
||||
if (!code) return t("filterLanguageAll");
|
||||
if (code === "zh-TW") return t("lang_zh_TW");
|
||||
if (code === "zh-CN") return t("lang_zh_CN");
|
||||
return t("lang_en");
|
||||
}
|
||||
|
||||
export type FilterChipsProps = {
|
||||
type: string;
|
||||
language: string;
|
||||
onTypeChange: (next: string) => void;
|
||||
onLanguageChange: (next: string) => void;
|
||||
};
|
||||
|
||||
export function FilterChips({
|
||||
type,
|
||||
language,
|
||||
onTypeChange,
|
||||
onLanguageChange,
|
||||
}: FilterChipsProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="sticky top-0 z-10 -mx-3 border-b border-ark-line bg-ark-bg/90 px-3 py-2 backdrop-blur md:-mx-0 md:rounded-t-xl">
|
||||
<div className="flex gap-1.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{TYPE_FILTERS.map((tp) => {
|
||||
const active = type === tp;
|
||||
return (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => onTypeChange(tp)}
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs transition ${
|
||||
active
|
||||
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
|
||||
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
|
||||
}`}
|
||||
>
|
||||
{typeFilterLabel(t, tp)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{LANG_FILTERS.map((code) => {
|
||||
const active = language === code;
|
||||
return (
|
||||
<button
|
||||
key={code || "all"}
|
||||
type="button"
|
||||
onClick={() => onLanguageChange(code)}
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs transition ${
|
||||
active
|
||||
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
|
||||
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
|
||||
}`}
|
||||
>
|
||||
{langLabel(t, code)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/messageStream/MessageBubble.tsx
Normal file
53
src/components/messageStream/MessageBubble.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { Post } from "../../types/post";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { TextBubble } from "./bubbles/TextBubble";
|
||||
import { FileDocBubble } from "./bubbles/FileDocBubble";
|
||||
import { ImageBubble } from "./bubbles/ImageBubble";
|
||||
import { ImageWithTextBubble } from "./bubbles/ImageWithTextBubble";
|
||||
import { AlbumBubble } from "./bubbles/AlbumBubble";
|
||||
import { VideoBubble } from "./bubbles/VideoBubble";
|
||||
import { formatDateTime } from "./utils/formatTime";
|
||||
|
||||
type BubbleComponent = ComponentType<{ post: Post }>;
|
||||
|
||||
export function pickBubble(post: Post): BubbleComponent {
|
||||
const a = post.attachments;
|
||||
if (a.length === 0) return TextBubble;
|
||||
if (a.length >= 2 && a.every((x) => x.kind === "image")) return AlbumBubble;
|
||||
const only = a[0];
|
||||
if (only.kind === "video") return VideoBubble;
|
||||
if (only.kind === "image") {
|
||||
return post.text ? ImageWithTextBubble : ImageBubble;
|
||||
}
|
||||
return FileDocBubble;
|
||||
}
|
||||
|
||||
export function MessageBubble({ post }: { post: Post }) {
|
||||
const { lang } = useI18n();
|
||||
const Bubble = pickBubble(post);
|
||||
const isTextOnly = post.attachments.length === 0;
|
||||
const isVisual = post.attachments.some(
|
||||
(a) => a.kind === "image" || a.kind === "video",
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${post.id}`}
|
||||
className={`relative self-start rounded-2xl bg-ark-panel text-left shadow-sm ${
|
||||
isVisual
|
||||
? "w-[82vw] max-w-[320px] md:w-[52vw] md:max-w-[420px] lg:w-[46vw] lg:max-w-[520px]"
|
||||
: "inline-block max-w-[92%] md:max-w-[680px]"
|
||||
} ${isTextOnly ? "px-3 py-2" : "p-2"}`}
|
||||
>
|
||||
<Bubble post={post} />
|
||||
<time
|
||||
dateTime={post.publishedAt}
|
||||
className="ml-2 mt-1 inline-block float-right text-[10.5px] leading-none text-neutral-500"
|
||||
>
|
||||
{formatDateTime(post.publishedAt, lang)}
|
||||
</time>
|
||||
<span className="block clear-both" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
115
src/components/messageStream/MessageStream.tsx
Normal file
115
src/components/messageStream/MessageStream.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useI18n } from "../../i18n";
|
||||
import type { PostScope } from "../../types/post";
|
||||
import { FilterChips } from "./FilterChips";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||
import { usePostStream } from "./hooks/usePostStream";
|
||||
|
||||
export type MessageStreamProps = {
|
||||
scope: PostScope;
|
||||
};
|
||||
|
||||
export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
|
||||
const type = sp.get("type") || "all";
|
||||
const language = sp.get("language") || "";
|
||||
|
||||
const params = useMemo(
|
||||
() => ({ scope, type, language, lang }),
|
||||
[scope, type, language, lang],
|
||||
);
|
||||
|
||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||
usePostStream(params);
|
||||
const groups = useGroupedByDay(items, lang);
|
||||
const retryLabel =
|
||||
lang === "zh-TW" ? "重試" : lang === "zh-CN" ? "重试" : "Retry";
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const hasMoreRef = useRef(hasMore);
|
||||
const isLoadingRef = useRef(isLoading);
|
||||
useEffect(() => {
|
||||
hasMoreRef.current = hasMore;
|
||||
}, [hasMore]);
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isIntersecting &&
|
||||
hasMoreRef.current &&
|
||||
!isLoadingRef.current
|
||||
) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const updateParam = (key: string, value: string) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
if (!value || value === "all") n.delete(key);
|
||||
else n.set(key, value);
|
||||
setSp(n, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||
<FilterChips
|
||||
type={type}
|
||||
language={language}
|
||||
onTypeChange={(v) => updateParam("type", v)}
|
||||
onLanguageChange={(v) => updateParam("language", v)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pb-10 pt-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.dayKey} className="flex flex-col gap-2">
|
||||
{group.items.map((post) => (
|
||||
<MessageBubble key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && !error && items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-400">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
<span className="break-all">{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
||||
) : null}
|
||||
|
||||
<div ref={sentinelRef} aria-hidden className="h-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/messageStream/bubbles/AlbumBubble.tsx
Normal file
85
src/components/messageStream/bubbles/AlbumBubble.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Attachment, Post } from "../../../types/post";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { autolink } from "../utils/autolink";
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
function imageRatio(att: Attachment) {
|
||||
return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
||||
}
|
||||
|
||||
export function AlbumBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const images = post.attachments;
|
||||
const shouldMerge = images.length > MAX_VISIBLE;
|
||||
|
||||
if (!shouldMerge) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{images.map((att, i) => (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => openLightbox(images, i)}
|
||||
className="relative block max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
<img
|
||||
src={att.url}
|
||||
alt={att.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
style={{ aspectRatio: imageRatio(att) }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{post.text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = images.slice(0, MAX_VISIBLE);
|
||||
const extra = images.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
|
||||
{visible.map((att, i) => {
|
||||
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => openLightbox(images, i)}
|
||||
className="relative block h-full w-full overflow-hidden"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
<img
|
||||
src={att.thumbnailUrl ?? att.url}
|
||||
alt={att.filename}
|
||||
loading="lazy"
|
||||
className={`h-full w-full object-cover ${
|
||||
isLastSlot ? "blur-sm scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
{isLastSlot ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-3xl font-semibold text-white">
|
||||
+{extra}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{post.text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/messageStream/bubbles/FileDocBubble.tsx
Normal file
64
src/components/messageStream/bubbles/FileDocBubble.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Download } from "lucide-react";
|
||||
import type { Attachment, Post } from "../../../types/post";
|
||||
import { fileIcon } from "../utils/fileIcon";
|
||||
import { formatBytes } from "../utils/formatBytes";
|
||||
|
||||
function AttachmentRow({ att }: { att: Attachment }) {
|
||||
const isImageAsDoc = att.mime.startsWith("image/");
|
||||
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||
|
||||
return (
|
||||
<a
|
||||
href={att.url}
|
||||
download={att.filename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
|
||||
>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
|
||||
{isImageAsDoc && att.thumbnailUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={att.thumbnailUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
|
||||
<Download className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-white" strokeWidth={2.2} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold">
|
||||
{att.filename}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400">
|
||||
{formatBytes(att.sizeBytes)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDocBubble({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{post.attachments.map((att) => (
|
||||
<AttachmentRow key={att.id} att={att} />
|
||||
))}
|
||||
{post.text ? (
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{post.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/messageStream/bubbles/ImageBubble.tsx
Normal file
27
src/components/messageStream/bubbles/ImageBubble.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
|
||||
export function ImageBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const att = post.attachments[0];
|
||||
if (!att) return null;
|
||||
const ratio =
|
||||
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox([att], 0)}
|
||||
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
<img
|
||||
src={att.url}
|
||||
alt={att.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
style={{ aspectRatio: ratio }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
35
src/components/messageStream/bubbles/ImageWithTextBubble.tsx
Normal file
35
src/components/messageStream/bubbles/ImageWithTextBubble.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { autolink } from "../utils/autolink";
|
||||
|
||||
export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const att = post.attachments[0];
|
||||
if (!att) return null;
|
||||
const ratio =
|
||||
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox([att], 0)}
|
||||
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
||||
aria-label={att.filename}
|
||||
>
|
||||
<img
|
||||
src={att.url}
|
||||
alt={att.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
style={{ aspectRatio: ratio }}
|
||||
/>
|
||||
</button>
|
||||
{post.text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/components/messageStream/bubbles/TextBubble.tsx
Normal file
10
src/components/messageStream/bubbles/TextBubble.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Post } from "../../../types/post";
|
||||
import { autolink } from "../utils/autolink";
|
||||
|
||||
export function TextBubble({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text ?? "")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/messageStream/bubbles/VideoBubble.tsx
Normal file
81
src/components/messageStream/bubbles/VideoBubble.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Play } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { formatBytes } from "../utils/formatBytes";
|
||||
|
||||
function formatDuration(sec: number | undefined): string {
|
||||
if (!sec || sec <= 0) return "";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function VideoBubble({ post }: { post: Post }) {
|
||||
const { openVideo } = useVideoPlayer();
|
||||
const att = post.attachments[0];
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
if (!att) return null;
|
||||
const ratio =
|
||||
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className="relative max-h-[220px] w-full overflow-hidden rounded-xl bg-black min-[440px]:max-h-[250px] md:max-h-[300px] lg:max-h-[340px]"
|
||||
style={{ aspectRatio: ratio }}
|
||||
onClick={() => {
|
||||
if (playing) {
|
||||
const v = videoRef.current;
|
||||
openVideo(att, v?.currentTime ?? 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{playing ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={att.url}
|
||||
poster={att.posterUrl}
|
||||
controls
|
||||
playsInline
|
||||
autoPlay
|
||||
className="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPlaying(true);
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
aria-label="Play video"
|
||||
>
|
||||
{att.posterUrl ? (
|
||||
<img
|
||||
src={att.posterUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-xs text-white">
|
||||
<span>{formatDuration(att.durationSec)}</span>
|
||||
<span className="opacity-70">·</span>
|
||||
<span>{formatBytes(att.sizeBytes)}</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur md:h-14 md:w-14">
|
||||
<Play className="h-5 w-5 translate-x-0.5 fill-white md:h-6 md:w-6" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{post.text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
||||
{autolink(post.text)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/messageStream/hooks/useGroupedByDay.test.ts
Normal file
53
src/components/messageStream/hooks/useGroupedByDay.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useGroupedByDay } from "./useGroupedByDay";
|
||||
import type { Post } from "../../../types/post";
|
||||
|
||||
function makePost(id: string, isoDate: string): Post {
|
||||
return {
|
||||
id,
|
||||
categoryId: 1,
|
||||
categorySlug: "x",
|
||||
language: "zh-CN",
|
||||
attachments: [],
|
||||
isRecommended: false,
|
||||
publishedAt: isoDate,
|
||||
updatedAt: isoDate,
|
||||
text: id,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useGroupedByDay", () => {
|
||||
it("groups posts by local date", () => {
|
||||
const posts: Post[] = [
|
||||
makePost("a", "2026-02-27T10:00:00.000Z"),
|
||||
makePost("b", "2026-02-27T23:00:00.000Z"),
|
||||
makePost("c", "2026-02-28T01:00:00.000Z"),
|
||||
makePost("d", "2026-05-16T12:00:00.000Z"),
|
||||
];
|
||||
const { result } = renderHook(() => useGroupedByDay(posts, "zh-CN"));
|
||||
expect(result.current.length).toBeGreaterThanOrEqual(2);
|
||||
const allIds = result.current.flatMap((g) => g.items.map((p) => p.id));
|
||||
expect(allIds).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("preserves input order within groups", () => {
|
||||
const posts: Post[] = [
|
||||
makePost("first", "2026-03-01T10:00:00.000Z"),
|
||||
makePost("second", "2026-03-01T11:00:00.000Z"),
|
||||
makePost("third", "2026-03-01T12:00:00.000Z"),
|
||||
];
|
||||
const { result } = renderHook(() => useGroupedByDay(posts, "en"));
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].items.map((p) => p.id)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
const { result } = renderHook(() => useGroupedByDay([], "zh-CN"));
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
62
src/components/messageStream/hooks/useGroupedByDay.ts
Normal file
62
src/components/messageStream/hooks/useGroupedByDay.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Post } from "../../../types/post";
|
||||
|
||||
export type DayGroup = {
|
||||
dayKey: string;
|
||||
dayLabel: string;
|
||||
items: Post[];
|
||||
};
|
||||
|
||||
function localeFor(lang: string): string {
|
||||
if (lang === "zh-TW") return "zh-TW";
|
||||
if (lang === "zh-CN") return "zh-CN";
|
||||
return "en-US";
|
||||
}
|
||||
|
||||
function dayKey(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
}
|
||||
|
||||
function dayLabel(iso: string, lang: string): string {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const isSameDay = (a: Date, b: Date) =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
if (isSameDay(d, today)) {
|
||||
if (lang === "en") return "Today";
|
||||
return "今天";
|
||||
}
|
||||
if (isSameDay(d, yesterday)) {
|
||||
if (lang === "en") return "Yesterday";
|
||||
return "昨天";
|
||||
}
|
||||
return new Intl.DateTimeFormat(localeFor(lang), {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function useGroupedByDay(posts: Post[], lang: string): DayGroup[] {
|
||||
return useMemo(() => {
|
||||
const groups: DayGroup[] = [];
|
||||
const seen = new Map<string, DayGroup>();
|
||||
for (const p of posts) {
|
||||
const k = dayKey(p.publishedAt);
|
||||
let g = seen.get(k);
|
||||
if (!g) {
|
||||
g = { dayKey: k, dayLabel: dayLabel(p.publishedAt, lang), items: [] };
|
||||
seen.set(k, g);
|
||||
groups.push(g);
|
||||
}
|
||||
g.items.push(p);
|
||||
}
|
||||
return groups;
|
||||
}, [posts, lang]);
|
||||
}
|
||||
|
||||
export { dayKey as _dayKey, dayLabel as _dayLabel };
|
||||
160
src/components/messageStream/hooks/usePostStream.ts
Normal file
160
src/components/messageStream/hooks/usePostStream.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getJSON } from "../../../api";
|
||||
import { MOCK_POSTS } from "../../../mocks/mockPosts";
|
||||
import type { Post, PostListResponse, PostScope } from "../../../types/post";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const MOCK_DELAY_MS = 200;
|
||||
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
|
||||
|
||||
export type PostStreamParams = {
|
||||
scope: PostScope;
|
||||
type?: string;
|
||||
language?: string;
|
||||
lang: string;
|
||||
};
|
||||
|
||||
export type PostStreamResult = {
|
||||
items: Post[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
function postMatchesType(post: Post, type: string): boolean {
|
||||
if (!type || type === "all") return true;
|
||||
if (type === "text" || type === "link") {
|
||||
return !!post.text && post.text.length > 0;
|
||||
}
|
||||
return post.attachments.some((a) => {
|
||||
const ext = a.filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (type === "image")
|
||||
return a.kind === "image" || a.mime.startsWith("image/");
|
||||
if (type === "video")
|
||||
return a.kind === "video" || a.mime.startsWith("video/");
|
||||
if (type === "pdf") return ext === "pdf" || a.mime === "application/pdf";
|
||||
if (type === "ppt")
|
||||
return (
|
||||
["ppt", "pptx", "key"].includes(ext) || a.mime.includes("presentation")
|
||||
);
|
||||
if (type === "archive")
|
||||
return ["zip", "rar", "7z", "tar", "gz"].includes(ext);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function filterMock(params: PostStreamParams): Post[] {
|
||||
return MOCK_POSTS.filter((p) => {
|
||||
if (
|
||||
params.scope.kind === "category" &&
|
||||
p.categorySlug !== params.scope.slug
|
||||
)
|
||||
return false;
|
||||
if (params.language && p.language !== params.language) return false;
|
||||
if (!postMatchesType(p, params.type ?? "all")) return false;
|
||||
return true;
|
||||
}).sort(
|
||||
(a, b) =>
|
||||
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
function buildRealUrl(params: PostStreamParams, cursor?: string): string {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("lang", params.lang);
|
||||
sp.set("limit", String(PAGE_SIZE));
|
||||
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
|
||||
if (params.type && params.type !== "all") sp.set("type", params.type);
|
||||
if (params.language) sp.set("language", params.language);
|
||||
if (cursor) sp.set("cursor", cursor);
|
||||
return `/api/posts?${sp.toString()}`;
|
||||
}
|
||||
|
||||
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
||||
const [items, setItems] = useState<Post[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reqIdRef = useRef(0);
|
||||
const cursorRef = useRef<string | undefined>(undefined);
|
||||
const hasMoreRef = useRef(true);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (resetting: boolean) => {
|
||||
if (loadingRef.current) return;
|
||||
if (!resetting && !hasMoreRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const myReq = ++reqIdRef.current;
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS));
|
||||
const all = filterMock(params);
|
||||
const offset = resetting ? 0 : Number(cursorRef.current ?? "0");
|
||||
const slice = all.slice(offset, offset + PAGE_SIZE);
|
||||
const nextOffset = offset + slice.length;
|
||||
const more = nextOffset < all.length;
|
||||
if (myReq !== reqIdRef.current) return;
|
||||
setItems((prev) => (resetting ? slice : [...prev, ...slice]));
|
||||
const nextCursor = more ? String(nextOffset) : undefined;
|
||||
cursorRef.current = nextCursor;
|
||||
setHasMore(more);
|
||||
hasMoreRef.current = more;
|
||||
} else {
|
||||
const url = buildRealUrl(
|
||||
params,
|
||||
resetting ? undefined : cursorRef.current,
|
||||
);
|
||||
const res = await getJSON<PostListResponse>(url);
|
||||
if (myReq !== reqIdRef.current) return;
|
||||
setItems((prev) => (resetting ? res.items : [...prev, ...res.items]));
|
||||
cursorRef.current = res.nextCursor;
|
||||
const more = !!res.nextCursor;
|
||||
setHasMore(more);
|
||||
hasMoreRef.current = more;
|
||||
}
|
||||
} catch (e) {
|
||||
if (myReq !== reqIdRef.current) return;
|
||||
setError(String(e));
|
||||
} finally {
|
||||
if (myReq === reqIdRef.current) setIsLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[params],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
cursorRef.current = undefined;
|
||||
setHasMore(true);
|
||||
hasMoreRef.current = true;
|
||||
fetchPage(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
params.scope.kind,
|
||||
params.scope.kind === "category" ? params.scope.slug : "",
|
||||
params.type,
|
||||
params.language,
|
||||
params.lang,
|
||||
]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
fetchPage(false);
|
||||
}, [fetchPage]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
fetchPage(true);
|
||||
}, [fetchPage]);
|
||||
|
||||
return { items, isLoading, error, hasMore, loadMore, reset };
|
||||
}
|
||||
|
||||
export { USE_MOCK as POST_STREAM_USES_MOCK };
|
||||
180
src/components/messageStream/overlays/ImageLightbox.tsx
Normal file
180
src/components/messageStream/overlays/ImageLightbox.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
|
||||
type LightboxState = {
|
||||
images: Attachment[];
|
||||
index: number;
|
||||
} | null;
|
||||
|
||||
type Ctx = {
|
||||
openLightbox: (images: Attachment[], startIndex?: number) => void;
|
||||
closeLightbox: () => void;
|
||||
};
|
||||
|
||||
const LightboxContext = createContext<Ctx | null>(null);
|
||||
|
||||
export function useLightbox(): Ctx {
|
||||
const ctx = useContext(LightboxContext);
|
||||
if (!ctx)
|
||||
throw new Error("useLightbox must be used inside ImageLightboxProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<LightboxState>(null);
|
||||
|
||||
const openLightbox = useCallback((images: Attachment[], startIndex = 0) => {
|
||||
if (!images.length) return;
|
||||
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
||||
setState({ images, index: i });
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => setState(null), []);
|
||||
|
||||
return (
|
||||
<LightboxContext.Provider value={{ openLightbox, closeLightbox }}>
|
||||
{children}
|
||||
{state ? (
|
||||
<LightboxView
|
||||
images={state.images}
|
||||
startIndex={state.index}
|
||||
onClose={closeLightbox}
|
||||
/>
|
||||
) : null}
|
||||
</LightboxContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxView({
|
||||
images,
|
||||
startIndex,
|
||||
onClose,
|
||||
}: {
|
||||
images: Attachment[];
|
||||
startIndex: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
|
||||
const goPrev = useCallback(
|
||||
() => setIndex((i) => (i - 1 + images.length) % images.length),
|
||||
[images.length],
|
||||
);
|
||||
const goNext = useCallback(
|
||||
() => setIndex((i) => (i + 1) % images.length),
|
||||
[images.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft") goPrev();
|
||||
if (e.key === "ArrowRight") goNext();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [goPrev, goNext, onClose]);
|
||||
|
||||
const current = images[index];
|
||||
if (!current) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={current.url}
|
||||
download={current.filename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
aria-label="Download"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
}}
|
||||
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:left-6"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:right-6"
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||
{index + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.filename}
|
||||
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
if (touchStartX.current == null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
if (Math.abs(dx) > 40) {
|
||||
if (dx > 0) goPrev();
|
||||
else goNext();
|
||||
}
|
||||
touchStartX.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
118
src/components/messageStream/overlays/VideoPlayer.tsx
Normal file
118
src/components/messageStream/overlays/VideoPlayer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
|
||||
type PlayerState = {
|
||||
attachment: Attachment;
|
||||
currentTime: number;
|
||||
} | null;
|
||||
|
||||
type Ctx = {
|
||||
openVideo: (attachment: Attachment, currentTime?: number) => void;
|
||||
closeVideo: () => void;
|
||||
};
|
||||
|
||||
const VideoPlayerContext = createContext<Ctx | null>(null);
|
||||
|
||||
export function useVideoPlayer(): Ctx {
|
||||
const ctx = useContext(VideoPlayerContext);
|
||||
if (!ctx)
|
||||
throw new Error("useVideoPlayer must be used inside VideoPlayerProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<PlayerState>(null);
|
||||
|
||||
const openVideo = useCallback(
|
||||
(attachment: Attachment, currentTime = 0) =>
|
||||
setState({ attachment, currentTime }),
|
||||
[],
|
||||
);
|
||||
const closeVideo = useCallback(() => setState(null), []);
|
||||
|
||||
return (
|
||||
<VideoPlayerContext.Provider value={{ openVideo, closeVideo }}>
|
||||
{children}
|
||||
{state ? (
|
||||
<PlayerView
|
||||
attachment={state.attachment}
|
||||
startAt={state.currentTime}
|
||||
onClose={closeVideo}
|
||||
/>
|
||||
) : null}
|
||||
</VideoPlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerView({
|
||||
attachment,
|
||||
startAt,
|
||||
onClose,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
startAt: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (startAt > 0) v.currentTime = startAt;
|
||||
v.play().catch(() => {});
|
||||
}, [startAt]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attachment.url}
|
||||
poster={attachment.posterUrl}
|
||||
controls
|
||||
playsInline
|
||||
className="max-h-[92vh] max-w-[96vw] outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
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