diff --git a/fluxer_app/src/app/App.tsx b/fluxer_app/src/app/App.tsx index 8f8a5cd4d..b4f13d9be 100644 --- a/fluxer_app/src/app/App.tsx +++ b/fluxer_app/src/app/App.tsx @@ -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'; diff --git a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx index 6d3d52847..d32b0a97e 100644 --- a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx +++ b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx @@ -366,7 +366,6 @@ export const AttachmentGridItem: FC = observer( onClick={handleClick} onMouseEnter={scheduleViewerWarm} onMouseLeave={cancelViewerWarm} - onMouseDown={openInBrowser.onMouseDown} onAuxClick={openInBrowser.onAuxClick} onKeyDown={handleClick} aria-label={ariaLabel} diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx index a5de76ce2..d5d207782 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedGifv.tsx @@ -343,7 +343,6 @@ const ImagePreviewHandler: FC = observer( className={styles.imagePreviewHandler} aria-label={ariaLabel} onClick={openImagePreview} - onMouseDown={openInBrowser.onMouseDown} onAuxClick={openInBrowser.onAuxClick} onKeyDown={openImagePreview} onMouseEnter={onViewerWarmEnter} diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx index 4bcc28fb0..e2529fe4e 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx @@ -233,7 +233,6 @@ const ImagePreviewHandler: FC = 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} diff --git a/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx b/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx index 5fec9d925..07d07fe35 100644 --- a/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx +++ b/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx @@ -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'; diff --git a/fluxer_app/src/features/messaging/hooks/useOpenInBrowserOnMiddleClick.ts b/fluxer_app/src/features/messaging/hooks/useOpenInBrowserOnMiddleClick.ts index 14cf102fd..d91ea13c1 100644 --- a/fluxer_app/src/features/messaging/hooks/useOpenInBrowserOnMiddleClick.ts +++ b/fluxer_app/src/features/messaging/hooks/useOpenInBrowserOnMiddleClick.ts @@ -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( () => ({ - 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], diff --git a/fluxer_app/src/features/messaging/utils/ExternalLinkUtils.tsx b/fluxer_app/src/features/messaging/utils/ExternalLinkUtils.tsx index 9b90aee84..c3d2e1507 100644 --- a/fluxer_app/src/features/messaging/utils/ExternalLinkUtils.tsx +++ b/fluxer_app/src/features/messaging/utils/ExternalLinkUtils.tsx @@ -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(() => ( )), ); } + +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; + }; +} diff --git a/fluxer_app/src/features/ui/utils/NativeUtils.ts b/fluxer_app/src/features/ui/utils/NativeUtils.ts index e70c34dbd..5674ffa1b 100644 --- a/fluxer_app/src/features/ui/utils/NativeUtils.ts +++ b/fluxer_app/src/features/ui/utils/NativeUtils.ts @@ -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 { 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: { diff --git a/fluxer_app/src/features/user/components/popouts/UserProfileShared.tsx b/fluxer_app/src/features/user/components/popouts/UserProfileShared.tsx index 5c00bb7e5..542a2200d 100644 --- a/fluxer_app/src/features/user/components/popouts/UserProfileShared.tsx +++ b/fluxer_app/src/features/user/components/popouts/UserProfileShared.tsx @@ -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" > 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 ? (