292 lines
6.8 KiB
TypeScript
292 lines
6.8 KiB
TypeScript
import {
|
|
AUDIOS,
|
|
IMAGES,
|
|
NOSTR_EVENTS,
|
|
NOSTR_MENTIONS,
|
|
VIDEOS,
|
|
cn,
|
|
regionNames,
|
|
} from "@lume/utils";
|
|
import { NDKKind } from "@nostr-dev-kit/ndk";
|
|
import { fetch } from "@tauri-apps/plugin-http";
|
|
import getUrls from "get-urls";
|
|
import { nanoid } from "nanoid";
|
|
import { nip19 } from "nostr-tools";
|
|
import { ReactNode, useMemo, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import reactStringReplace from "react-string-replace";
|
|
import {
|
|
Hashtag,
|
|
ImagePreview,
|
|
LinkPreview,
|
|
MentionNote,
|
|
MentionUser,
|
|
VideoPreview,
|
|
useNoteContext,
|
|
useStorage,
|
|
} from "../..";
|
|
import { NIP89 } from "./nip89";
|
|
|
|
export function NoteContent({
|
|
className,
|
|
isTranslatable = false,
|
|
}: {
|
|
className?: string;
|
|
isTranslatable?: boolean;
|
|
}) {
|
|
const storage = useStorage();
|
|
const event = useNoteContext();
|
|
|
|
const [content, setContent] = useState(event.content);
|
|
const [translated, setTranslated] = useState(false);
|
|
|
|
const richContent = useMemo(() => {
|
|
if (event.kind !== NDKKind.Text) return content;
|
|
|
|
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, "\n");
|
|
let linkPreview: string = undefined;
|
|
let images: string[] = [];
|
|
let videos: string[] = [];
|
|
let audios: string[] = [];
|
|
let events: string[] = [];
|
|
|
|
const text = parsedContent;
|
|
const words = text.split(/( |\n)/);
|
|
const urls = [...getUrls(text)];
|
|
|
|
if (storage.settings.media && !storage.settings.lowPower) {
|
|
images = urls.filter((word) =>
|
|
IMAGES.some((el) => {
|
|
const url = new URL(word);
|
|
const extension = url.pathname.split(".")[1];
|
|
if (extension === el) return true;
|
|
return false;
|
|
}),
|
|
);
|
|
videos = urls.filter((word) =>
|
|
VIDEOS.some((el) => {
|
|
const url = new URL(word);
|
|
const extension = url.pathname.split(".")[1];
|
|
if (extension === el) return true;
|
|
return false;
|
|
}),
|
|
);
|
|
audios = urls.filter((word) =>
|
|
AUDIOS.some((el) => {
|
|
const url = new URL(word);
|
|
const extension = url.pathname.split(".")[1];
|
|
if (extension === el) return true;
|
|
return false;
|
|
}),
|
|
);
|
|
}
|
|
|
|
events = words.filter((word) =>
|
|
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
|
);
|
|
|
|
const hashtags = words.filter((word) => word.startsWith("#"));
|
|
const mentions = words.filter((word) =>
|
|
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
|
);
|
|
|
|
try {
|
|
if (images.length) {
|
|
for (const image of images) {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
image,
|
|
(match, i) => <ImagePreview key={match + i} url={match} />,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (videos.length) {
|
|
for (const video of videos) {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
video,
|
|
(match, i) => <VideoPreview key={match + i} url={match} />,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (audios.length) {
|
|
for (const audio of audios) {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
audio,
|
|
(match, i) => <VideoPreview key={match + i} url={match} />,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (hashtags.length) {
|
|
for (const hashtag of hashtags) {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
hashtag,
|
|
(match, i) => {
|
|
if (storage.settings.hashtag)
|
|
return <Hashtag key={match + i} tag={hashtag} />;
|
|
return null;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (events.length) {
|
|
for (const event of events) {
|
|
const address = event
|
|
.replace("nostr:", "")
|
|
.replace(/[^a-zA-Z0-9]/g, "");
|
|
const decoded = nip19.decode(address);
|
|
|
|
if (decoded.type === "note") {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
event,
|
|
(match, i) => (
|
|
<MentionNote key={match + i} eventId={decoded.data} />
|
|
),
|
|
);
|
|
}
|
|
|
|
if (decoded.type === "nevent") {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
event,
|
|
(match, i) => (
|
|
<MentionNote key={match + i} eventId={decoded.data.id} />
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mentions.length) {
|
|
for (const mention of mentions) {
|
|
const address = mention
|
|
.replace("nostr:", "")
|
|
.replace("@", "")
|
|
.replace(/[^a-zA-Z0-9]/g, "");
|
|
const decoded = nip19.decode(address);
|
|
|
|
if (decoded.type === "npub") {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
mention,
|
|
(match, i) => (
|
|
<MentionUser key={match + i} pubkey={decoded.data} />
|
|
),
|
|
);
|
|
}
|
|
|
|
if (decoded.type === "nprofile" || decoded.type === "naddr") {
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
mention,
|
|
(match, i) => (
|
|
<MentionUser key={match + i} pubkey={decoded.data.pubkey} />
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
parsedContent = reactStringReplace(
|
|
parsedContent,
|
|
/(https?:\/\/\S+)/g,
|
|
(match, i) => {
|
|
const url = new URL(match);
|
|
|
|
if (!linkPreview) {
|
|
linkPreview = match;
|
|
return <LinkPreview key={match + i} url={url.toString()} />;
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
key={match + i}
|
|
to={url.toString()}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="break-all font-normal text-blue-500 hover:text-blue-600"
|
|
>
|
|
{url.toString()}
|
|
</Link>
|
|
);
|
|
},
|
|
);
|
|
|
|
parsedContent = reactStringReplace(parsedContent, "\n", () => {
|
|
return <div key={nanoid()} className="h-3" />;
|
|
});
|
|
|
|
if (typeof parsedContent[0] === "string") {
|
|
parsedContent[0] = parsedContent[0].trimStart();
|
|
}
|
|
|
|
return parsedContent;
|
|
} catch (e) {
|
|
console.warn("[parser] parse failed: ", e);
|
|
return parsedContent;
|
|
}
|
|
}, [content]);
|
|
|
|
const translate = async () => {
|
|
try {
|
|
const res = await fetch("https://translate.nostr.wine/translate", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
q: content,
|
|
target: "vi",
|
|
api_key: storage.settings.translateApiKey,
|
|
}),
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
setContent(data.translatedText);
|
|
setTranslated(true);
|
|
} catch (e) {
|
|
console.error(String(e));
|
|
}
|
|
};
|
|
|
|
if (event.kind !== NDKKind.Text) {
|
|
return <NIP89 className={className} />;
|
|
}
|
|
|
|
return (
|
|
<div className={cn("", className)}>
|
|
<div className="break-p select-text whitespace-pre-line text-balance leading-normal">
|
|
{richContent}
|
|
</div>
|
|
{isTranslatable && storage.settings.translation ? (
|
|
translated ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setTranslated(false);
|
|
setContent(event.content);
|
|
}}
|
|
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
|
|
>
|
|
Show original content
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={translate}
|
|
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
|
|
>
|
|
Translate to {regionNames.of(storage.locale)}
|
|
</button>
|
|
)
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|