Skip to content

Commit 9d4b286

Browse files
committed
feat: link popup warning, clean up codes
1 parent e1a3d05 commit 9d4b286

File tree

3 files changed

+281
-8
lines changed

3 files changed

+281
-8
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import React from "react";
2+
import styled, { css } from "styled-components";
3+
4+
import Modal from "react-modal";
5+
6+
import { Button } from "@kleros/ui-components-library";
7+
8+
import WarningIcon from "svgs/icons/warning-outline.svg";
9+
10+
import { landscapeStyle } from "styles/landscapeStyle";
11+
12+
const StyledModal = styled(Modal)`
13+
position: absolute;
14+
top: 50%;
15+
left: 50%;
16+
right: auto;
17+
bottom: auto;
18+
margin-right: -50%;
19+
transform: translate(-50%, -50%);
20+
height: auto;
21+
width: min(90%, 480px);
22+
border: 1px solid ${({ theme }) => theme.stroke};
23+
border-radius: 8px;
24+
background-color: ${({ theme }) => theme.whiteBackground};
25+
padding: 32px;
26+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
27+
z-index: 10002;
28+
`;
29+
30+
const Overlay = styled.div`
31+
position: fixed;
32+
top: 0;
33+
left: 0;
34+
right: 0;
35+
bottom: 0;
36+
background-color: rgba(0, 0, 0, 0.5);
37+
z-index: 10001;
38+
`;
39+
40+
const Header = styled.div`
41+
display: flex;
42+
align-items: center;
43+
gap: 12px;
44+
margin-bottom: 16px;
45+
`;
46+
47+
const StyledWarningIcon = styled(WarningIcon)`
48+
width: 24px;
49+
height: 24px;
50+
fill: ${({ theme }) => theme.warning};
51+
`;
52+
53+
const Title = styled.h3`
54+
color: ${({ theme }) => theme.primaryText};
55+
font-size: 18px;
56+
font-weight: 600;
57+
margin: 0;
58+
`;
59+
60+
const Message = styled.p`
61+
color: ${({ theme }) => theme.primaryText};
62+
font-size: 14px;
63+
line-height: 1.5;
64+
margin: 0 0 16px 0;
65+
`;
66+
67+
const UrlContainer = styled.div`
68+
background-color: ${({ theme }) => theme.lightGrey};
69+
border: 1px solid ${({ theme }) => theme.stroke};
70+
border-radius: 4px;
71+
padding: 12px;
72+
margin: 16px 0;
73+
word-break: break-all;
74+
`;
75+
76+
const Url = styled.code`
77+
color: ${({ theme }) => theme.secondaryText};
78+
font-size: 13px;
79+
font-family: monospace;
80+
`;
81+
82+
const ButtonContainer = styled.div`
83+
display: flex;
84+
gap: 12px;
85+
justify-content: center;
86+
flex-wrap: wrap;
87+
margin-top: 24px;
88+
89+
${landscapeStyle(
90+
() => css`
91+
justify-content: flex-end;
92+
`
93+
)}
94+
`;
95+
96+
const CancelButton = styled(Button)`
97+
background-color: ${({ theme }) => theme.whiteBackground};
98+
border: 1px solid ${({ theme }) => theme.stroke};
99+
100+
p {
101+
color: ${({ theme }) => theme.primaryText} !important;
102+
}
103+
104+
&:hover {
105+
background-color: ${({ theme }) => theme.mediumBlue};
106+
}
107+
`;
108+
109+
const ConfirmButton = styled(Button)`
110+
background-color: ${({ theme }) => theme.warning};
111+
color: ${({ theme }) => theme.whiteBackground};
112+
border: 1px solid ${({ theme }) => theme.warning};
113+
114+
&:hover {
115+
background-color: ${({ theme }) => theme.warning}BB;
116+
}
117+
`;
118+
119+
interface IExternalLinkWarning {
120+
isOpen: boolean;
121+
url: string;
122+
onConfirm: () => void;
123+
onCancel: () => void;
124+
}
125+
126+
const ExternalLinkWarning: React.FC<IExternalLinkWarning> = ({ isOpen, url, onConfirm, onCancel }) => {
127+
return (
128+
<StyledModal
129+
isOpen={isOpen}
130+
onRequestClose={onCancel}
131+
overlayElement={(props, contentElement) => <Overlay {...props}>{contentElement}</Overlay>}
132+
ariaHideApp={false}
133+
role="dialog"
134+
aria-labelledby="external-link-title"
135+
aria-describedby="external-link-description"
136+
>
137+
<Header>
138+
<StyledWarningIcon />
139+
<Title id="external-link-title">External Link Warning</Title>
140+
</Header>
141+
142+
<Message id="external-link-description">
143+
You are about to navigate to an external website. Please verify the URL before proceeding to ensure it&apos;s
144+
safe and legitimate.
145+
</Message>
146+
147+
<UrlContainer>
148+
<Url>{url}</Url>
149+
</UrlContainer>
150+
151+
<Message>
152+
<strong>Safety Tips:</strong>
153+
<br />
154+
• Verify the domain name is correct
155+
<br />
156+
• Check for suspicious characters or typos
157+
<br />• Only proceed if you trust this destination
158+
</Message>
159+
160+
<ButtonContainer>
161+
<CancelButton text="Cancel" onClick={onCancel} />
162+
<ConfirmButton text="Continue to External Site" onClick={onConfirm} />
163+
</ButtonContainer>
164+
</StyledModal>
165+
);
166+
};
167+
168+
export default ExternalLinkWarning;

web/src/components/MarkdownRenderer.tsx

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React from "react";
1+
import React, { useCallback, useEffect, useRef, useState } from "react";
2+
import styled from "styled-components";
23

34
import {
45
MDXEditor,
@@ -13,11 +14,33 @@ import {
1314
codeBlockPlugin,
1415
} from "@mdxeditor/editor";
1516

17+
import { isExternalLink } from "utils/linkUtils";
1618
import { sanitizeMarkdown } from "utils/markdownSanitization";
1719
import { isValidUrl } from "utils/urlValidation";
1820

1921
import { MDXRendererContainer } from "styles/mdxEditorTheme";
2022

23+
import ExternalLinkWarning from "components/ExternalLinkWarning";
24+
25+
const LinkInterceptorContainer = styled(MDXRendererContainer)`
26+
a {
27+
pointer-events: none;
28+
cursor: pointer;
29+
position: relative;
30+
31+
&::after {
32+
content: "";
33+
position: absolute;
34+
top: 0;
35+
left: 0;
36+
width: 100%;
37+
height: 100%;
38+
pointer-events: auto;
39+
cursor: pointer;
40+
}
41+
}
42+
`;
43+
2144
import "@mdxeditor/editor/style.css";
2245

2346
interface IMarkdownRenderer {
@@ -26,6 +49,54 @@ interface IMarkdownRenderer {
2649
}
2750

2851
const MarkdownRenderer: React.FC<IMarkdownRenderer> = ({ content, className }) => {
52+
const [isWarningOpen, setIsWarningOpen] = useState(false);
53+
const [pendingUrl, setPendingUrl] = useState("");
54+
const containerRef = useRef<HTMLDivElement>(null);
55+
56+
const handleExternalLink = useCallback((url: string) => {
57+
setPendingUrl(url);
58+
setIsWarningOpen(true);
59+
}, []);
60+
61+
const handleConfirmNavigation = useCallback(() => {
62+
if (pendingUrl) {
63+
window.open(pendingUrl, "_blank", "noopener,noreferrer");
64+
}
65+
setIsWarningOpen(false);
66+
setPendingUrl("");
67+
}, [pendingUrl]);
68+
69+
const handleCancelNavigation = useCallback(() => {
70+
setIsWarningOpen(false);
71+
setPendingUrl("");
72+
}, []);
73+
74+
useEffect(() => {
75+
const container = containerRef.current;
76+
if (!container) return;
77+
78+
const handleClick = (event: Event) => {
79+
const linkElement = (event.target as HTMLElement).closest("a") as HTMLAnchorElement | null;
80+
81+
if (linkElement) {
82+
const href = linkElement.getAttribute("href") || linkElement.href;
83+
if (href && isValidUrl(href) && isExternalLink(href)) {
84+
event.preventDefault();
85+
event.stopImmediatePropagation();
86+
handleExternalLink(href);
87+
}
88+
}
89+
};
90+
91+
container.addEventListener("click", handleClick, true);
92+
document.addEventListener("click", handleClick, true);
93+
94+
return () => {
95+
container.removeEventListener("click", handleClick, true);
96+
document.removeEventListener("click", handleClick, true);
97+
};
98+
}, [handleExternalLink]);
99+
29100
if (!content || content.trim() === "") {
30101
return null;
31102
}
@@ -35,25 +106,31 @@ const MarkdownRenderer: React.FC<IMarkdownRenderer> = ({ content, className }) =
35106
const editorProps: MDXEditorProps = {
36107
markdown: sanitizedContent,
37108
readOnly: true,
38-
suppressHtmlProcessing: true,
109+
suppressHtmlProcessing: false,
39110
plugins: [
40111
headingsPlugin(),
41112
listsPlugin(),
42113
quotePlugin(),
43114
thematicBreakPlugin(),
44115
markdownShortcutPlugin(),
45-
linkPlugin({
46-
validateUrl: (url) => isValidUrl(url),
47-
}),
116+
linkPlugin({ validateUrl: isValidUrl }),
48117
tablePlugin(),
49118
codeBlockPlugin({ defaultCodeBlockLanguage: "text" }),
50119
],
51120
};
52121

53122
return (
54-
<MDXRendererContainer className={className} role="region" aria-label="Markdown content">
55-
<MDXEditor {...editorProps} aria-label="Rendered markdown content" />
56-
</MDXRendererContainer>
123+
<>
124+
<LinkInterceptorContainer ref={containerRef} className={className} role="region" aria-label="Markdown content">
125+
<MDXEditor {...editorProps} aria-label="Rendered markdown content" />
126+
</LinkInterceptorContainer>
127+
<ExternalLinkWarning
128+
isOpen={isWarningOpen}
129+
url={pendingUrl}
130+
onConfirm={handleConfirmNavigation}
131+
onCancel={handleCancelNavigation}
132+
/>
133+
</>
57134
);
58135
};
59136

web/src/utils/linkUtils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const isExternalLink = (url: string): boolean => {
2+
if (!url || typeof url !== "string") {
3+
return false;
4+
}
5+
6+
const trimmedUrl = url.trim();
7+
8+
if (trimmedUrl.startsWith("/") || trimmedUrl.startsWith("./") || trimmedUrl.startsWith("../")) {
9+
return false;
10+
}
11+
12+
if (trimmedUrl.startsWith("#")) {
13+
return false;
14+
}
15+
16+
if (trimmedUrl.startsWith("mailto:") || trimmedUrl.startsWith("tel:")) {
17+
return true;
18+
}
19+
20+
try {
21+
const currentOrigin = window.location.origin;
22+
const linkUrl = new URL(trimmedUrl, currentOrigin);
23+
24+
return linkUrl.origin !== currentOrigin;
25+
} catch {
26+
return true;
27+
}
28+
};

0 commit comments

Comments
 (0)