/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {CaretRightIcon, ChatTeardropIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as InviteActionCreators from '~/actions/InviteActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; import {modal} from '~/actions/ModalActionCreators'; import * as ThemeActionCreators from '~/actions/ThemeActionCreators'; import {ConfirmModal} from '~/components/modals/ConfirmModal'; import {ExternalLinkWarningModal} from '~/components/modals/ExternalLinkWarningModal'; import {UserSettingsModal} from '~/components/modals/UserSettingsModal'; import {GuildIcon} from '~/components/popouts/GuildIcon'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {Routes} from '~/Routes'; import type {ChannelRecord} from '~/records/ChannelRecord'; import type {GuildRecord} from '~/records/GuildRecord'; import ChannelStore from '~/stores/ChannelStore'; import DeveloperModeStore from '~/stores/DeveloperModeStore'; import GuildStore from '~/stores/GuildStore'; import TrustedDomainStore from '~/stores/TrustedDomainStore'; import markupStyles from '~/styles/Markup.module.css'; import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol'; import {getDMDisplayName, getIcon, getName} from '~/utils/ChannelUtils'; import { isInternalChannelHost, parseChannelJumpLink, parseChannelUrl, parseMessageJumpLink, } from '~/utils/DeepLinkUtils'; import * as InviteUtils from '~/utils/InviteUtils'; import {goToMessage} from '~/utils/MessageNavigator'; import {openExternalUrl} from '~/utils/NativeUtils'; import * as RouterUtils from '~/utils/RouterUtils'; import * as ThemeUtils from '~/utils/ThemeUtils'; import type {LinkNode} from '../parser/types/nodes'; import type {RendererProps} from '.'; import jumpLinkStyles from './MessageJumpLink.module.css'; interface JumpLinkMentionProps { channel: ChannelRecord; guild: GuildRecord | null; messageId?: string; i18n: I18n; interactive?: boolean; } const INLINE_REPLY_CONTEXT = 1; const JumpLinkMention = observer(function JumpLinkMention({ channel, guild, messageId, i18n, interactive = true, }: JumpLinkMentionProps) { const handleClick = React.useCallback( (event: React.MouseEvent) => { if (!interactive) return; event.preventDefault(); event.stopPropagation(); if (messageId) { goToMessage(channel.id, messageId); return; } const channelPath = channel.guildId ? Routes.guildChannel(channel.guildId, channel.id) : Routes.dmChannel(channel.id); RouterUtils.transitionTo(channelPath); }, [channel.id, channel.guildId, messageId], ); 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 ariaLabel = messageId ? labelText ? i18n._(msg`Jump to the message in ${labelText}`) : i18n._(msg`Jump to the linked message`) : labelText ? i18n._(msg`Jump to ${labelText}`) : i18n._(msg`Jump to the linked channel`); const Component = interactive ? 'button' : 'span'; return ( {guild ? ( {guild.name} ) : shouldShowDMIconLabel ? ( {getIcon(channel, {size: 12})} {displayName} ) : ( {displayName} )} {hasDetailChunk && ( )} ); }); export const LinkRenderer = observer(function LinkRenderer({ node, id, renderChildren, options, }: RendererProps): React.ReactElement { const i18n = options.i18n!; const {url, text} = node; const inviteCode = InviteUtils.findInvite(url); const themeCode = ThemeUtils.findTheme(url); const messageJumpTarget = parseMessageJumpLink(url); const jumpTarget = messageJumpTarget ?? parseChannelJumpLink(url); const jumpChannel = jumpTarget ? (ChannelStore.getChannel(jumpTarget.channelId) ?? null) : null; const jumpGuild = jumpChannel?.guildId ? (GuildStore.getGuild(jumpChannel.guildId) ?? null) : null; const isInlineReplyContext = options.context === INLINE_REPLY_CONTEXT; if (jumpTarget && jumpChannel) { const mention = ( ); return isInlineReplyContext ? mention : {mention}; } const shouldShowAccessDeniedModal = Boolean(jumpTarget && !jumpChannel); let isInternal = false; let handleClick: ((e: React.MouseEvent) => void) | undefined; if (shouldShowAccessDeniedModal) { handleClick = (event) => { event.preventDefault(); event.stopPropagation(); ModalActionCreators.push( modal(() => ( {}} /> )), ); }; isInternal = true; } else if (url === `${APP_PROTOCOL_PREFIX}dev`) { handleClick = (e) => { e.preventDefault(); if (DeveloperModeStore.isDeveloper) { ModalActionCreators.push(modal(() => )); } else { ModalActionCreators.push( modal(() => ( {}} /> )), ); } }; isInternal = true; } else { try { const parsed = new URL(url); isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/'); if (inviteCode) { handleClick = (e) => { e.preventDefault(); InviteActionCreators.acceptAndTransitionToChannel(inviteCode, i18n); }; } else if (themeCode) { handleClick = (e) => { e.preventDefault(); ThemeActionCreators.openAcceptModal(themeCode, i18n); }; isInternal = true; } else if (isInternal) { const channelPath = parseChannelUrl(url); if (channelPath) { handleClick = (e) => { e.preventDefault(); RouterUtils.transitionTo(channelPath); }; } else { isInternal = false; } } if (!isInternal && !inviteCode) { const isTrusted = TrustedDomainStore.isTrustedDomain(parsed.hostname); if (!isTrusted) { handleClick = (e) => { e.preventDefault(); ModalActionCreators.push(modal(() => )); }; } } } catch (_error) { console.warn('Invalid URL in link:', url); } } const content = text ? renderChildren([text]) : url; return ( { e.stopPropagation(); if (handleClick) { handleClick(e); return; } if (!isInternal) { e.preventDefault(); void openExternalUrl(url); } }} className={markupStyles.link} > {content} ); });