diff --git a/src/App.tsx b/src/App.tsx index 32eb7fc..5a3681a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1663,7 +1663,8 @@ function App() { openSnackGlobal: openSnack, setOpenSnackGlobal: setOpenSnack, infoSnackCustom: infoSnack, - setInfoSnackCustom: setInfoSnack + setInfoSnackCustom: setInfoSnack, + userInfo: userInfo }} > { - if (!url || !url.startsWith("qortal://")) { // Check if url exists and starts with "qortal://" + if (!url || !url.startsWith("qortal://")) { return null; } - url = url.replace(/^(qortal\:\/\/)/, ""); // Safe to use replace now + // Skip links starting with "qortal://use-" + if (url.startsWith("qortal://use-")) { + return null; + } + + url = url.replace(/^(qortal\:\/\/)/, ""); if (url.includes("/")) { let parts = url.split("/"); const service = parts[0].toUpperCase(); @@ -26,6 +31,7 @@ export const extractComponents = (url) => { function processText(input) { const linkRegex = /(qortal:\/\/\S+)/g; + function processNode(node) { if (node.nodeType === Node.TEXT_NODE) { const parts = node.textContent.split(linkRegex); @@ -69,6 +75,7 @@ export const MessageDisplay = ({ htmlContent, isReply }) => { }); return processText(textFormatted); }; + const sanitizedContent = DOMPurify.sanitize(linkify(htmlContent), { ALLOWED_TAGS: [ @@ -79,7 +86,7 @@ export const MessageDisplay = ({ htmlContent, isReply }) => { 'href', 'target', 'rel', 'class', 'src', 'alt', 'title', 'width', 'height', 'style', 'align', 'valign', 'colspan', 'rowspan', 'border', 'cellpadding', 'cellspacing', 'data-url' ], - }); + }).replace(/]*data-url="qortal:\/\/use-embed\/[^"]*"[^>]*>.*?<\/span>/g, '');; const handleClick = async (e) => { e.preventDefault(); @@ -100,11 +107,25 @@ export const MessageDisplay = ({ htmlContent, isReply }) => { } }; + + const embedLink = htmlContent.match(/qortal:\/\/use-embed\/[^\s<>]+/); + + let embedData = null; + + if (embedLink) { + embedData = embedLink[0] + } + return ( + <> + {embedLink && ( + + )}
+ ); }; diff --git a/src/components/Embeds/Embed.tsx b/src/components/Embeds/Embed.tsx new file mode 100644 index 0000000..4a1bbfe --- /dev/null +++ b/src/components/Embeds/Embed.tsx @@ -0,0 +1,533 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { MyContext, getBaseApiReact } from "../../App"; +import { + Card, + CardContent, + CardHeader, + Typography, + RadioGroup, + Radio, + FormControlLabel, + Button, + Box, + ButtonBase, + Divider, +} from "@mui/material"; +import { getNameInfo } from "../Group/Group"; +import { getFee } from "../../background"; +import { Spacer } from "../../common/Spacer"; +import { CustomizedSnackbars } from "../Snackbar/Snackbar"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { extractComponents } from "../Chat/MessageDisplay"; +import { executeEvent } from "../../utils/events"; +import { CustomLoader } from "../../common/CustomLoader"; + +function decodeHTMLEntities(str) { + const txt = document.createElement("textarea"); + txt.innerHTML = str; + return txt.value; +} + +const parseQortalLink = (link) => { + const prefix = "qortal://use-embed/"; + if (!link.startsWith(prefix)) { + throw new Error("Invalid link format"); + } + + // Decode any HTML entities in the link + link = decodeHTMLEntities(link); + + const [typePart, queryPart] = link.slice(prefix.length).split("?"); + const type = typePart.toUpperCase(); + + const params = {}; + if (queryPart) { + const queryPairs = queryPart.split("&"); + + queryPairs.forEach((pair) => { + const [key, value] = pair.split("="); + if (key && value) { + const decodedKey = decodeURIComponent(key.trim()); + const decodedValue = decodeURIComponent(value.trim()).replace( + /<\/?[^>]+(>|$)/g, + "" + ); // Remove any HTML tags + params[decodedKey] = decodedValue; + } + }); + } + + return { type, ...params }; +}; +const getPoll = async (name) => { + const pollName = name; + const url = `${getBaseApiReact()}/polls/${pollName}`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseData = await response.json(); + if (responseData?.message?.includes("POLL_NO_EXISTS")) { + throw new Error("POLL_NO_EXISTS"); + } else if (responseData?.pollName) { + const urlVotes = `${getBaseApiReact()}/polls/votes/${pollName}`; + + const responseVotes = await fetch(urlVotes, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseDataVotes = await responseVotes.json(); + return { + info: responseData, + votes: responseDataVotes, + }; + } +}; + +export const Embed = ({ embedLink }) => { + const [errorMsg, setErrorMsg] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [poll, setPoll] = useState(null); + const [type, setType] = useState(""); + const hasFetched = useRef(false); + const [openSnack, setOpenSnack] = useState(false); + const [infoSnack, setInfoSnack] = useState(null); + const [external, setExternal] = useState(null); + + const handlePoll = async (parsedData) => { + try { + setIsLoading(true); + setErrorMsg(""); + setType("POLL"); + if (!parsedData?.name) + throw new Error("Invalid poll embed link. Missing name."); + const pollRes = await getPoll(parsedData.name); + setPoll(pollRes); + if (parsedData?.ref) { + const res = extractComponents(parsedData.ref); + const { service, name, identifier, path } = res; + + if (service && name) { + setExternal(res); + } + } + } catch (error) { + setErrorMsg(error?.message || "Invalid embed link"); + } finally { + setIsLoading(false); + } + }; + const handleLink = () => { + try { + const parsedData = parseQortalLink(embedLink); + const type = parsedData?.type; + switch (type) { + case "POLL": + { + handlePoll(parsedData); + } + break; + + default: + break; + } + } catch (error) { + setErrorMsg(error?.message || "Invalid embed link"); + } + }; + + const openExternal = () => { + executeEvent("addTab", { data: external }); + executeEvent("open-apps-mode", {}); + }; + + useEffect(() => { + if (!embedLink || hasFetched.current) return; + handleLink(); + hasFetched.current = true; + }, [embedLink]); + + return ( +
+ {!type && } + {type === "POLL" && ( + + )} + +
+ ); +}; + +export const PollCard = ({ + poll, + setInfoSnack, + setOpenSnack, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, +}) => { + const [selectedOption, setSelectedOption] = useState(""); + const [ownerName, setOwnerName] = useState(""); + const [showResults, setShowResults] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const { show, userInfo } = useContext(MyContext); + const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); + const handleVote = async () => { + const fee = await getFee("VOTE_ON_POLL"); + + await show({ + message: `Do you accept this VOTE_ON_POLL transaction?`, + publishFee: fee.fee + " QORT", + }); + setIsLoadingSubmit(true); + + window + .sendMessage( + "voteOnPoll", + { + pollName: poll?.info?.pollName, + optionIndex: +selectedOption, + }, + 60000 + ) + .then((response) => { + setIsLoadingSubmit(false); + if (response.error) { + setInfoSnack({ + type: "error", + message: response?.error || "Unable to vote.", + }); + setOpenSnack(true); + return; + } else { + setInfoSnack({ + type: "success", + message: + "Successfully voted. Please wait a couple minutes for the network to propogate the changes.", + }); + setOpenSnack(true); + } + }) + .catch((error) => { + setIsLoadingSubmit(false); + setInfoSnack({ + type: "error", + message: error?.message || "Unable to vote.", + }); + setOpenSnack(true); + }); + }; + + const getName = async (owner) => { + try { + const res = await getNameInfo(owner); + if (res) { + setOwnerName(res); + } + } catch (error) {} + }; + + useEffect(() => { + if (poll?.info?.owner) { + getName(poll.info.owner); + } + }, [poll?.info?.owner]); + + return ( + + + POLL embed + + + + + {external && ( + + + + )} + + + + + Created by {ownerName || poll?.info?.owner} + + + + + {!isOpen && !errorMsg && ( + <> + + + + )} + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + + + Options + + setSelectedOption(e.target.value)} + > + {poll?.info?.pollOptions?.map((option, index) => ( + + } + label={option?.optionName} + /> + ))} + + + + + {" "} + {`${poll?.votes?.totalVotes} ${ + poll?.votes?.totalVotes === 1 ? " vote" : " votes" + }`} + + + + + item?.voterPublicKey === userInfo?.publicKey + ) + ? "visible" + : "hidden", + }} + > + You've already voted. + + + {isLoadingSubmit && ( + + Is processing transaction, please wait... + + )} + { + setShowResults((prev) => !prev); + }} + > + {showResults ? "hide " : "show "} results + + + {showResults && } + + + ); +}; + +const PollResults = ({ votes }) => { + const maxVotes = Math.max( + ...votes?.voteCounts?.map((option) => option.voteCount) + ); + const options = votes?.voteCounts; + return ( + + {options + .sort((a, b) => b.voteCount - a.voteCount) // Sort options by votes (highest first) + .map((option, index) => ( + + + + {`${index + 1}. ${option.optionName}`} + + + {option.voteCount} votes + + + + + + + ))} + + ); +};