fix(links): warn on unhandled middle clicks and desktop links (#3311)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 20:05:54 +02:00
1 parent ebf91ccc76
commit bef20cba85
9 files changed
+71 -69

No files matched your search

+2 -1
View File
@@ -26,6 +26,7 @@ import GatewayConnection from '@app/features/gateway/transport/GatewayConnection
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import MemberSidebar from '@app/features/member/state/MemberSidebar';
import {attachExternalLinkInterceptor} from '@app/features/messaging/utils/ExternalLinkUtils';
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
import SessionManager from '@app/features/platform/state/AuthSession';
@@ -50,7 +51,7 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
import Modal from '@app/features/ui/state/Modal';
import Popout from '@app/features/ui/state/Popout';
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
@@ -366,7 +366,6 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
onClick={handleClick}
onMouseEnter={scheduleViewerWarm}
onMouseLeave={cancelViewerWarm}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={handleClick}
aria-label={ariaLabel}
@@ -343,7 +343,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
className={styles.imagePreviewHandler}
aria-label={ariaLabel}
onClick={openImagePreview}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={openImagePreview}
onMouseEnter={onViewerWarmEnter}
@@ -233,7 +233,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
className={styles.imagePreviewHandler}
aria-label={i18n._(OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR)}
onClick={openImagePreview}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={openImagePreview}
onMouseEnter={onViewerWarmEnter}
@@ -31,11 +31,9 @@ import {
type RendererProps,
} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {ExternalLinkWarningModal} from '@app/features/messaging/components/modals/ExternalLinkWarningModal';
import {
MIDDLE_MOUSE_BUTTON,
useOpenInBrowserOnMiddleClick,
} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {MIDDLE_MOUSE_BUTTON} from '@app/features/messaging/utils/ExternalLinkUtils';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import type {LinkNode} from '@app/features/messaging/utils/markdown/parser/Nodes';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
@@ -1,27 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import {handleExternalLinkAuxClick} from '@app/features/messaging/utils/ExternalLinkUtils';
import type React from 'react';
import {useMemo} from 'react';
export const MIDDLE_MOUSE_BUTTON = 1;
interface MiddleClickOpenHandlers {
onMouseDown: (event: React.MouseEvent) => void;
onAuxClick: (event: React.MouseEvent) => void;
}
export function useOpenInBrowserOnMiddleClick(url: string | null | undefined, enabled = true): MiddleClickOpenHandlers {
return useMemo<MiddleClickOpenHandlers>(
() => ({
onMouseDown: (event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON || !enabled || !url) return;
},
onAuxClick: (event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON || !enabled || !url) return;
event.preventDefault();
event.stopPropagation();
openExternalUrlWithWarning(url);
if (enabled) handleExternalLinkAuxClick(event, url);
},
}),
[url, enabled],
@@ -2,31 +2,79 @@
import {openOAuthAuthorizeModalFromUrl} from '@app/features/auth/commands/OAuthAuthorizeModalCommands';
import {ExternalLinkWarningModal} from '@app/features/messaging/components/modals/ExternalLinkWarningModal';
import {unwrapDesktopLocalResourceURL} from '@app/features/messaging/utils/DesktopLocalResourceTarget';
import TrustedDomain from '@app/features/trusted_domain/state/TrustedDomain';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import {getSafeExternalUrl, isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import type React from 'react';
export function openExternalUrlWithWarning(url: string): void {
if (openOAuthAuthorizeModalFromUrl(url)) {
return;
}
let hostname: string | null = null;
export const MIDDLE_MOUSE_BUTTON = 1;
let externalLinkInterceptorAttached = false;
function isTrustedExternalUrl(url: string): boolean {
try {
hostname = new URL(url).hostname;
return TrustedDomain.isTrustedDomain(new URL(url).hostname);
} catch {
hostname = null;
return false;
}
if (hostname && TrustedDomain.isTrustedDomain(hostname)) {
void openExternalUrl(url);
}
export function openUrlInBrowserWithWarning(url: string): void {
const target = unwrapDesktopLocalResourceURL(url);
if (isTrustedExternalUrl(target)) {
void openExternalUrl(target);
return;
}
ModalCommands.push(
modal(() => (
<ExternalLinkWarningModal
url={url}
url={target}
data-flx="messaging.external-link-utils.open-external-url-with-warning.external-link-warning-modal"
/>
)),
);
}
export function openExternalUrlWithWarning(url: string): void {
if (openOAuthAuthorizeModalFromUrl(url)) {
return;
}
openUrlInBrowserWithWarning(url);
}
export function handleExternalLinkAuxClick(event: React.MouseEvent, url: string | null | undefined): void {
if (event.button !== MIDDLE_MOUSE_BUTTON || !url) return;
event.preventDefault();
event.stopPropagation();
openUrlInBrowserWithWarning(url);
}
function findUnhandledExternalAnchorHref(event: MouseEvent): string | null {
if (event.defaultPrevented) return null;
const target = event.target as Element | null;
const anchor = target?.closest?.('a[target="_blank"]');
const href = anchor?.getAttribute('href') ?? null;
return getSafeExternalUrl(href) ? href : null;
}
export function attachExternalLinkInterceptor(): () => void {
if (!isDesktop() || externalLinkInterceptorAttached) return () => undefined;
const handler = (event: MouseEvent) => {
const expectedButton = event.type === 'click' ? 0 : MIDDLE_MOUSE_BUTTON;
if (event.button !== expectedButton) return;
const href = findUnhandledExternalAnchorHref(event);
if (href == null) return;
event.preventDefault();
openUrlInBrowserWithWarning(href);
};
document.addEventListener('click', handler);
document.addEventListener('auxclick', handler);
externalLinkInterceptorAttached = true;
return () => {
document.removeEventListener('click', handler);
document.removeEventListener('auxclick', handler);
externalLinkInterceptorAttached = false;
};
}
@@ -150,8 +150,6 @@ export function supportsDesktopScreenShareAudioCapture(): boolean {
return electronApi.platform === 'win32' || electronApi.platform === 'linux' || electronApi.platform === 'darwin';
}
let externalLinkHandlerAttached = false;
const BLOCKED_EXTERNAL_URL_PROTOCOLS = new Set([
'file:',
'javascript:',
@@ -165,7 +163,7 @@ const BLOCKED_EXTERNAL_URL_PROTOCOLS = new Set([
]);
const EXPLICIT_URL_PROTOCOL_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;
const getSafeExternalUrl = (href: string | null): string | null => {
export function getSafeExternalUrl(href: string | null): string | null {
if (!href) return null;
const trimmed = unwrapDesktopLocalResourceURL(href.trim());
if (!EXPLICIT_URL_PROTOCOL_PATTERN.test(trimmed)) return null;
@@ -176,7 +174,7 @@ const getSafeExternalUrl = (href: string | null): string | null => {
} catch {
return null;
}
};
}
export async function navigateToExternalURL(url: string): Promise<void> {
const safeUrl = getSafeExternalUrl(url);
@@ -237,40 +235,6 @@ export function handleExternalLinkClick(
void openExternalUrl(url);
}
export function attachExternalLinkInterceptor() {
if (!isDesktop() || externalLinkHandlerAttached) return () => undefined;
const handler = (event: MouseEvent) => {
if (event.defaultPrevented) return;
if (event.button !== 0) return;
const target = event.target as HTMLElement | null;
const anchor = target?.closest?.('a[target="_blank"]') as HTMLAnchorElement | null;
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!getSafeExternalUrl(href)) return;
event.preventDefault();
void openExternalUrl(href ?? '');
};
const auxHandler = (event: MouseEvent) => {
if (event.defaultPrevented) return;
if (event.button !== 1) return;
const target = event.target as HTMLElement | null;
const anchor = target?.closest?.('a[target="_blank"]') as HTMLAnchorElement | null;
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!getSafeExternalUrl(href)) return;
event.preventDefault();
void openExternalUrl(href ?? '');
};
document.addEventListener('click', handler);
document.addEventListener('auxclick', auxHandler);
externalLinkHandlerAttached = true;
return () => {
document.removeEventListener('click', handler);
document.removeEventListener('auxclick', auxHandler);
externalLinkHandlerAttached = false;
};
}
export type NativeDownloadOutcome = 'success' | 'canceled' | 'checksum-mismatch' | 'failed' | 'unavailable';
export async function downloadWithNative(options: {
@@ -12,7 +12,7 @@ import {
import {getCachedDateTimeFormat} from '@app/features/i18n/utils/IntlCache';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import {handleExternalLinkAuxClick, openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import markupStyles from '@app/features/theme/styles/Markup.module.css';
@@ -469,6 +469,7 @@ const ConnectionCard: React.FC<{
rel="noopener noreferrer"
className={styles.connectionCard}
onClick={(e) => onLinkClick(e, url)}
onAuxClick={(e) => handleExternalLinkAuxClick(e, url)}
data-flx="user.user-profile-shared.connection-card.connection-card.link-click"
>
{icon}
@@ -494,6 +495,7 @@ const ConnectionCard: React.FC<{
rel="noopener noreferrer"
className={styles.connectionExternalLink}
onClick={(e) => onLinkClick(e, url)}
onAuxClick={(e) => handleExternalLinkAuxClick(e, url)}
data-flx="user.user-profile-shared.connection-card.connection-external-link.link-click"
>
<ArrowSquareOutIcon
@@ -577,6 +579,7 @@ export const UserProfileConnections: React.FC<{
rel="noopener noreferrer"
className={styles.connectionCompactIcon}
onClick={(e) => handleConnectionClick(e, url)}
onAuxClick={(e) => handleExternalLinkAuxClick(e, url)}
data-flx="user.user-profile-shared.user-profile-connections.connection-compact-icon.connection-click"
>
{connection.type === ConnectionTypes.BLUESKY ? (