You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vizality/renderer/src/modules/components/Icon.jsx

364 lines
12 KiB

/* eslint-disable no-unused-vars */
import parseHTML, { attributesToProps, domToReact } from 'html-react-parser';
import * as FontAwesomeIcons from '@fortawesome/free-solid-svg-icons';
import { toKebabCase, toTitleCase } from '@vizality/util/string';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core';
import { Events, Directories } from '@vizality/constants';
import { excludeProperties } from '@vizality/util/object';
import { joinClassNames } from '@vizality/util/dom';
import { getModules } from '@vizality/webpack';
import { readdirSync, readFileSync } from 'fs';
import { error } from '@vizality/util/logger';
import React, { memo, useState } from 'react';
import { sleep } from '@vizality/util/time';
import { Messages } from '@vizality/i18n';
import { join, parse } from 'path';
import { Clickable, Flex, Tooltip as TooltipContainer } from '.';
const iconList = Object
.keys(FontAwesomeIcons)
.filter(key => key !== 'fas' && key !== 'prefix')
.map(icon => FontAwesomeIcons[icon]);
library.add(...iconList);
/**
* @private
*/
const _labels = [ 'Component', 'Icon' ];
const _error = (...message) => error({ labels: _labels, message });
export const Icons = {};
/*
* We're going to process our assets folder SVGs now and turn them into React components.
*/
(async () => {
try {
const dirs = [ 'svgs', 'logos' ];
for (const dirName of dirs) {
const icons = readdirSync(join(Directories.ASSETS, dirName)).map(item => parse(item).name);
for (const name of icons) {
const icon = readFileSync(join(Directories.ASSETS, dirName, `${name}.svg`), { encoding: 'utf8' });
Icons[toKebabCase(name)] = memo(props => parseHTML(icon, {
replace: domNode => {
if (domNode?.attribs && domNode?.name === 'svg') {
const attrs = attributesToProps(domNode.attribs);
return (
<svg {...attrs} {...props}>
{domToReact(domNode?.children)}
</svg>
);
}
}
}));
}
}
/**
* Add the Font Awesome icons to our collection. These will override any of the existing
* icons with the same name.
*/
Object.keys(FontAwesomeIcons)
.filter(key => key !== 'fas' && key !== 'prefix')
.forEach(icon => Icons[FontAwesomeIcons[icon].iconName] = memo(props => (
<FontAwesomeIcon icon={FontAwesomeIcons[icon].iconName} {...props} />
)));
/*
* @note The following is a sort of automated warning system to let us know when Discord
* has added an icon to their batch, so we can be made aware of and add it. This will
* initiate after Vizality's settings are ready so that only Vizality developers get
* alerted about this.
*/
vizality.once(Events.VIZALITY_SETTINGS_READY, async () => {
if (vizality.settings.get('developer')) {
/*
* These are Discord's icons that will crash the appl if attempted to render as a normal icon.
*/
const blacklist = [
'ApplicationPlaceholder',
'DiscordNitro',
'DiscordWordmark',
'Nitro',
'NitroClassic',
'NitroStackedIcon',
'NitroClassicHorizontal',
'NowPlayingMemberMenuItem',
'PremiumGuildSubscriptionLogoCentered',
'Arrow',
'PremiumGuildTier1Simple',
'PremiumGuildTier2Simple',
'PremiumGuildTier3Simple'
];
/*
* These are Discord's inherent icons I have purposely altered or removed for whatever reason.
*/
const knownAlterations = [
'ChannelTextNSFW',
'CopyID',
'EarlyAccess',
'EmojiActivityCategory',
'ExpandIcon',
'FlowerStarIcon',
'Grid',
'GridSmall',
'HelpButton',
'InvertedGIFLabel',
'LeftCaret',
'MegaphoneNSFW',
'MultipleChoice',
'NitroWheel2',
'NitroStackedLeftAlignedIcon',
'NSFWAnnouncementThreadIcon',
'NSFWThreadIcon',
'PlatformSpotify',
'PlatformSteam',
'PlatformTwitch',
'PlatformXbox',
'PlatformBlizzard',
'PlayIcon',
'RightCaret',
'StarBadge',
'Synced',
'TemplateIcon',
'TitleBarClose',
'TitleBarCloseMac',
'TitleBarMaximize',
'TitleBarMaximizeMac',
'TitleBarMinimize',
'TitleBarMinimizeMac',
'TrendingArrow',
'Unsynced',
'UpdateAvailable',
'Upload2',
'SwitchAccountsIcon',
'WalletIcon',
'ThickArrowUp',
'ImagePlaceholderWithPlusIcon',
'HandPointIcon',
'PrivacyAndSafetyShield',
'Home',
'PremiumChannelIcon',
'BoostedGuildTier1Simple',
'BoostedGuildTier2Simple',
'BoostedGuildTier3Simple',
'BoostedGuildTier1',
'HubBadge',
'GuildBoostingLogoCentered',
'DiscordLogoLockup',
'ForumPostLockedIcon',
'ForumPostIcon',
'AllChannelsIcon',
'DoubleStarIcon',
'ForumPostNSFWIcon'
];
const registry = getModules(m => typeof m === 'function' && m.toString()?.indexOf('"currentColor"') !== -1);
const Names = Object.keys(Icons).map(name => toTitleCase(name).replaceAll(' ', ''));
const DiscordIcons = registry?.map(m => m?.displayName);
const missing = DiscordIcons?.filter(icon => !Names?.includes(icon) && !blacklist?.includes(icon) && !knownAlterations?.includes(icon));
const SVG = memo(({ icon }) => {
const Icon = registry.find(m => m?.displayName === icon);
const [ tooltipColor, setTooltipColor ] = useState('black');
const [ tooltipText, setTooltipText ] = useState(icon);
const [ tooltipShow, setTooltipShow ] = useState(false);
const handleCodeCopy = () => {
try {
// Prevent clicking when it's still showing copied
if (tooltipText === Messages?.COPIED) return;
setTooltipText(Messages?.COPIED);
setTooltipColor('green');
setTooltipShow(true);
setTimeout(() => {
setTooltipText(icon);
setTooltipColor('black');
setTooltipShow(false);
}, 1500);
// Make it easy to copy the markup for the icon with just a click
const copy = document.querySelector(`.vz-missing-icon-${icon.toLowerCase()}`)?.outerHTML;
DiscordNative?.clipboard?.copy(copy);
} catch (err) {
return _error(err);
}
};
return (
<div style={{ margin: 5 }}>
<TooltipContainer
className='vz-icon-wrapper'
text={tooltipText}
color={tooltipColor}
forceOpen={tooltipShow}
>
<Icon
className={joinClassNames('vz-icon', `vz-missing-icon-${icon.toLowerCase()}`)}
onClick={handleCodeCopy}
/>
</TooltipContainer>
</div>
);
});
if (missing?.length) {
while (!vizality.manager.builtins.get('notifications')) await sleep(100);
vizality.manager.builtins.get('notifications') && vizality.api.notifications.sendToast({
header: `Found ${missing.length} Missing Icon ${missing.length === 1 ? 'Asset' : 'Assets '}`,
icon: 'uwu',
timeout: false,
content:
<Flex wrap={Flex.Wrap.WRAP} style={ { gap: 5 } }>
{missing.map(icon => <SVG icon={icon} />)}
</Flex>
});
}
}
});
} catch (err) {
return _error(err);
}
})();
export default memo(props => {
let {
name,
icon,
width = '24',
height = '24',
size,
className,
iconClassName,
color = 'currentColor',
tooltip,
tooltipColor = 'primary',
tooltipPosition = 'top',
onClick,
onContextMenu,
svgOnly = false
} = props;
try {
if (!name) {
throw new Error('You must specify a valid name property!');
}
const SVG = icon ? icon : Icons[name] ? Icons[name] : null;
if (!SVG && !icon) {
throw new Error(`"${name}" is not a valid name property.`);
}
if (size) {
width = size;
height = size;
}
const isClickable = Boolean(onClick || onContextMenu);
const exposeProps = excludeProperties(props, 'name', 'icon', 'size', 'width', 'height', 'className', 'iconClassName', 'color', 'tooltip', 'tooltipColor', 'tooltipPosition', 'onClick', 'onContextMenu', 'svgOnly');
const renderIcon = () => {
// !svgOnly
if (!svgOnly) {
// !svgOnly and tooltip
if (tooltip) {
// !svgOnly and tooltip and clickable
if (isClickable) {
return (
<TooltipContainer
text={tooltip}
color={tooltipColor}
position={tooltipPosition}
>
<Clickable
className={joinClassNames(className, 'vz-icon-wrapper')}
onClick={onClick}
onContextMenu={onContextMenu}
>
<SVG
vz-icon={name}
className={joinClassNames(iconClassName, 'vz-icon')}
fill={color}
color={color}
width={width}
height={height}
{...exposeProps}
/>
</Clickable>
</TooltipContainer>
);
}
// !svgOnly and tooltip and !clickable
return (
<TooltipContainer
className={joinClassNames(className, 'vz-icon-wrapper')}
text={tooltip}
color={tooltipColor}
position={tooltipPosition}
>
<SVG
vz-icon={name}
className={joinClassNames(iconClassName, 'vz-icon')}
fill={color}
color={color}
width={width}
height={height}
{...exposeProps}
/>
</TooltipContainer>
);
}
// !svgOnly and !tooltip and clickable
if (isClickable) {
return (
<Clickable
className={joinClassNames(className, 'vz-icon-wrapper')}
onClick={onClick}
onContextMenu={onContextMenu}
>
<SVG
vz-icon={name}
className={joinClassNames(iconClassName, 'vz-icon')}
fill={color}
color={color}
width={width}
height={height}
{...exposeProps}
/>
</Clickable>
);
}
// !svgOnly and !tooltip and !clickable
return (
<div className={joinClassNames(className, 'vz-icon-wrapper')}>
<SVG
vz-icon={name}
className={joinClassNames(iconClassName, 'vz-icon')}
fill={color}
color={color}
width={width}
height={height}
{...exposeProps}
/>
</div>
);
}
// svgOnly
return (
<SVG
vz-icon={name}
className={joinClassNames(className, 'vz-icon')}
fill={color}
color={color}
width={width}
height={height}
{...exposeProps}
/>
);
};
return renderIcon();
} catch (err) {
return _error(err);
}
});