Files
fluxer/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx
T

1201 lines
41 KiB
TypeScript

// 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<MarkdownRenderOptions> = {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(() => (
<ConfirmModal
title={i18n._(CHANNEL_ACCESS_DENIED_DESCRIPTOR)}
description={i18n._(YOU_DO_NOT_HAVE_ACCESS_TO_THE_CHANNEL_DESCRIPTOR)}
primaryText={i18n._(OKAY_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {}}
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<HTMLButtonElement | HTMLSpanElement>) => {
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<Guild, 'id' | 'name' | 'icon'>}) {
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 (
<span
className={clsx(jumpLinkStyles.icon, jumpLinkStyles.guildIcon)}
style={style}
aria-hidden="true"
data-jump-link-guild-icon=""
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon"
>
{!iconUrl && (
<span
className={jumpLinkStyles.guildInitials}
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon.initials"
>
{initials}
</span>
)}
</span>
);
}
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 (
<MenuGroup data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.menu-group">
<MenuItem
icon={
<OpenLinkIcon
size={20}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.open-link-icon"
/>
}
onClick={handleOpenLink}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.menu-item.open-link"
>
{i18n._(OPEN_LINK_DESCRIPTOR)}
</MenuItem>
<MenuItem
icon={
<CopyLinkIcon
size={20}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.copy-link-icon"
/>
}
onClick={() => void handleCopyLink()}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-context-menu.menu-item.copy-link"
>
{i18n._(COPY_LINK_DESCRIPTOR)}
</MenuItem>
</MenuGroup>
);
}
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<HTMLButtonElement | HTMLSpanElement>) => {
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<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<JumpLinkContextMenu
url={url}
i18n={i18n}
onClose={onClose}
data-flx="messaging.markdown.renderers.link-renderer.handle-context-menu.jump-link-context-menu"
/>
));
},
[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 (
<Component
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.component.click"
{...(interactive ? {type: 'button'} : {})}
className={clsx(markupStyles.mention, interactive && markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
onClick={handleClick}
onAuxClick={handleAuxClick}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...(interactive ? {'aria-roledescription': roleDescription} : {})}
tabIndex={interactive ? 0 : -1}
>
{guild ? (
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.part--guild"
>
<CompactGuildMentionIcon
guild={guild}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.compact-guild-mention-icon"
/>
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.guild-name"
>
{guild.name}
</span>
</span>
) : shouldShowDMIconLabel ? (
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.part--dm"
>
{getIcon(channel, {className: jumpLinkStyles.icon})}
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.dm-name"
>
{displayName}
</span>
</span>
) : (
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.label"
>
{displayName}
</span>
)}
{hasDetailChunk && (
<>
<span
className={jumpLinkStyles.divider}
aria-hidden="true"
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.divider"
>
<CaretRightIcon
weight="bold"
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.caret-right-icon"
/>
</span>
{messageId ? (
<ChatTeardropIcon
className={jumpLinkStyles.icon}
weight="fill"
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.chat-teardrop-icon"
/>
) : (
shouldShowChannelInfo && (
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.part--channel"
>
{getIcon(channel, {className: jumpLinkStyles.icon})}
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention.channel-name"
>
{channelDisplayName}
</span>
</span>
)
)}
</>
)}
</Component>
);
});
interface InaccessibleJumpLinkMentionProps {
url: string;
i18n: I18n;
interactive?: boolean;
}
function InaccessibleJumpLinkMention({url, i18n, interactive = true}: InaccessibleJumpLinkMentionProps) {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
event.preventDefault();
event.stopPropagation();
showChannelAccessDeniedModal(i18n);
},
[i18n, interactive],
);
const handleAuxClick = useJumpLinkAuxClick(url, interactive);
const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<JumpLinkContextMenu
url={url}
i18n={i18n}
onClose={onClose}
data-flx="messaging.markdown.renderers.link-renderer.handle-context-menu.jump-link-context-menu--2"
/>
));
},
[interactive, i18n, url],
);
const noAccessText = i18n._(NO_ACCESS_DESCRIPTOR);
const Component = interactive ? 'button' : 'span';
return (
<Component
data-flx="messaging.markdown.renderers.link-renderer.inaccessible-jump-link-mention.component"
{...(interactive ? {type: 'button'} : {})}
className={clsx(markupStyles.mention, interactive && markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
onClick={handleClick}
onAuxClick={handleAuxClick}
onContextMenu={handleContextMenu}
aria-label={noAccessText}
{...(interactive ? {'aria-roledescription': i18n._(MESSAGE_LINK_DESCRIPTOR)} : {})}
tabIndex={interactive ? 0 : -1}
>
<LockIcon
className={jumpLinkStyles.icon}
weight="fill"
data-flx="messaging.markdown.renderers.link-renderer.inaccessible-jump-link-mention.lock-icon"
/>
<span
className={clsx(jumpLinkStyles.name, jumpLinkStyles.noAccess)}
data-flx="messaging.markdown.renderers.link-renderer.inaccessible-jump-link-mention.text"
>
{noAccessText}
</span>
</Component>
);
}
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<HTMLButtonElement | HTMLSpanElement>) => {
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<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<JumpLinkContextMenu
url={url}
i18n={i18n}
onClose={onClose}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.context-menu"
/>
));
},
[interactive, i18n, url],
);
const entry = DiscoveryChannelPreviews.get(guildId, channelId);
if (entry?.status !== 'ready') {
return (
<InaccessibleJumpLinkMention
url={url}
i18n={i18n}
interactive={interactive}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.inaccessible"
/>
);
}
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 (
<Component
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.component.click"
{...(interactive ? {type: 'button'} : {})}
className={clsx(markupStyles.mention, interactive && markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
onClick={handleClick}
onAuxClick={handleAuxClick}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
aria-busy={joining || undefined}
{...(interactive
? {'aria-roledescription': i18n._(messageId ? MESSAGE_LINK_DESCRIPTOR : CHANNEL_LINK_DESCRIPTOR)}
: {})}
tabIndex={interactive ? 0 : -1}
>
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.part--guild"
>
<CompactGuildMentionIcon
guild={guild}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.guild-icon"
/>
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.guild-name"
>
{guild.name}
</span>
</span>
<span
className={jumpLinkStyles.divider}
aria-hidden="true"
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.divider"
>
<CaretRightIcon
weight="bold"
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.caret-right-icon"
/>
</span>
{messageId ? (
<ChatTeardropIcon
className={jumpLinkStyles.icon}
weight="fill"
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.chat-teardrop-icon"
/>
) : (
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.part--channel"
>
{getIcon({type: channel.type}, {className: jumpLinkStyles.icon})}
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.channel-name"
>
{channel.name ?? channel.id}
</span>
</span>
)}
</Component>
);
});
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 (
<MenuGroup data-flx="messaging.markdown.renderers.link-renderer.settings-link-context-menu.menu-group">
<MenuItem
icon={
<CopyLinkIcon
size={20}
data-flx="messaging.markdown.renderers.link-renderer.settings-link-context-menu.copy-link-icon"
/>
}
onClick={() => void handleCopyLink()}
data-flx="messaging.markdown.renderers.link-renderer.settings-link-context-menu.menu-item.copy-link"
>
{i18n._(COPY_LINK_DESCRIPTOR)}
</MenuItem>
</MenuGroup>
);
}
function SettingsJumpLinkMention({target, url, i18n, interactive = true}: SettingsJumpLinkMentionProps) {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
event.preventDefault();
event.stopPropagation();
handleDeepLinkUrl(url);
},
[interactive, url],
);
const handleAuxClick = useJumpLinkAuxClick(url, interactive);
const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<SettingsLinkContextMenu
url={url}
i18n={i18n}
onClose={onClose}
data-flx="messaging.markdown.renderers.link-renderer.handle-context-menu.settings-link-context-menu"
/>
));
},
[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 (
<Component
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.component.click"
{...(interactive ? {type: 'button'} : {})}
className={clsx(markupStyles.mention, interactive && markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
onClick={handleClick}
onAuxClick={handleAuxClick}
onContextMenu={handleContextMenu}
aria-label={i18n._(OPEN_SETTINGS_SECTION_DESCRIPTOR, {labelText: ariaLabelText})}
{...(interactive ? {'aria-roledescription': i18n._(SETTINGS_LINK_DESCRIPTOR)} : {})}
tabIndex={interactive ? 0 : -1}
>
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.part--tab"
>
{TabIcon && (
<TabIcon
className={jumpLinkStyles.icon}
weight={tabIconWeight}
aria-hidden="true"
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.tab-icon"
/>
)}
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.tab-name"
>
{tabLabel}
</span>
</span>
{sectionLabel && (
<>
<span
className={jumpLinkStyles.divider}
aria-hidden="true"
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.divider"
>
<CaretRightIcon
weight="bold"
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.caret-right-icon"
/>
</span>
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention.section-name"
>
{sectionLabel}
</span>
</>
)}
</Component>
);
}
interface AppPageJumpLinkMentionProps {
page: AppPageId;
url: string;
i18n: I18n;
interactive?: boolean;
}
function AppPageJumpLinkMention({page, url, i18n, interactive = true}: AppPageJumpLinkMentionProps) {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
event.preventDefault();
event.stopPropagation();
navigateToAppPage(page);
},
[interactive, page],
);
const handleAuxClick = useJumpLinkAuxClick(url, interactive);
const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
if (!interactive) return;
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<SettingsLinkContextMenu
url={url}
i18n={i18n}
onClose={onClose}
data-flx="messaging.markdown.renderers.link-renderer.app-page-jump-link-mention.context-menu"
/>
));
},
[interactive, i18n, url],
);
const label = getAppPageLabel(i18n, page);
const PageIcon = getAppPageIcon(page);
const Component = interactive ? 'button' : 'span';
return (
<Component
data-flx="messaging.markdown.renderers.link-renderer.app-page-jump-link-mention.component.click"
{...(interactive ? {type: 'button'} : {})}
className={clsx(markupStyles.mention, interactive && markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
onClick={handleClick}
onAuxClick={handleAuxClick}
onContextMenu={handleContextMenu}
aria-label={i18n._(OPEN_APP_PAGE_DESCRIPTOR, {labelText: label})}
{...(interactive ? {'aria-roledescription': i18n._(PAGE_LINK_DESCRIPTOR)} : {})}
tabIndex={interactive ? 0 : -1}
>
<span
className={jumpLinkStyles.part}
data-flx="messaging.markdown.renderers.link-renderer.app-page-jump-link-mention.part"
>
<PageIcon
className={jumpLinkStyles.icon}
weight="fill"
aria-hidden="true"
data-flx="messaging.markdown.renderers.link-renderer.app-page-jump-link-mention.icon"
/>
<span
className={jumpLinkStyles.name}
data-flx="messaging.markdown.renderers.link-renderer.app-page-jump-link-mention.name"
>
{label}
</span>
</span>
</Component>
);
}
export const LinkRenderer = observer(function LinkRenderer({
node,
id,
renderChildren,
options,
}: RendererProps<LinkNode>): 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 (
<span key={id} className={markupStyles.link} data-flx="messaging.markdown.renderers.link-renderer.invite-masked">
{i18n._(INVITE_LINK_MASKED_DESCRIPTOR)}
</span>
);
}
if (settingsTarget && !text) {
const mention = (
<SettingsJumpLinkMention
target={settingsTarget}
url={url}
i18n={i18n}
interactive={!isInlineReplyContext && !shouldDisableInteractions}
data-flx="messaging.markdown.renderers.link-renderer.settings-jump-link-mention"
/>
);
return shouldDisableInteractions || isInlineReplyContext ? (
mention
) : (
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--settings">
{mention}
</FocusRing>
);
}
if (appPage && !text) {
const mention = (
<AppPageJumpLinkMention
page={appPage}
url={url}
i18n={i18n}
interactive={!isInlineReplyContext && !shouldDisableInteractions}
data-flx="messaging.markdown.renderers.link-renderer.app-page-jump-link-mention"
/>
);
return shouldDisableInteractions || isInlineReplyContext ? (
mention
) : (
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--app-page">
{mention}
</FocusRing>
);
}
if (jumpTarget && jumpChannel && !text) {
const mention = (
<JumpLinkMention
channel={jumpChannel}
guild={jumpGuild}
messageId={messageJumpTarget?.messageId}
returnToMessageId={options.messageId}
returnChannelId={options.channelId}
url={url}
i18n={i18n}
interactive={!isInlineReplyContext && !shouldDisableInteractions}
data-flx="messaging.markdown.renderers.link-renderer.jump-link-mention"
/>
);
return shouldDisableInteractions || isInlineReplyContext ? (
mention
) : (
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring">
{mention}
</FocusRing>
);
}
if (jumpTarget && !jumpChannel && !text) {
const interactive = !isInlineReplyContext && !shouldDisableInteractions;
const mention = discoverableGuildId ? (
<DiscoverableJumpLinkMention
guildId={discoverableGuildId}
channelId={jumpTarget.channelId}
messageId={messageJumpTarget?.messageId}
url={url}
i18n={i18n}
interactive={interactive}
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention"
/>
) : (
<InaccessibleJumpLinkMention
url={url}
i18n={i18n}
interactive={interactive}
data-flx="messaging.markdown.renderers.link-renderer.inaccessible-jump-link-mention"
/>
);
return shouldDisableInteractions || isInlineReplyContext ? (
mention
) : (
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--inaccessible">
{mention}
</FocusRing>
);
}
if (shouldDisableInteractions) {
return (
<span key={id} className={markupStyles.link} data-flx="messaging.markdown.renderers.link-renderer.span">
{content}
</span>
);
}
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(() => (
<ConfirmModal
title={i18n._(DEVELOPER_TOOLS_DESCRIPTOR)}
description={i18n._(DEVELOPER_TOOLS_ARE_AVAILABLE_FROM_THE_STAFF_BADGE_DESCRIPTOR)}
primaryText={i18n._(OKAY_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {}}
hideCloseButton
data-flx="messaging.markdown.renderers.link-renderer.handle-click.confirm-modal--2"
/>
)),
);
} else {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(LINK_UNAVAILABLE_DESCRIPTOR)}
description={i18n._(THIS_LINK_IS_NOT_AVAILABLE_FOR_YOUR_ACCOUNT_DESCRIPTOR)}
primaryText={i18n._(OKAY_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {}}
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(() => (
<ConfirmModal
title={i18n._(LINK_UNAVAILABLE_DESCRIPTOR)}
description={i18n._(THIS_LINK_IS_NOT_AVAILABLE_FOR_YOUR_ACCOUNT_DESCRIPTOR)}
primaryText={i18n._(OKAY_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {}}
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(() => (
<ExternalLinkWarningModal
url={url}
data-flx="messaging.markdown.renderers.link-renderer.handle-click.external-link-warning-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 = (
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--2">
<a
href={href}
target={isInternal ? undefined : '_blank'}
rel={isInternal ? undefined : 'noopener noreferrer'}
onPointerEnter={warmAttachmentUrl}
onFocus={warmAttachmentUrl}
onClick={(e) => {
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}
</a>
</FocusRing>
);
if (!showDestinationTooltip) {
return linkElement;
}
const destinationTooltipText = MobileLayout.enabled
? url
: () => (
<span className={linkRendererStyles.destination}>
{!isTrustedExternalDestination && (
<WarningIcon size={16} weight="fill" className={linkRendererStyles.destinationWarningIcon} />
)}
<span>{url}</span>
</span>
);
return (
<Tooltip text={destinationTooltipText} type="normal" position="bottom" maxWidth="xl">
{linkElement}
</Tooltip>
);
});