// SPDX-License-Identifier: AGPL-3.0-or-later import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import { openOAuthAuthorizeModalFromUrl, parseOAuthAuthorizeModalUrl, } from '@app/features/auth/commands/OAuthAuthorizeModalCommands'; import * as ChannelGateCommands from '@app/features/channel/commands/ChannelGateCommands'; import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands'; import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/ChannelUtils'; import DeveloperMode from '@app/features/devtools/state/DeveloperMode'; import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands'; import DiscoveryChannelPreviews from '@app/features/discovery/state/DiscoveryChannelPreviews'; import type {Guild} from '@app/features/guild/models/Guild'; import Guilds from '@app/features/guild/state/Guilds'; import {getGuildIconDisplayInitials} from '@app/features/guild/utils/GuildInitialsUtils'; import { COPY_LINK_DESCRIPTOR, OKAY_DESCRIPTOR, OPEN_LINK_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import * as InviteCommands from '@app/features/invite/commands/InviteCommands'; import * as InviteUtils from '@app/features/invite/utils/InviteUtils'; import jumpLinkStyles from '@app/features/messaging/components/markdown/renderers/MessageJumpLink.module.css'; import { isRestrictedInlineContext, type MarkdownRenderOptions, 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 AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher'; 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'; import { type AppPageId, getAppPageIcon, getAppPageLabel, navigateToAppPage, parseAppPageLink, } from '@app/features/navigation/utils/AppPageLinks'; import { handleDeepLinkUrl, isInternalChannelHost, navigateToLinkedUserProfile, parseChannelJumpLink, parseChannelUrl, parseMessageJumpLink, parseUserProfileUrl, parseUserSettingsDeepLink, } from '@app/features/navigation/utils/DeepLinkUtils'; import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP'; import {Logger} from '@app/features/platform/utils/AppLogger'; import StreamerMode from '@app/features/streamer_mode/state/StreamerMode'; import * as ThemeCommands from '@app/features/theme/commands/ThemeCommands'; import linkRendererStyles from '@app/features/theme/styles/LinkRenderer.module.css'; import markupStyles from '@app/features/theme/styles/Markup.module.css'; import * as ThemeUtils from '@app/features/theme/utils/ThemeUtils'; import TrustedDomain from '@app/features/trusted_domain/state/TrustedDomain'; import {CopyLinkIcon, OpenLinkIcon} from '@app/features/ui/action_menu/ContextMenuIcons'; import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {APP_PROTOCOL_PREFIX, APP_PROTOCOL_SCHEME, isAppProtocolUrl} from '@app/features/ui/utils/AppProtocol'; import {openExternalUrl} from '@app/features/ui/utils/NativeUtils'; import { getUserSettingsSectionLabel, getUserSettingsTabIconDescriptor, getUserSettingsTabLabel, } from '@app/features/user/components/settings_utils/SettingsConstants'; import type {UserSettingsDeepLinkTarget} from '@app/features/user/components/settings_utils/UserSettingsDeepLinks'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import * as StringUtils from '@app/lib/strings'; import {ME} from '@fluxer/constants/src/AppConstants'; import {isProbablyAValidSnowflake} from '@fluxer/snowflake/src/SnowflakeUtils'; import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react'; import {CaretRightIcon, ChatTeardropIcon, LockIcon, WarningIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useState} from 'react'; const LINK_CONTENT_OPTION_OVERRIDES: Partial = {disableEmojiInteractions: true}; const MESSAGE_LINK_DESCRIPTOR = msg({ message: 'message link', comment: 'Type chip suffix in tooltips for inline rendered message links. Lowercase because it follows the channel name.', }); const CHANNEL_LINK_DESCRIPTOR = msg({ message: 'channel link', comment: 'Type chip suffix in tooltips for inline rendered channel mentions. Lowercase because it follows the channel name.', }); const JUMP_TO_THE_MESSAGE_IN_DESCRIPTOR = msg({ message: 'Jump to the message in {labelText}', comment: 'Accessible label for an inline message link that jumps to a specific message in a channel. labelText is the channel display name.', }); const JUMP_TO_THE_LINKED_MESSAGE_DESCRIPTOR = msg({ message: 'Jump to the linked message', comment: 'Fallback accessible label for an inline message link when the target channel is not known.', }); const JUMP_TO_DESCRIPTOR = msg({ message: 'Jump to {labelText}', comment: 'Accessible label for an inline channel mention link. labelText is the channel display name.', }); const JUMP_TO_THE_LINKED_CHANNEL_DESCRIPTOR = msg({ message: 'Jump to the linked channel', comment: 'Fallback accessible label for an inline channel mention when the target name is not known.', }); const OPEN_SETTINGS_SECTION_DESCRIPTOR = msg({ message: 'Open {labelText} in settings', comment: 'Accessible label for an inline settings deep-link pill. labelText is the resolved settings tab and section breadcrumb.', }); const OPEN_APP_PAGE_DESCRIPTOR = msg({ message: 'Open {labelText}', comment: 'Accessible label for an inline chat link pill that opens an app page such as Discovery. labelText is the page name.', }); const PAGE_LINK_DESCRIPTOR = msg({ message: 'page link', comment: 'Role description announced for an inline chat link pill that opens an app page. Lowercase.', }); const SETTINGS_LINK_DESCRIPTOR = msg({ message: 'settings link', comment: 'Type chip suffix in tooltips for inline rendered fluxer://settings/user deep links. Lowercase.', }); const CHANNEL_ACCESS_DENIED_DESCRIPTOR = msg({ message: 'Channel access denied', comment: 'Title of the alert shown when clicking a message link the user does not have permission to view.', }); const YOU_DO_NOT_HAVE_ACCESS_TO_THE_CHANNEL_DESCRIPTOR = msg({ message: 'You do not have access to the channel where this message was sent.', comment: 'Body of the access-denied alert for clicked message links.', }); const NO_ACCESS_DESCRIPTOR = msg({ message: 'No access', comment: 'Inline text shown inside a custom message link pill when the linked message cannot be accessed.', }); const DEVELOPER_TOOLS_DESCRIPTOR = msg({ message: 'Developer tools', comment: 'Title of the info alert shown when clicking an internal developer-tools deep link.', }); const DEVELOPER_TOOLS_ARE_AVAILABLE_FROM_THE_STAFF_BADGE_DESCRIPTOR = msg({ message: 'Developer tools are available from the staff badge in the channel header.', comment: 'Body of the developer-tools info alert. Refers to the staff badge button in the channel header.', }); const LINK_UNAVAILABLE_DESCRIPTOR = msg({ message: 'Link unavailable', comment: 'Title of the alert shown when clicking an internal link that is not available to the current account.', }); const THIS_LINK_IS_NOT_AVAILABLE_FOR_YOUR_ACCOUNT_DESCRIPTOR = msg({ message: 'This link is not available for your account.', comment: 'Body of the link-unavailable alert.', }); const INVITE_LINK_MASKED_DESCRIPTOR = msg({ message: 'Invite link masked while sharing', comment: 'Inline replacement text shown for invite links while streaming privacy is active.', }); const logger = new Logger('LinkRenderer'); function showChannelAccessDeniedModal(i18n: I18n): void { ModalCommands.push( modal(() => ( {}} hideCloseButton data-flx="messaging.markdown.renderers.link-renderer.show-channel-access-denied-modal.confirm-modal" /> )), ); } function getJumpGateGuildId(channel: Channel, scope?: string | null): string | null { if (channel.guildId) return channel.guildId; if (scope && scope !== ME) return scope; return null; } function promptForJumpGate(channel: Channel, scope: string | null | undefined, onConfirm: () => void): boolean { return ChannelGateCommands.promptForChannelGate({ channel, channelId: channel.id, guildId: getJumpGateGuildId(channel, scope), onConfirm, }); } function useJumpLinkAuxClick(url: string, interactive: boolean) { return useCallback( (event: React.MouseEvent) => { if (!interactive) return; if (event.button !== MIDDLE_MOUSE_BUTTON) return; event.preventDefault(); event.stopPropagation(); if (url) void openExternalUrl(url); }, [interactive, url], ); } type CompactGuildMentionIconStyle = React.CSSProperties & { '--jump-link-guild-icon-image'?: string; }; function CompactGuildMentionIcon({guild}: {guild: Pick}) { const iconUrl = guild.icon ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : ''; const style: CompactGuildMentionIconStyle | undefined = iconUrl ? {'--jump-link-guild-icon-image': `url(${iconUrl})`} : undefined; const initials = getGuildIconDisplayInitials(StringUtils.getInitialsFromName(guild.name)); return ( ); } interface JumpLinkMentionProps { channel: Channel; guild: Guild | null; messageId?: string; returnToMessageId?: string; returnChannelId?: string; url: string; i18n: I18n; interactive?: boolean; } interface JumpLinkContextMenuProps { url: string; i18n: I18n; onClose: () => void; } function JumpLinkContextMenu({url, i18n, onClose}: JumpLinkContextMenuProps) { const handleOpenLink = useCallback(() => { void openExternalUrl(url); onClose(); }, [onClose, url]); const handleCopyLink = useCallback(async () => { await TextCopyCommands.copy(i18n, url); onClose(); }, [i18n, onClose, url]); return ( } onClick={handleOpenLink} data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.menu-item.open-link" > {i18n._(OPEN_LINK_DESCRIPTOR)} } onClick={() => void handleCopyLink()} data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.menu-item.copy-link" > {i18n._(COPY_LINK_DESCRIPTOR)} ); } const JumpLinkMention = observer(function JumpLinkMention({ channel, guild, messageId, returnToMessageId, returnChannelId, url, i18n, interactive = true, }: JumpLinkMentionProps) { useLingui(); const navigateToJumpTarget = useCallback(() => { if (LinkChannelCommands.openLinkChannel(channel, {skipGate: true})) { return; } if (messageId) { goToMessage(channel.id, messageId, {returnToMessageId, returnChannelId}); return; } NavigationCommands.selectChannel(channel.guildId ?? undefined, channel.id); }, [channel, messageId, returnChannelId, returnToMessageId]); const handleClick = useCallback( (event: React.MouseEvent) => { if (!interactive) return; event.preventDefault(); event.stopPropagation(); if (promptForJumpGate(channel, guild?.id ?? null, navigateToJumpTarget)) { return; } navigateToJumpTarget(); }, [channel, guild?.id, interactive, navigateToJumpTarget], ); const handleAuxClick = useJumpLinkAuxClick(url, interactive); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (!interactive) return; ContextMenuCommands.openFromEvent(event, ({onClose}) => ( )); }, [interactive, i18n, url], ); const displayName = channel.isPrivate() ? getDMDisplayName(channel) : (channel.name ?? channel.id); const labelText = guild ? guild.name : displayName; const shouldShowChannelInfo = !messageId && Boolean(channel.guildId); const channelDisplayName = channel.name ?? getName(channel); const isDMChannel = channel.isPrivate() && !channel.guildId; const shouldShowDMIconLabel = isDMChannel && !messageId; const hasDetailChunk = Boolean(messageId) || shouldShowChannelInfo; const roleDescription = messageId ? i18n._(MESSAGE_LINK_DESCRIPTOR) : i18n._(CHANNEL_LINK_DESCRIPTOR); const ariaLabel = messageId ? labelText ? i18n._(JUMP_TO_THE_MESSAGE_IN_DESCRIPTOR, {labelText}) : i18n._(JUMP_TO_THE_LINKED_MESSAGE_DESCRIPTOR) : labelText ? i18n._(JUMP_TO_DESCRIPTOR, {labelText}) : i18n._(JUMP_TO_THE_LINKED_CHANNEL_DESCRIPTOR); const Component = interactive ? 'button' : 'span'; return ( {guild ? ( {guild.name} ) : shouldShowDMIconLabel ? ( {getIcon(channel, {className: jumpLinkStyles.icon})} {displayName} ) : ( {displayName} )} {hasDetailChunk && ( <> {messageId ? ( ) : ( shouldShowChannelInfo && ( {getIcon(channel, {className: jumpLinkStyles.icon})} {channelDisplayName} ) )} )} ); }); interface InaccessibleJumpLinkMentionProps { url: string; i18n: I18n; interactive?: boolean; } function InaccessibleJumpLinkMention({url, i18n, interactive = true}: InaccessibleJumpLinkMentionProps) { const handleClick = useCallback( (event: React.MouseEvent) => { if (!interactive) return; event.preventDefault(); event.stopPropagation(); showChannelAccessDeniedModal(i18n); }, [i18n, interactive], ); const handleAuxClick = useJumpLinkAuxClick(url, interactive); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (!interactive) return; ContextMenuCommands.openFromEvent(event, ({onClose}) => ( )); }, [interactive, i18n, url], ); const noAccessText = i18n._(NO_ACCESS_DESCRIPTOR); const Component = interactive ? 'button' : 'span'; return ( {noAccessText} ); } interface DiscoverableJumpLinkMentionProps { guildId: string; channelId: string; messageId?: string; url: string; i18n: I18n; interactive?: boolean; } const DiscoverableJumpLinkMention = observer(function DiscoverableJumpLinkMention({ guildId, channelId, messageId, url, i18n, interactive = true, }: DiscoverableJumpLinkMentionProps) { useLingui(); const [joining, setJoining] = useState(false); useEffect(() => { DiscoveryChannelPreviews.request(guildId, channelId); }, [guildId, channelId]); const handleClick = useCallback( (event: React.MouseEvent) => { if (!interactive) return; event.preventDefault(); event.stopPropagation(); if (joining) return; setJoining(true); void joinDiscoveryGuild(guildId, {channelId, messageId}).finally(() => setJoining(false)); }, [channelId, guildId, interactive, joining, messageId], ); const handleAuxClick = useJumpLinkAuxClick(url, interactive); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (!interactive) return; ContextMenuCommands.openFromEvent(event, ({onClose}) => ( )); }, [interactive, i18n, url], ); const entry = DiscoveryChannelPreviews.get(guildId, channelId); if (entry?.status !== 'ready') { return ( ); } const {guild, channel} = entry.preview; const ariaLabel = messageId ? i18n._(JUMP_TO_THE_MESSAGE_IN_DESCRIPTOR, {labelText: guild.name}) : i18n._(JUMP_TO_DESCRIPTOR, {labelText: guild.name}); const Component = interactive ? 'button' : 'span'; return ( {guild.name} {messageId ? ( ) : ( {getIcon({type: channel.type}, {className: jumpLinkStyles.icon})} {channel.name ?? channel.id} )} ); }); interface SettingsJumpLinkMentionProps { target: UserSettingsDeepLinkTarget; url: string; i18n: I18n; interactive?: boolean; } interface SettingsLinkContextMenuProps { url: string; i18n: I18n; onClose: () => void; } function SettingsLinkContextMenu({url, i18n, onClose}: SettingsLinkContextMenuProps) { const handleCopyLink = useCallback(async () => { await TextCopyCommands.copy(i18n, url); onClose(); }, [i18n, onClose, url]); return ( } onClick={() => void handleCopyLink()} data-flx="messaging.markdown.renderers.link-renderer.settings-link-context-menu.menu-item.copy-link" > {i18n._(COPY_LINK_DESCRIPTOR)} ); } function SettingsJumpLinkMention({target, url, i18n, interactive = true}: SettingsJumpLinkMentionProps) { const handleClick = useCallback( (event: React.MouseEvent) => { if (!interactive) return; event.preventDefault(); event.stopPropagation(); handleDeepLinkUrl(url); }, [interactive, url], ); const handleAuxClick = useJumpLinkAuxClick(url, interactive); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (!interactive) return; ContextMenuCommands.openFromEvent(event, ({onClose}) => ( )); }, [interactive, i18n, url], ); const tabLabel = getUserSettingsTabLabel(i18n, target.tab); const sectionLabel = target.section ? getUserSettingsSectionLabel(i18n, target.section, target.tab) : ''; const tabIcon = getUserSettingsTabIconDescriptor(target.tab); const TabIcon = tabIcon?.icon; const tabIconWeight = tabIcon?.iconWeight ?? 'fill'; const ariaLabelText = sectionLabel ? `${tabLabel} > ${sectionLabel}` : tabLabel; const Component = interactive ? 'button' : 'span'; return ( {TabIcon && ( {sectionLabel && ( <> {sectionLabel} )} ); } interface AppPageJumpLinkMentionProps { page: AppPageId; url: string; i18n: I18n; interactive?: boolean; } function AppPageJumpLinkMention({page, url, i18n, interactive = true}: AppPageJumpLinkMentionProps) { const handleClick = useCallback( (event: React.MouseEvent) => { if (!interactive) return; event.preventDefault(); event.stopPropagation(); navigateToAppPage(page); }, [interactive, page], ); const handleAuxClick = useJumpLinkAuxClick(url, interactive); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (!interactive) return; ContextMenuCommands.openFromEvent(event, ({onClose}) => ( )); }, [interactive, i18n, url], ); const label = getAppPageLabel(i18n, page); const PageIcon = getAppPageIcon(page); const Component = interactive ? 'button' : 'span'; return ( ); } export const LinkRenderer = observer(function LinkRenderer({ node, id, renderChildren, options, }: RendererProps): React.ReactElement { const i18n = options.i18n!; const {url, text} = node; const openInBrowser = useOpenInBrowserOnMiddleClick(url); const content = text ? renderChildren([text], LINK_CONTENT_OPTION_OVERRIDES) : url; const inviteCode = InviteUtils.findInvite(url); const themeCode = ThemeUtils.findTheme(url); const userProfileId = parseUserProfileUrl(url); const messageJumpTarget = parseMessageJumpLink(url); const channelJumpTarget = messageJumpTarget ? null : parseChannelJumpLink(url); const jumpTarget = messageJumpTarget ?? channelJumpTarget; const jumpChannel = jumpTarget ? (Channels.getChannel(jumpTarget.channelId) ?? null) : null; const jumpGuild = jumpChannel?.guildId ? (Guilds.getGuild(jumpChannel.guildId) ?? null) : null; const discoverableGuildId = jumpTarget && !jumpChannel && jumpTarget.scope !== ME && isProbablyAValidSnowflake(jumpTarget.scope) && !Guilds.getGuild(jumpTarget.scope) ? jumpTarget.scope : null; const settingsTarget = isAppProtocolUrl(url) ? parseUserSettingsDeepLink(url) : null; const appPage = settingsTarget ? null : parseAppPageLink(url); const isInlineReplyContext = isRestrictedInlineContext(options.context); const shouldDisableInteractions = options.disableInteractions === true; if (inviteCode && StreamerMode.shouldHideInviteLinks) { return ( {i18n._(INVITE_LINK_MASKED_DESCRIPTOR)} ); } if (settingsTarget && !text) { const mention = ( ); return shouldDisableInteractions || isInlineReplyContext ? ( mention ) : ( {mention} ); } if (appPage && !text) { const mention = ( ); return shouldDisableInteractions || isInlineReplyContext ? ( mention ) : ( {mention} ); } if (jumpTarget && jumpChannel && !text) { const mention = ( ); return shouldDisableInteractions || isInlineReplyContext ? ( mention ) : ( {mention} ); } if (jumpTarget && !jumpChannel && !text) { const interactive = !isInlineReplyContext && !shouldDisableInteractions; const mention = discoverableGuildId ? ( ) : ( ); return shouldDisableInteractions || isInlineReplyContext ? ( mention ) : ( {mention} ); } if (shouldDisableInteractions) { return ( {content} ); } const shouldShowAccessDeniedModal = Boolean(jumpTarget && !jumpChannel); let isInternal = false; let handleClick: ((e: React.MouseEvent) => void) | undefined; let isExternalDestination = false; let isTrustedExternalDestination = false; if (userProfileId) { handleClick = (event) => { event.preventDefault(); event.stopPropagation(); navigateToLinkedUserProfile(userProfileId); }; isInternal = true; } else if (shouldShowAccessDeniedModal) { handleClick = (event) => { event.preventDefault(); event.stopPropagation(); showChannelAccessDeniedModal(i18n); }; isInternal = true; } else if (url === `${APP_PROTOCOL_PREFIX}dev` || url === `${APP_PROTOCOL_SCHEME}dev`) { handleClick = (e) => { e.preventDefault(); if (DeveloperMode.isDeveloper) { ModalCommands.push( modal(() => ( {}} hideCloseButton data-flx="messaging.markdown.renderers.link-renderer.handle-click.confirm-modal--2" /> )), ); } else { ModalCommands.push( modal(() => ( {}} hideCloseButton data-flx="messaging.markdown.renderers.link-renderer.handle-click.confirm-modal--3" /> )), ); } }; isInternal = true; } else if (appPage) { handleClick = (event) => { event.preventDefault(); event.stopPropagation(); navigateToAppPage(appPage); }; isInternal = true; } else if (isAppProtocolUrl(url)) { handleClick = (event) => { event.preventDefault(); event.stopPropagation(); if (handleDeepLinkUrl(url)) return; ModalCommands.push( modal(() => ( {}} hideCloseButton data-flx="messaging.markdown.renderers.link-renderer.handle-click.confirm-modal--4" /> )), ); }; isInternal = true; } else { try { const oauthAuthorizeUrl = parseOAuthAuthorizeModalUrl(url); const parsed = oauthAuthorizeUrl ?? new URL(url); if (oauthAuthorizeUrl) { handleClick = (e) => { e.preventDefault(); e.stopPropagation(); if (!openOAuthAuthorizeModalFromUrl(url)) { void openExternalUrl(url); } }; isInternal = true; } else { isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/'); } if (!handleClick && inviteCode) { handleClick = (e) => { e.preventDefault(); void InviteCommands.openAcceptModal(inviteCode, currentInstanceTarget()); }; isInternal = true; } else if (!handleClick && themeCode) { handleClick = (e) => { e.preventDefault(); ThemeCommands.openAcceptModal(themeCode, i18n, RuntimeConfig.getSnapshot()); }; isInternal = true; } else if (!handleClick && isInternal) { const channelJump = channelJumpTarget; if (messageJumpTarget && jumpChannel) { const targetChannelId = messageJumpTarget.channelId; const targetMessageId = messageJumpTarget.messageId; handleClick = (e) => { e.preventDefault(); const navigateToTarget = () => { if (LinkChannelCommands.openLinkChannel(jumpChannel, {skipGate: true})) { return; } goToMessage(targetChannelId, targetMessageId, { returnToMessageId: options.messageId, returnChannelId: options.channelId, }); }; if (promptForJumpGate(jumpChannel, messageJumpTarget.scope, navigateToTarget)) { return; } navigateToTarget(); }; } else if (channelJump) { handleClick = (e) => { e.preventDefault(); const navigateToTarget = () => { if (jumpChannel && LinkChannelCommands.openLinkChannel(jumpChannel, {skipGate: true})) { return; } NavigationCommands.selectChannel( channelJump.scope === ME ? undefined : channelJump.scope, channelJump.channelId, ); }; if (jumpChannel && promptForJumpGate(jumpChannel, channelJump.scope, navigateToTarget)) { return; } navigateToTarget(); }; } else if (parseChannelUrl(url)) { handleClick = (e) => { e.preventDefault(); NavigationCommands.deselectGuild(); }; } else { isInternal = false; } } if (!isInternal && !inviteCode) { const isTrusted = TrustedDomain.isTrustedDomain(parsed.hostname); isExternalDestination = true; isTrustedExternalDestination = isTrusted; if (!isTrusted) { handleClick = (e) => { e.preventDefault(); ModalCommands.push( modal(() => ( )), ); }; } } } catch (_error) { logger.warn('Invalid URL in link:', url); } } const href = AttachmentUrlRefresher.fresh(url); const warmAttachmentUrl = () => AttachmentUrlRefresher.warm(url); const showDestinationTooltip = isExternalDestination && text != null; const linkElement = ( { e.stopPropagation(); if (handleClick) { handleClick(e); return; } if (!isInternal) { e.preventDefault(); void openExternalUrl(url); } }} onAuxClick={isInternal ? undefined : openInBrowser.onAuxClick} className={markupStyles.link} data-flx="messaging.markdown.renderers.link-renderer.a" > {content} ); if (!showDestinationTooltip) { return linkElement; } const destinationTooltipText = MobileLayout.enabled ? url : () => ( {!isTrustedExternalDestination && ( )} {url} ); return ( {linkElement} ); });