-
Notifications
You must be signed in to change notification settings - Fork 25
✨(frontend) Add a link preview modal #651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cunningham already expose modals to ask confirmation.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,127 @@ | ||||||||||||||
| import { useState, useCallback, useRef } from "react"; | ||||||||||||||
| import i18n from "@/features/i18n/initI18n"; | ||||||||||||||
| import { Button, Modal, ModalSize, Alert, VariantType, iconFromType } from "@gouvfr-lasuite/cunningham-react"; | ||||||||||||||
| import classNames from "classnames"; | ||||||||||||||
| import { Trans, useTranslation } from "react-i18next"; | ||||||||||||||
| /** | ||||||||||||||
| * Modal component to show a preview of a link. | ||||||||||||||
| * <a href={url}>{linkText}</a> | ||||||||||||||
| * | ||||||||||||||
| * @param isOpen - Whether the modal is open | ||||||||||||||
| * @param onClose - Function to call when the modal is closed | ||||||||||||||
| * @param url - The URL to preview | ||||||||||||||
| * @param linkText - The text of the link (optional) | ||||||||||||||
| * @param hardWarning - Whether to show a more prominent warning | ||||||||||||||
| * @param decision - Function to call with the user's confirmation choice | ||||||||||||||
| */ | ||||||||||||||
| type LinkPreviewModalProps = { | ||||||||||||||
| isOpen: boolean; | ||||||||||||||
| url: string; | ||||||||||||||
| hardWarning?: boolean; | ||||||||||||||
| decision: (choice: boolean) => void; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Confirmation modal before redirecting to an external link. | ||||||||||||||
| * It alerts the user about potential risks (phishing, etc.). | ||||||||||||||
| */ | ||||||||||||||
| export const LinkPreviewModal = ({ isOpen, url, hardWarning, decision }: LinkPreviewModalProps) => { | ||||||||||||||
| const { t } = useTranslation(); | ||||||||||||||
| return ( | ||||||||||||||
| <Modal | ||||||||||||||
| isOpen={isOpen} | ||||||||||||||
| size={ModalSize.SMALL} | ||||||||||||||
| title={( | ||||||||||||||
| <span className="c__modal__text--centered">{ | ||||||||||||||
| hardWarning | ||||||||||||||
| ? i18n.t('Be careful!') | ||||||||||||||
| : i18n.t('This links redirects to :') | ||||||||||||||
| }</span> | ||||||||||||||
| )} | ||||||||||||||
| titleIcon={hardWarning && ( | ||||||||||||||
| <span | ||||||||||||||
| className="material-icons modal-message-error-icon" | ||||||||||||||
| > | ||||||||||||||
| {iconFromType(VariantType.WARNING)} | ||||||||||||||
| </span> | ||||||||||||||
|
Comment on lines
+42
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is an Icon component. Furthermore if you need to tweak the size instead to do that in
Suggested change
|
||||||||||||||
| )} | ||||||||||||||
| hideCloseButton={true} | ||||||||||||||
| actions={[ | ||||||||||||||
| <Button | ||||||||||||||
| key="cancel" | ||||||||||||||
| variant={hardWarning ? "primary" : "tertiary"} | ||||||||||||||
| onClick={() => decision(false)} | ||||||||||||||
| > | ||||||||||||||
| {t("Cancel")} | ||||||||||||||
| </Button>, | ||||||||||||||
| <Button | ||||||||||||||
| key="confirm" | ||||||||||||||
| variant={hardWarning ? "tertiary" : "primary"} | ||||||||||||||
| color={hardWarning ? "error" : "neutral"} | ||||||||||||||
| onClick={() => decision(true)} | ||||||||||||||
| > | ||||||||||||||
| {t("Open the link")} | ||||||||||||||
| </Button> | ||||||||||||||
| ]} | ||||||||||||||
| onClose={() => decision(false)} | ||||||||||||||
| closeOnClickOutside={true} | ||||||||||||||
| > | ||||||||||||||
| <div className="link-preview__children"> | ||||||||||||||
| {hardWarning && i18n.t('The link you clicked is probably unsafe :')} | ||||||||||||||
| <Alert type={hardWarning ? VariantType.WARNING : VariantType.NEUTRAL}>{url}</Alert> | ||||||||||||||
| <p className="link-preview__phishing-notice"> | ||||||||||||||
| <Trans i18nKey="phishing_notice"> | ||||||||||||||
| Be careful when clicking links in email, it could be a | ||||||||||||||
| <a href={`https://www.service-public.gouv.fr/particuliers/vosdroits/F34800`}> | ||||||||||||||
| phishing attempt | ||||||||||||||
| </a>. | ||||||||||||||
| </Trans> | ||||||||||||||
| </p> | ||||||||||||||
| </div> | ||||||||||||||
| </Modal > | ||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Hook to manage the state and logic of the link preview modal. | ||||||||||||||
| * Exposes an asynchronous `askConfirmation` function that waits for user action. | ||||||||||||||
| * | ||||||||||||||
| * @returns An object containing: | ||||||||||||||
| * - `askConfirmation`: an async function that opens the modal and returns a boolean (`true` if confirmed) | ||||||||||||||
| * - `modal`: the React node of the modal to be injected into the component tree | ||||||||||||||
| */ | ||||||||||||||
| export const useLinkPreviewModal = () => { | ||||||||||||||
| const [isOpen, setIsOpen] = useState(false); | ||||||||||||||
| const [url, setUrl] = useState(''); | ||||||||||||||
| const [hardWarning, setHardWarning] = useState(false); | ||||||||||||||
| const resolverRef = useRef<((choice: boolean) => void) | null>(null); | ||||||||||||||
|
|
||||||||||||||
| const askConfirmation = useCallback((urlToPreview: string, isHardWarning: boolean = false, textToPreview?: string) => { | ||||||||||||||
| setUrl(urlToPreview); | ||||||||||||||
| setHardWarning(isHardWarning); | ||||||||||||||
| setIsOpen(true); | ||||||||||||||
|
|
||||||||||||||
| return new Promise<boolean>((resolve) => { | ||||||||||||||
| resolverRef.current = resolve; | ||||||||||||||
| }); | ||||||||||||||
| }, []); | ||||||||||||||
|
|
||||||||||||||
| const decision = useCallback((choice: boolean) => { | ||||||||||||||
| setIsOpen(false); | ||||||||||||||
| if (resolverRef.current) { | ||||||||||||||
| resolverRef.current(choice); | ||||||||||||||
| resolverRef.current = null; | ||||||||||||||
| } | ||||||||||||||
| }, []); | ||||||||||||||
|
|
||||||||||||||
| const modal = isOpen ? ( | ||||||||||||||
| <LinkPreviewModal | ||||||||||||||
| isOpen={isOpen} | ||||||||||||||
| url={url} | ||||||||||||||
| hardWarning={hardWarning} | ||||||||||||||
| decision={decision} | ||||||||||||||
| /> | ||||||||||||||
| ) : null; | ||||||||||||||
|
|
||||||||||||||
| return { askConfirmation, modal }; | ||||||||||||||
| } | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO if the message is a spam, link should be simply disabled. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; | |||||
| import { getRequestUrl, getApiOrigin } from "@/features/api/utils"; | ||||||
| import { getBlobDownloadRetrieveUrl } from "@/features/api/gen/blob/blob"; | ||||||
| import { UnquoteMessage } from '@/features/utils/unquote-message'; | ||||||
| import { useTranslation } from "react-i18next"; | ||||||
| import { Trans, useTranslation } from "react-i18next"; | ||||||
| import { tokens } from '@/styles/cunningham-tokens' | ||||||
| import { useTheme } from "@/features/providers/theme"; | ||||||
| import { useConfig } from "@/features/providers/config"; | ||||||
|
|
@@ -14,6 +14,11 @@ import { getMailboxesImageProxyListUrl } from "@/features/api/gen/mailboxes/mail | |||||
| import { EXTERNAL_IMAGES_CONSENT_KEY } from "@/features/config/constants"; | ||||||
| import { renderBodyParts } from "./renderers"; | ||||||
| import { ThreadMessageBodyProps } from "./types"; | ||||||
| import { LinkPreviewModal, useLinkPreviewModal } from "./link-preview-modal"; | ||||||
| import { Alert, Button, iconFromType, VariantType } from "@gouvfr-lasuite/cunningham-react"; | ||||||
| import { handle } from "@/features/utils/errors"; | ||||||
| import { set } from "zod"; | ||||||
| import Link from "next/link"; | ||||||
|
|
||||||
| const CSP = [ | ||||||
| // Allow images from our domain, data URIs, and API endpoints | ||||||
|
|
@@ -44,7 +49,7 @@ const CSP = [ | |||||
| ].join('; '); | ||||||
|
|
||||||
| const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, messageId, onLoad }: ThreadMessageBodyProps) => { | ||||||
| const { t } = useTranslation(); | ||||||
| const { t, i18n } = useTranslation(); | ||||||
| const iframeRef = useRef<HTMLIFrameElement>(null); | ||||||
| const { cunninghamTheme, variant } = useTheme(); | ||||||
| const { selectedMailbox } = useMailboxContext(); | ||||||
|
|
@@ -64,6 +69,8 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess | |||||
| setDisplayExternalImages(true); | ||||||
| }; | ||||||
|
|
||||||
| const { askConfirmation: askLinkConfirmation, modal: linkConfirmationModal } = useLinkPreviewModal(); | ||||||
|
|
||||||
| // Build CID to blob URL mapping for inline image resolution | ||||||
| const cidToBlobUrlMap = useMemo(() => { | ||||||
| const map = new Map<string, string>(); | ||||||
|
|
@@ -321,10 +328,28 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess | |||||
| doc.querySelectorAll('details.email-quoted-content').forEach(node => { | ||||||
| node.addEventListener('toggle', resizeIframe); | ||||||
| }); | ||||||
|
|
||||||
| // Handle link clicks | ||||||
| doc.querySelectorAll('a').forEach(link => { | ||||||
| link.addEventListener('click', (e) => { | ||||||
| if (link.href != link.textContent) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feel this is a little bit fragile. |
||||||
| e.preventDefault(); | ||||||
| handleLinkClick(link.href, link.textContent); | ||||||
| } | ||||||
| }); | ||||||
| }); | ||||||
| } | ||||||
| onLoad?.(); | ||||||
| }, [onLoad, resizeIframe]); | ||||||
|
|
||||||
| const handleLinkClick = async (url: string, text: string) => { | ||||||
| const lang = i18n.language.split('-')[0]; // Get base language for documentation link | ||||||
| const decision = await askLinkConfirmation(url, false, text); | ||||||
| if (decision) { | ||||||
| window.open(url, '_blank', 'rel=noopener'); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
| } | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| const handleMessage = (event: MessageEvent) => { | ||||||
| if (event.data === 'iframe-loaded') { | ||||||
|
|
@@ -380,6 +405,7 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess | |||||
| sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" | ||||||
| onLoad={handleIframeLoad} | ||||||
| /> | ||||||
| {linkConfirmationModal} | ||||||
| </> | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We generate CHANGELOG at release time according to commit list no need to fulfill it manually.