feat: add infinite scroll & fix some ui
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user