From 073a25b8a8c140023f3a6da8211a94bcd7284944 Mon Sep 17 00:00:00 2001 From: Steffen Kleinle Date: Wed, 22 Jan 2025 13:18:45 +0100 Subject: [PATCH] 2990: Open external links in new tab --- build-configs/BuildConfigType.ts | 4 +- build-configs/aschaffenburg/index.ts | 2 +- build-configs/integreat/index.ts | 2 +- build-configs/malte/index.ts | 2 +- build-configs/obdach/index.ts | 2 +- docs/whitelabelling.md | 2 +- native/src/constants/__mocks__/buildConfig.ts | 2 +- native/src/hooks/useNavigateToLink.ts | 4 +- native/src/utils/openExternalUrl.ts | 2 +- .../2990-external-links-new-tab.yml | 5 + web/src/components/PoiDetails.tsx | 1 - web/src/components/RemoteContent.tsx | 180 +----------------- web/src/components/RemoteContentSandBox.ts | 155 +++++++++++++++ .../__tests__/RemoteContent.spec.tsx | 11 +- web/src/components/base/Link.tsx | 6 +- web/src/utils/__tests__/openLink.spec.ts | 47 +++++ web/src/utils/isExternalUrl.ts | 13 -- web/src/utils/openLink.ts | 38 ++++ 18 files changed, 277 insertions(+), 201 deletions(-) create mode 100644 release-notes/unreleased/2990-external-links-new-tab.yml create mode 100644 web/src/components/RemoteContentSandBox.ts create mode 100644 web/src/utils/__tests__/openLink.spec.ts delete mode 100644 web/src/utils/isExternalUrl.ts create mode 100644 web/src/utils/openLink.ts diff --git a/build-configs/BuildConfigType.ts b/build-configs/BuildConfigType.ts index b8303e244e..254d793853 100644 --- a/build-configs/BuildConfigType.ts +++ b/build-configs/BuildConfigType.ts @@ -55,9 +55,9 @@ export type CommonBuildConfigType = { allowedHostNames: string[] // Linked hosts that can may look similar https://chromium.googlesource.com/chromium/src/+/master/docs/security/lookalikes/lookalike-domains.md#automated-warning-removal allowedLookalikes: string[] - // Regex defining which urls to intercept as they are internal ones. supportedIframeSources: string[] - internalLinksHijackPattern: string + // Regex defining which urls to intercept as they are internal ones. + internalUrlPattern: string featureFlags: FeatureFlagsType lightTheme: ThemeType // Translations deviating from the standard integreat translations. diff --git a/build-configs/aschaffenburg/index.ts b/build-configs/aschaffenburg/index.ts index 5d814a9040..1c8ecd663c 100644 --- a/build-configs/aschaffenburg/index.ts +++ b/build-configs/aschaffenburg/index.ts @@ -26,7 +26,7 @@ const commonAschaffenburgBuildConfig: CommonBuildConfigType = { allowedLookalikes: [], supportedIframeSources: ['vimeo.com'], translationsOverride: aschaffenburgOverrideTranslations, - internalLinksHijackPattern: + internalUrlPattern: 'https?:\\/\\/(cms(-test)?\\.integreat-app\\.de|web\\.integreat-app\\.de|integreat\\.app|aschaffenburg\\.app)(?!\\/(media|[^/]*\\/(wp-content|wp-admin|wp-json))\\/.*).*', featureFlags: { floss: false, diff --git a/build-configs/integreat/index.ts b/build-configs/integreat/index.ts index 61686f242d..338ebb8a36 100644 --- a/build-configs/integreat/index.ts +++ b/build-configs/integreat/index.ts @@ -25,7 +25,7 @@ const commonIntegreatBuildConfig: CommonBuildConfigType = { allowedHostNames: ['cms.integreat-app.de', 'cms-test.integreat-app.de', 'admin.integreat-app.de'], allowedLookalikes: ['https://integreat.app', 'https://integreat-app.de'], supportedIframeSources: ['vimeo.com'], - internalLinksHijackPattern: + internalUrlPattern: 'https?:\\/\\/(cms(-test)?\\.integreat-app\\.de|web\\.integreat-app\\.de|integreat\\.app)(?!\\/(media|[^/]*\\/(wp-content|wp-admin|wp-json))\\/.*).*', featureFlags: { floss: false, diff --git a/build-configs/malte/index.ts b/build-configs/malte/index.ts index 3bfd98942a..0949a56da8 100644 --- a/build-configs/malte/index.ts +++ b/build-configs/malte/index.ts @@ -26,7 +26,7 @@ const commonMalteBuildConfig: CommonBuildConfigType = { allowedLookalikes: [], supportedIframeSources: ['vimeo.com'], translationsOverride: malteOverrideTranslations, - internalLinksHijackPattern: + internalUrlPattern: 'https?:\\/\\/((cms\\.)?malteapp\\.de|malte-test\\.tuerantuer\\.org)(?!\\/(media|[^/]*\\/(wp-content|wp-admin|wp-json))\\/.*).*', hostName: 'malteapp.de', featureFlags: { diff --git a/build-configs/obdach/index.ts b/build-configs/obdach/index.ts index 2bfbf49a85..691b890b25 100644 --- a/build-configs/obdach/index.ts +++ b/build-configs/obdach/index.ts @@ -16,7 +16,7 @@ const commonObdachBuildConfig: CommonBuildConfigType = { allowedLookalikes: [], supportedIframeSources: ['vimeo.com'], translationsOverride: obdachOverrideTranslations, - internalLinksHijackPattern: + internalUrlPattern: 'https?:\\/\\/((cms\\.)?netzwerkobdachwohnen\\.de)(?!\\/(media|[^/]*\\/(wp-content|wp-admin|wp-json))\\/.*).*', featureFlags: { floss: false, diff --git a/docs/whitelabelling.md b/docs/whitelabelling.md index 46025bf158..20040272d1 100644 --- a/docs/whitelabelling.md +++ b/docs/whitelabelling.md @@ -79,7 +79,7 @@ Information needed from our CMS. - Switch cms url - Share base url - Allowed host names -- Internal link hijack pattern +- Internal url pattern - [depends] City name (to skip the landing screen) ## AppTeam TODOs diff --git a/native/src/constants/__mocks__/buildConfig.ts b/native/src/constants/__mocks__/buildConfig.ts index 2819e0e518..25fd9aeb86 100644 --- a/native/src/constants/__mocks__/buildConfig.ts +++ b/native/src/constants/__mocks__/buildConfig.ts @@ -23,7 +23,7 @@ const buildConfig = jest.fn( allowedHostNames: ['cms.integreat-app.de', 'cms-test.integreat-app.de', 'admin.integreat-app.de'], allowedLookalikes: ['https://integreat.app', 'https://integreat-app.de'], supportedIframeSources: ['vimeo.com'], - internalLinksHijackPattern: + internalUrlPattern: 'https?:\\/\\/(cms(-test)?\\.integreat-app\\.de|web\\.integreat-app\\.de|integreat\\.app)(?!\\/[^/]*\\/(wp-content|wp-admin|wp-json)\\/.*).*', featureFlags: { floss: false, diff --git a/native/src/hooks/useNavigateToLink.ts b/native/src/hooks/useNavigateToLink.ts index a0aa177131..1e1abafcc3 100644 --- a/native/src/hooks/useNavigateToLink.ts +++ b/native/src/hooks/useNavigateToLink.ts @@ -20,7 +20,7 @@ import useSnackbar from './useSnackbar' const SUPPORTED_IMAGE_FILE_TYPES = ['.jpg', '.jpeg', '.png'] -const HIJACK = new RegExp(buildConfig().internalLinksHijackPattern) +const internalUrlRegex = new RegExp(buildConfig().internalUrlPattern) const navigateToLink = ( url: string, @@ -52,7 +52,7 @@ const navigateToLink = ( url, shareUrl, }) - } else if (HIJACK.test(url)) { + } else if (internalUrlRegex.test(url)) { sendTrackingSignal({ signal: { name: OPEN_INTERNAL_LINK_SIGNAL_NAME, diff --git a/native/src/utils/openExternalUrl.ts b/native/src/utils/openExternalUrl.ts index 4f5f9030a7..15fcc49c9e 100644 --- a/native/src/utils/openExternalUrl.ts +++ b/native/src/utils/openExternalUrl.ts @@ -12,7 +12,7 @@ import { reportError } from './sentry' const openExternalUrl = async (rawUrl: string, showSnackbar: (snackbar: SnackbarType) => void): Promise => { const encodedUrl = encodeURI(rawUrl) const { protocol } = new URL(encodedUrl) - const internalLinkRegexp = new RegExp(buildConfig().internalLinksHijackPattern) + const internalLinkRegexp = new RegExp(buildConfig().internalUrlPattern) const canBeOpenedWithInAppBrowser = (await InAppBrowser.isAvailable()) && ['https:', 'http:'].includes(protocol) const canBeOpenedWithOtherApp = await Linking.canOpenURL(encodedUrl) diff --git a/release-notes/unreleased/2990-external-links-new-tab.yml b/release-notes/unreleased/2990-external-links-new-tab.yml new file mode 100644 index 0000000000..1bf5468916 --- /dev/null +++ b/release-notes/unreleased/2990-external-links-new-tab.yml @@ -0,0 +1,5 @@ +issue_key: 2990 +show_in_stores: false +platforms: + - web +en: Open external links in a new tab. diff --git a/web/src/components/PoiDetails.tsx b/web/src/components/PoiDetails.tsx index 3d5073afca..91dc3cd216 100644 --- a/web/src/components/PoiDetails.tsx +++ b/web/src/components/PoiDetails.tsx @@ -1,6 +1,5 @@ import React, { ReactElement } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' import styled, { useTheme } from 'styled-components' import { getExternalMapsLink } from 'shared' diff --git a/web/src/components/RemoteContent.tsx b/web/src/components/RemoteContent.tsx index 249f1e649a..9b79b40d91 100644 --- a/web/src/components/RemoteContent.tsx +++ b/web/src/components/RemoteContent.tsx @@ -2,14 +2,10 @@ import Dompurify from 'dompurify' import React, { ReactElement, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' -import styled, { css } from 'styled-components' import { ExternalSourcePermissions } from 'shared' -import { ExternalLinkIcon } from '../assets' import buildConfig from '../constants/buildConfig' -import dimensions from '../constants/dimensions' -import { helpers } from '../constants/theme' import useLocalStorage from '../hooks/useLocalStorage' import useWindowDimensions from '../hooks/useWindowDimensions' import { @@ -18,154 +14,8 @@ import { hideIframe, preserveIFrameSourcesFromContent, } from '../utils/iframes' - -const SandBox = styled.div<{ $centered: boolean; $smallText: boolean }>` - font-family: ${props => props.theme.fonts.web.contentFont}; - font-size: ${props => (props.$smallText ? helpers.adaptiveFontSize : props.theme.fonts.contentFontSize)}; - line-height: ${props => props.theme.fonts.contentLineHeight}; - display: flow-root; /* clearfix for the img floats */ - - ${props => (props.$centered ? 'text-align: center;' : '')} - ${props => (props.$centered ? 'list-style-position: inside;' : '')} - img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - - &.alignright { - float: inline-end; - } - - &.alignleft { - float: inline-start; - } - - &.aligncenter { - display: block; - margin: auto; - } - } - - figure { - margin-inline-start: 0; - text-align: center; - margin: 15px auto; - - @media only screen and (width <= 640px) { - width: 100% !important; - } - } - - figcaption { - font-size: ${props => props.theme.fonts.hintFontSize}; - font-style: italic; - padding: 0 15px; - } - - table { - display: block; - width: 100% !important; - height: auto !important; /* need important because of badly formatted remote content */ - overflow: auto; - } - - tbody, - thead { - display: table; /* little bit hacky, but works in all browsers, even IE11 :O */ - width: 100%; - box-sizing: border-box; - border-collapse: collapse; - } - - tbody, - thead, - th, - td { - border: 1px solid ${props => props.theme.colors.backgroundAccentColor}; - } - - a { - overflow-wrap: break-word; - } - - details > * { - padding: 0 25px; - } - - details > summary { - padding: 0; - } - - pre { - overflow-x: auto; - } - - .link-external::after { - content: ''; - display: inline-block; - background-image: url('${ExternalLinkIcon}'); - width: ${props => props.theme.fonts.contentFontSize}; - height: ${props => props.theme.fonts.contentFontSize}; - ${props => - props.$smallText && - css` - ${helpers.adaptiveHeight} - ${helpers.adaptiveWidth} - `}; - background-size: contain; - background-repeat: no-repeat; - vertical-align: middle; - margin: 0 4px; - } - - iframe { - border: none; - border-bottom: 1px solid ${props => props.theme.colors.borderColor}; - - @media ${dimensions.smallViewport} { - max-width: 100%; - } - } - - .iframe-container { - display: flex; - padding: 4px; - flex-direction: column; - border: 1px solid ${props => props.theme.colors.borderColor}; - border-radius: 4px; - box-shadow: - 0 1px 3px rgb(0 0 0 / 10%), - 0 1px 2px rgb(0 0 0 / 15%); - } - - .iframe-info-text { - display: flex; - padding: 12px; - justify-content: space-between; - font-size: ${props => props.theme.fonts.decorativeFontSizeSmall}; - } - - .iframe-info-text > input { - margin-inline-start: 12px; - cursor: pointer; - } - - .iframe-info-text > label { - cursor: pointer; - } - - .iframe-source { - display: contents; - font-weight: bold; - } - - #opt-in-settings-link { - margin-inline-start: 12px; - padding: 0; - cursor: pointer; - align-self: center; - } -` +import openLink from '../utils/openLink' +import RemoteContentSandBox from './RemoteContentSandBox' type RemoteContentProps = { html: string @@ -173,7 +23,6 @@ type RemoteContentProps = { smallText?: boolean } -const HIJACK = new RegExp(buildConfig().internalLinksHijackPattern) const DOMPURIFY_TAG_IFRAME = 'iframe' const DOMPURIFY_ATTRIBUTE_FULLSCREEN = 'allowfullscreen' const DOMPURIFY_ATTRIBUTE_TARGET = 'target' @@ -193,16 +42,11 @@ const RemoteContent = ({ html, centered = false, smallText = false }: RemoteCont const { viewportSmall, width: deviceWidth } = useWindowDimensions() const { t } = useTranslation() - const handleClick = useCallback( - (event: MouseEvent): void => { + const handleAnchorClick = useCallback( + (event: MouseEvent) => { event.preventDefault() - const target = event.currentTarget - - if (target instanceof HTMLAnchorElement) { - const href = target.href - const url = new URL(decodeURIComponent(href)) - navigate(decodeURIComponent(`${url.pathname}${url.search}${url.hash}`)) - } + const target = event.currentTarget as HTMLAnchorElement + openLink(navigate, target.href) }, [navigate], ) @@ -217,12 +61,8 @@ const RemoteContent = ({ html, centered = false, smallText = false }: RemoteCont return } const currentSandBoxRef = sandBoxRef.current - const collection = currentSandBoxRef.getElementsByTagName('a') - Array.from(collection).forEach(node => { - if (HIJACK.test(node.href)) { - node.addEventListener('click', handleClick) - } - }) + const anchors = currentSandBoxRef.getElementsByTagName('a') + Array.from(anchors).forEach(anchor => anchor.addEventListener('click', handleAnchorClick)) const iframes = currentSandBoxRef.getElementsByTagName('iframe') Array.from(iframes).forEach((iframe: HTMLIFrameElement, index: number) => { @@ -253,7 +93,7 @@ const RemoteContent = ({ html, centered = false, smallText = false }: RemoteCont }, [ t, html, - handleClick, + handleAnchorClick, sandBoxRef, externalSourcePermissions, contentIframeSources, @@ -270,7 +110,7 @@ const RemoteContent = ({ html, centered = false, smallText = false }: RemoteCont } return ( - ` + font-family: ${props => props.theme.fonts.web.contentFont}; + font-size: ${props => (props.$smallText ? helpers.adaptiveFontSize : props.theme.fonts.contentFontSize)}; + line-height: ${props => props.theme.fonts.contentLineHeight}; + display: flow-root; /* clearfix for the img floats */ + + ${props => (props.$centered ? 'text-align: center;' : '')} + ${props => (props.$centered ? 'list-style-position: inside;' : '')} + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + + &.alignright { + float: inline-end; + } + + &.alignleft { + float: inline-start; + } + + &.aligncenter { + display: block; + margin: auto; + } + } + + figure { + margin-inline-start: 0; + text-align: center; + margin: 15px auto; + + @media only screen and (width <= 640px) { + width: 100% !important; + } + } + + figcaption { + font-size: ${props => props.theme.fonts.hintFontSize}; + font-style: italic; + padding: 0 15px; + } + + table { + display: block; + width: 100% !important; + height: auto !important; /* need important because of badly formatted remote content */ + overflow: auto; + } + + tbody, + thead { + display: table; /* little bit hacky, but works in all browsers, even IE11 :O */ + width: 100%; + box-sizing: border-box; + border-collapse: collapse; + } + + tbody, + thead, + th, + td { + border: 1px solid ${props => props.theme.colors.backgroundAccentColor}; + } + + a { + overflow-wrap: break-word; + } + + details > * { + padding: 0 25px; + } + + details > summary { + padding: 0; + } + + pre { + overflow-x: auto; + } + + .link-external::after { + content: ''; + display: inline-block; + background-image: url('${ExternalLinkIcon}'); + width: ${props => props.theme.fonts.contentFontSize}; + height: ${props => props.theme.fonts.contentFontSize}; + ${props => + props.$smallText && + css` + ${helpers.adaptiveHeight} + ${helpers.adaptiveWidth} + `}; + background-size: contain; + background-repeat: no-repeat; + vertical-align: middle; + margin: 0 4px; + } + + iframe { + border: none; + border-bottom: 1px solid ${props => props.theme.colors.borderColor}; + + @media ${dimensions.smallViewport} { + max-width: 100%; + } + } + + .iframe-container { + display: flex; + padding: 4px; + flex-direction: column; + border: 1px solid ${props => props.theme.colors.borderColor}; + border-radius: 4px; + box-shadow: + 0 1px 3px rgb(0 0 0 / 10%), + 0 1px 2px rgb(0 0 0 / 15%); + } + + .iframe-info-text { + display: flex; + padding: 12px; + justify-content: space-between; + font-size: ${props => props.theme.fonts.decorativeFontSizeSmall}; + } + + .iframe-info-text > input { + margin-inline-start: 12px; + cursor: pointer; + } + + .iframe-info-text > label { + cursor: pointer; + } + + .iframe-source { + display: contents; + font-weight: bold; + } + + #opt-in-settings-link { + margin-inline-start: 12px; + padding: 0; + cursor: pointer; + align-self: center; + } +` + +export default RemoteContentSandBox diff --git a/web/src/components/__tests__/RemoteContent.spec.tsx b/web/src/components/__tests__/RemoteContent.spec.tsx index 4ac10c5576..3550bbb8b9 100644 --- a/web/src/components/__tests__/RemoteContent.spec.tsx +++ b/web/src/components/__tests__/RemoteContent.spec.tsx @@ -13,6 +13,8 @@ jest.mock('react-router-dom', () => ({ jest.mock('react-i18next') describe('RemoteContent', () => { + window.open = jest.fn() + beforeEach(jest.resetAllMocks) it('should render the html content', () => { @@ -33,10 +35,11 @@ describe('RemoteContent', () => { expect(navigate).toHaveBeenCalledTimes(1) expect(navigate).toHaveBeenLastCalledWith(path) + expect(window.open).not.toHaveBeenCalled() }) - it('should not navigate for external links', () => { - const href = `https://some.external/link` + it('should open external links in a new tab', () => { + const href = 'https://example.com/' const html = `Test Anchor` const { getByRole, getAllByRole } = renderWithTheme() @@ -44,7 +47,9 @@ describe('RemoteContent', () => { expect(getAllByRole('link')).toHaveLength(1) fireEvent.click(getByRole('link')) - expect(navigate).toHaveBeenCalledTimes(0) + expect(window.open).toHaveBeenCalledTimes(1) + expect(window.open).toHaveBeenLastCalledWith(href, '_blank', 'noreferrer') + expect(navigate).not.toHaveBeenCalled() }) it('should block an iframe with unsupported source', () => { diff --git a/web/src/components/base/Link.tsx b/web/src/components/base/Link.tsx index 36e306febe..250a7598c6 100644 --- a/web/src/components/base/Link.tsx +++ b/web/src/components/base/Link.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components' import { UiDirectionType } from 'translations' -import isExternalUrl from '../../utils/isExternalUrl' +import { isInternalLink, NEW_TAB, NEW_TAB_FEATURES } from '../../utils/openLink' const StyledLink = styled(RouterLink)<{ $highlighted: boolean }>` color: ${props => (props.$highlighted ? props.theme.colors.linkColor : 'inherit')}; @@ -32,8 +32,8 @@ const Link = ({ id, highlighted = false, }: LinkProps): ReactElement => { - const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {} - const linkProps = isExternalUrl(to) ? { as: 'a', href: to } : { to } + const newTabProps = newTab ? { target: NEW_TAB, rel: NEW_TAB_FEATURES } : {} + const linkProps = isInternalLink(to) ? { to } : { as: 'a', href: to } return ( // @ts-expect-error wrong types from polymorphic 'as', see https://github.com/styled-components/styled-components/issues/4112 diff --git a/web/src/utils/__tests__/openLink.spec.ts b/web/src/utils/__tests__/openLink.spec.ts new file mode 100644 index 0000000000..6bc8a419e2 --- /dev/null +++ b/web/src/utils/__tests__/openLink.spec.ts @@ -0,0 +1,47 @@ +import openLink, { isInternalLink } from '../openLink' + +describe('isInternalLink', () => { + beforeEach(jest.resetAllMocks) + + it('should detect relative internal links', () => { + expect(isInternalLink('/testumgebung/de')).toBe(true) + }) + + it('should detect internal urls', () => { + expect(isInternalLink('https://integreat.app/testumgebung/de/my-page')).toBe(true) + }) + + it('should detect external urls', () => { + expect(isInternalLink('https://example.com')).toBe(false) + expect(isInternalLink('mailto:example@integreat.app')).toBe(false) + expect(isInternalLink('https://admin.integreat-app.de/')).toBe(false) + }) +}) + +describe('openLink', () => { + const navigate = jest.fn() + window.open = jest.fn() + + beforeEach(jest.resetAllMocks) + + it('should navigate to relative internal links', () => { + openLink(navigate, '/testumgebung/de') + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith('/testumgebung/de') + expect(window.open).not.toHaveBeenCalled() + }) + + it('should navigate to internal urls', () => { + openLink(navigate, 'https://integreat.app/testumgebung/de/my-page') + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith('/testumgebung/de/my-page') + expect(window.open).not.toHaveBeenCalled() + }) + + it('should open external urls in a new tab', () => { + openLink(navigate, 'https://example.com') + expect(window.open).toHaveBeenCalledTimes(1) + expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noreferrer') + expect(navigate).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/utils/isExternalUrl.ts b/web/src/utils/isExternalUrl.ts deleted file mode 100644 index 94968dd038..0000000000 --- a/web/src/utils/isExternalUrl.ts +++ /dev/null @@ -1,13 +0,0 @@ -const isExternalUrl = (link: string): boolean => { - // If it is possible to create a URL from the link, it is an absolute and therefore an external url - // If it throws an error, it is relative and therefore an internal link - // Might be refactored to URL.canParse() at a later point, got only implemented in 2023 in most browsers - try { - const _ = new URL(link) - return true - } catch (e) { - return false - } -} - -export default isExternalUrl diff --git a/web/src/utils/openLink.ts b/web/src/utils/openLink.ts new file mode 100644 index 0000000000..fbc3d6cac3 --- /dev/null +++ b/web/src/utils/openLink.ts @@ -0,0 +1,38 @@ +import { NavigateFunction } from 'react-router-dom' + +import buildConfig from '../constants/buildConfig' + +export const NEW_TAB = '_blank' +export const NEW_TAB_FEATURES = 'noreferrer' + +const internalUrlRegex = new RegExp(buildConfig().internalUrlPattern) + +const isInternalUrl = (url: string): boolean => internalUrlRegex.test(url) + +const isRelativeInternalLink = (link: string): boolean => { + // If it is possible to create a URL from the link, it is an absolute and therefore an external url + // If it throws an error, it is relative and therefore an internal link + // Might be refactored to URL.canParse() at a later point, got only implemented in 2023 in most browsers + try { + const _ = new URL(link) + return false + } catch (e) { + // Relative and therefore internal link + return true + } +} + +export const isInternalLink = (link: string): boolean => isRelativeInternalLink(link) || isInternalUrl(link) + +const openLink = (navigate: NavigateFunction, link: string): void => { + if (isRelativeInternalLink(link)) { + navigate(link) + } else if (isInternalUrl(link)) { + const url = new URL(decodeURIComponent(link)) + navigate(decodeURIComponent(`${url.pathname}${url.search}${url.hash}`)) + } else { + window.open(link, NEW_TAB, NEW_TAB_FEATURES) + } +} + +export default openLink