Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / components / hooks / useLinkHandler.tsx
blob854f8e4cde3764eec9edf41e7b036a8c7c2f051a
1 import type { ReactNode, RefObject } from 'react';
2 import { useEffect, useState } from 'react';
4 import { c } from 'ttag';
6 import useModalState from '@proton/components/components/modalTwo/useModalState';
7 import useNotifications from '@proton/components/hooks/useNotifications';
8 import { PROTON_DOMAINS } from '@proton/shared/lib/constants';
9 import { getSecondLevelDomain, isSubDomain } from '@proton/shared/lib/helpers/url';
10 import type { MailSettings } from '@proton/shared/lib/interfaces';
11 import { CONFIRM_LINK } from '@proton/shared/lib/mail/mailSettings';
12 import isTruthy from '@proton/utils/isTruthy';
14 import LinkConfirmationModal from '../components/notifications/LinkConfirmationModal/LinkConfirmationModal';
15 import { getHostname, isExternal, punycodeUrl } from '../helpers/url';
16 import useHandler from './useHandler';
18 // Reference : Angular/src/app/utils/directives/linkHandler.js
20 interface UseLinkHandlerOptions {
21     onMailTo?: (src: string) => void;
22     startListening?: boolean;
23     isOutside?: boolean;
24     isPhishingAttempt?: boolean;
26 type UseLinkHandler = (
27     wrapperRef: RefObject<HTMLDivElement | undefined>,
28     mailSettings?: MailSettings,
29     options?: UseLinkHandlerOptions
30 ) => { modal: ReactNode };
32 const defaultOptions: UseLinkHandlerOptions = {
33     startListening: true,
36 const getSrc = (target: Element) => {
37     return { encoded: target.toString() || '', raw: target.getAttribute('href') || '' };
40 export const useLinkHandler: UseLinkHandler = (
41     wrapperRef,
42     mailSettings,
43     { onMailTo, startListening, isOutside, isPhishingAttempt } = defaultOptions
44 ) => {
45     const { createNotification } = useNotifications();
46     const [link, setLink] = useState<string>();
47     const [linkConfirmationModalProps, setLinkConfirmationModalOpen, renderLinkConfirmationModal] = useModalState();
49     // eslint-disable-next-line @typescript-eslint/no-misused-promises
50     const handleClick = useHandler(async (event: Event) => {
51         const originalTarget = event.target as Element;
52         const target = originalTarget.closest('a') || originalTarget.closest('area');
54         if (!target) {
55             return;
56         }
58         const src = getSrc(target);
60         if (!src.raw) {
61             createNotification({
62                 text: c('Error')
63                     .t`This message may contain some links URL that cannot be properly opened by your current browser.`,
64                 type: 'error',
65             });
66         }
68         // We only handle anchor that begins with `mailto:`
69         if (src.raw.toLowerCase().startsWith('mailto:') && onMailTo) {
70             event.preventDefault();
71             event.stopPropagation(); // Required for Safari
73             /*
74              * Open the composer with the given mailto address
75              * position isAfter true as the user can choose to set a body
76              */
77             onMailTo(src.raw);
78         }
80         const askForConfirmation = mailSettings?.ConfirmLink ?? CONFIRM_LINK.CONFIRM;
81         const hostname = getHostname(src.raw);
82         const currentDomain = getSecondLevelDomain(window.location.hostname);
84         /*
85          * If the modal is already active --- do nothing
86          * ex: click on a link, open the modal, inside the continue button is an anchor with the same link.
87          */
88         if (linkConfirmationModalProps.open) {
89             return;
90         }
92         /*
93          * If dealing with anchors, we need to treat them separately because we use URLs with # for searching elements
94          */
95         if (src.raw.startsWith('#')) {
96             const id = src.raw.replace('#', '');
97             if (wrapperRef.current) {
98                 const elementInMail = wrapperRef.current.querySelector(`[name="${id}"], [id="${id}"]`);
99                 if (elementInMail) {
100                     elementInMail.scrollIntoView({ behavior: 'smooth', block: 'start' });
101                 }
102             }
103             event.preventDefault();
104             event.stopPropagation();
105             return;
106         }
108         if (
109             (askForConfirmation || isPhishingAttempt) &&
110             isExternal(src.raw, window.location.hostname) &&
111             ![...PROTON_DOMAINS, currentDomain]
112                 .filter(isTruthy) // currentDomain can be null
113                 .some((domain) => isSubDomain(hostname, domain))
114         ) {
115             event.preventDefault();
116             event.stopPropagation(); // Required for Safari
118             const link = punycodeUrl(src.encoded || src.raw);
119             setLink(link);
120             setLinkConfirmationModalOpen(true);
121         }
122     });
124     useEffect(() => {
125         if (startListening === false) {
126             return;
127         }
129         // eslint-disable-next-line @typescript-eslint/no-misused-promises
130         wrapperRef.current?.addEventListener('click', handleClick, false);
131         return () => {
132             // eslint-disable-next-line @typescript-eslint/no-misused-promises
133             wrapperRef.current?.removeEventListener('click', handleClick, false);
134         };
135     }, [startListening, wrapperRef.current]);
137     const modal = renderLinkConfirmationModal ? (
138         <LinkConfirmationModal
139             link={link}
140             isOutside={isOutside}
141             isPhishingAttempt={isPhishingAttempt}
142             modalProps={linkConfirmationModalProps}
143         />
144     ) : null;
146     return { modal };