/*
* 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 && (
{messageId ? (
) : (
shouldShowChannelInfo && (
{getIcon(channel, {size: 12})}
{channelDisplayName}
)
)}
)}
);
});
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}
);
});