feat: add infinite scroll & fix some ui

This commit is contained in:
2026-09-18 01:10:35 +08:00
parent 65a2e534d7
commit 6e852ab224
9 changed files with 443 additions and 91 deletions
+30 -12
View File
@@ -2,7 +2,12 @@ import { useWAClient } from "@/app/context";
import { storage } from "@/lib/storage";
import type { MessageDTO } from "@shared/dto";
import { cn } from "cn";
import { DownloadIcon, Loader2Icon, PaperclipIcon } from "lucide-react";
import {
DownloadIcon,
ImageIcon,
Loader2Icon,
PaperclipIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
type Props = {
@@ -17,7 +22,9 @@ export default function MessageMedia({ data }: Props) {
const [isLoading, setLoading] = useState(false);
const [isDownloaded, setDownloaded] = useState(false);
const [body, setBody] = useState(
type === "image" ? "data:image/webp;base64," + data.body : data.body,
type === "image" && data.body
? "data:image/webp;base64," + data.body
: data.body,
);
useEffect(() => {
@@ -85,17 +92,28 @@ export default function MessageMedia({ data }: Props) {
return (
<a
href="#"
onClick={() => openMedia()}
className="w-full h-32 aspect-video block relative overflow-hidden"
onClick={(e) => {
e.preventDefault();
openMedia();
}}
className={cn(
"w-full min-h-32 max-h-40 aspect-video block relative overflow-hidden rounded",
)}
>
<img
src={body || ""}
alt=""
className={cn(
"w-full h-full object-cover",
!isDownloaded && "blur-sm",
)}
/>
{body ? (
<img
src={body}
alt=""
className={cn(
"w-full h-full object-cover",
!isDownloaded && "blur-[2px]",
)}
/>
) : (
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<ImageIcon className="size-8 opacity-80" />
</div>
)}
{isLoading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
+132
View File
@@ -0,0 +1,132 @@
import { cn } from "cn";
import React from "react";
type Token =
| { type: "text"; value: string }
| { type: "bold"; value: string }
| { type: "italic"; value: string }
| { type: "strike"; value: string }
| { type: "code"; value: string }
| { type: "url"; value: string };
function renderWhatsAppMessage(message: string): React.ReactNode {
const regex =
/(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|https?:\/\/[^\s<]+|www\.[^\s<]+)/g;
const tokens: Token[] = [];
let lastIndex = 0;
for (const match of message.matchAll(regex)) {
const index = match.index ?? 0;
const value = match[0];
if (index > lastIndex) {
tokens.push({
type: "text",
value: message.slice(lastIndex, index),
});
}
if (value.startsWith("*")) {
tokens.push({ type: "bold", value: value.slice(1, -1) });
} else if (value.startsWith("_")) {
tokens.push({ type: "italic", value: value.slice(1, -1) });
} else if (value.startsWith("~")) {
tokens.push({ type: "strike", value: value.slice(1, -1) });
} else if (value.startsWith("`")) {
tokens.push({ type: "code", value: value.slice(1, -1) });
} else {
// Remove punctuation that commonly follows a URL.
const url = value.replace(/[.,!?;:)]+$/, "");
tokens.push({ type: "url", value: url });
// Preserve trailing punctuation.
if (url.length < value.length) {
tokens.push({
type: "text",
value: value.slice(url.length),
});
}
}
lastIndex = index + value.length;
}
if (lastIndex < message.length) {
tokens.push({
type: "text",
value: message.slice(lastIndex),
});
}
return tokens.map((token, i) => {
switch (token.type) {
case "bold":
return (
<strong key={i} className="font-bold">
{token.value}
</strong>
);
case "italic":
return (
<em key={i} className="italic">
{token.value}
</em>
);
case "strike":
return (
<del key={i} className="line-through">
{token.value}
</del>
);
case "code":
return (
<code
key={i}
className="rounded bg-black/10 px-1 py-0.5 font-mono text-sm"
>
{token.value}
</code>
);
case "url":
return (
<a
key={i}
href={
token.value.startsWith("www.")
? `https://${token.value}`
: token.value
}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-200 underline hover:text-blue-300"
>
{token.value}
</a>
);
case "text":
return <React.Fragment key={i}>{token.value}</React.Fragment>;
}
});
}
type Props = {
text?: string | null;
className?: string;
};
export default function Paragraph({ text, className }: Props) {
if (!text) return null;
return (
<div className={cn("whitespace-pre-wrap wrap-break-word", className)}>
{renderWhatsAppMessage(text)}
</div>
);
}