Files
fx-test/fluxer_app/src/lib/markdown/renderers/mention-renderer.tsx
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

285 lines
8.0 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {msg} from '@lingui/core/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ChannelTypes, Permissions} from '~/Constants';
import {GenericErrorModal} from '~/components/alerts/GenericErrorModal';
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
import {ChannelContextMenu} from '~/components/uikit/ContextMenu/ChannelContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Routes} from '~/Routes';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import UserStore from '~/stores/UserStore';
import markupStyles from '~/styles/Markup.module.css';
import mentionRendererStyles from '~/styles/MentionRenderer.module.css';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as ColorUtils from '~/utils/ColorUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import {GuildNavKind, MentionKind} from '../parser/types/enums';
import type {MentionNode} from '../parser/types/nodes';
import type {RendererProps} from '.';
interface UserInfo {
userId: string | null;
name: string | null;
}
function getUserInfo(userId: string, channelId?: string): UserInfo {
if (!userId) {
return {userId: null, name: null};
}
const user = UserStore.getUser(userId);
if (!user) {
return {userId, name: userId};
}
let name = user.displayName;
if (channelId) {
const channel = ChannelStore.getChannel(channelId);
if (channel?.guildId) {
name = NicknameUtils.getNickname(user, channel.guildId) || name;
}
}
return {userId: user.id, name};
}
export const MentionRenderer = observer(function MentionRenderer({
node,
id,
options,
}: RendererProps<MentionNode>): React.ReactElement {
const {kind} = node;
const {channelId} = options;
const i18n = options.i18n!;
switch (kind.kind) {
case MentionKind.User: {
const {userId, name} = getUserInfo(kind.id, channelId);
const genericMention = (
<span key={id} className={markupStyles.mention}>
@{name || kind.id}
</span>
);
if (!userId) {
return genericMention;
}
const user = UserStore.getUser(userId);
if (!user) {
return genericMention;
}
const channel = channelId ? ChannelStore.getChannel(channelId) : undefined;
const guildId = channel?.guildId || '';
return (
<PreloadableUserPopout key={id} user={user} isWebhook={false} guildId={guildId} position="right-start">
<FocusRing>
<span
role="button"
tabIndex={0}
className={clsx(markupStyles.mention, markupStyles.interactive)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
>
@{name || user.displayName}
</span>
</FocusRing>
</PreloadableUserPopout>
);
}
case MentionKind.Role: {
const selectedGuildId = SelectedGuildStore.selectedGuildId;
const guild = selectedGuildId != null ? GuildStore.getGuild(selectedGuildId) : null;
const role = guild?.roles[kind.id];
if (!role) {
return (
<span key={id} className={markupStyles.mention}>
@{i18n._(msg`Unknown Role`)}
</span>
);
}
const roleColor = role.color ? ColorUtils.int2rgb(role.color) : undefined;
const roleBgColor = role.color ? ColorUtils.int2rgba(role.color, 0.1) : undefined;
const roleBgHoverColor = role.color ? ColorUtils.int2rgba(role.color, 0.2) : undefined;
const style = {
color: roleColor,
backgroundColor: roleBgColor,
transition: 'background-color var(--transition-fast)',
'--hover-bg': roleBgHoverColor,
} as React.CSSProperties;
return (
<span key={id} className={markupStyles.mention} style={style}>
@{role.name}
</span>
);
}
case MentionKind.Channel: {
const unknownMention = (
<span key={id} className={markupStyles.mention}>
{ChannelUtils.getIcon({type: ChannelTypes.GUILD_TEXT}, {className: mentionRendererStyles.channelIcon})}
{i18n._(msg`unknown-channel`)}
</span>
);
const channel = ChannelStore.getChannel(kind.id);
if (!channel) {
return unknownMention;
}
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
return <span key={id}>#{channel.name}</span>;
}
if (
channel.type !== ChannelTypes.GUILD_TEXT &&
channel.type !== ChannelTypes.GUILD_VOICE &&
channel.type !== ChannelTypes.GUILD_LINK
) {
return unknownMention;
}
return (
<FocusRing key={id}>
<button
className={clsx(markupStyles.mention, markupStyles.interactive)}
onClick={(e) => {
e.stopPropagation();
if (channel.type === ChannelTypes.GUILD_VOICE) {
const canConnect = PermissionStore.can(Permissions.CONNECT, channel);
if (!canConnect) {
ModalActionCreators.push(
modal(() => (
<GenericErrorModal
title={i18n._(msg`Missing Permissions`)}
message={i18n._(msg`You don't have permission to connect to this voice channel.`)}
/>
)),
);
return;
}
}
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId!, channel.id));
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<ChannelContextMenu channel={channel} onClose={onClose} />
));
}}
type="button"
>
{ChannelUtils.getIcon(channel, {className: mentionRendererStyles.channelIcon})}
{channel.name}
</button>
</FocusRing>
);
}
case MentionKind.Everyone: {
return (
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.everyoneMention)}>
@everyone
</span>
);
}
case MentionKind.Here: {
return (
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.hereMention)}>
@here
</span>
);
}
case MentionKind.Command: {
const {name} = kind;
return (
<span key={id} className={markupStyles.mention}>
{name}
</span>
);
}
case MentionKind.GuildNavigation: {
const {navigationType} = kind;
let content: string;
switch (navigationType) {
case GuildNavKind.Customize:
content = '<id:customize>';
break;
case GuildNavKind.Browse:
content = '<id:browse>';
break;
case GuildNavKind.Guide:
content = '<id:guide>';
break;
case GuildNavKind.LinkedRoles: {
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
content = linkedRolesId ? `<id:linked-roles:${linkedRolesId}>` : '<id:linked-roles>';
break;
}
default:
content = `<id:${navigationType}>`;
break;
}
return (
<span key={id} className={markupStyles.mention}>
{content}
</span>
);
}
default:
return <span key={id}>{'<unknown-mention>'}</span>;
}
});