@ -23,11 +23,11 @@ const GREENTEXT_CLASS = 'dark:text-accent-green text-lime-600';
|
||||
const nodesToText = (nodes: Array<DOMNode>): string =>
|
||||
nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array<DOMNode>) : '').join('');
|
||||
|
||||
const isHostNotVisible = (href: string, nodes: Array<DOMNode>): false | string => {
|
||||
const isHostNotVisible = (href: string, text?: string): false | string => {
|
||||
try {
|
||||
let { host } = new URL(href);
|
||||
host = host.replace(/^www\./, '');
|
||||
const text = nodesToText(nodes).trim();
|
||||
if (!text) return host;
|
||||
|
||||
if (new RegExp(`^(https?://)?(www.)?${host}(/|$)`, 'i').test(text)) {
|
||||
return false;
|
||||
@ -39,6 +39,55 @@ const isHostNotVisible = (href: string, nodes: Array<DOMNode>): false | string =
|
||||
}
|
||||
};
|
||||
|
||||
interface IParsedUrl extends React.HTMLAttributes<HTMLAnchorElement> {
|
||||
/** Whether to call a function to remove tracking parameters from URLs. */
|
||||
cleanUrls?: boolean;
|
||||
/** Whether to call a function to redirect URLs to popular websites to privacy-respecting proxy services. */
|
||||
redirectUrls?: boolean;
|
||||
/** Whether to display link target domain when it's not part of the text. */
|
||||
displayTargetHost?: boolean;
|
||||
href: string;
|
||||
childrenPlain?: string;
|
||||
}
|
||||
|
||||
const ParsedUrl: React.FC<IParsedUrl> = React.memo((props) => {
|
||||
const { urlPrivacy } = useSettings();
|
||||
|
||||
props = { ...props };
|
||||
|
||||
if (props.cleanUrls === undefined) props.cleanUrls = urlPrivacy.clearLinksInContent;
|
||||
if (props.redirectUrls === undefined) props.redirectUrls = urlPrivacy.redirectLinksMode !== 'off';
|
||||
if (props.displayTargetHost === undefined) props.displayTargetHost = urlPrivacy.displayTargetHost;
|
||||
|
||||
let href = props.href;
|
||||
|
||||
if (props.cleanUrls) {
|
||||
try {
|
||||
href = Purify.clearUrl(href, props.cleanUrls, props.redirectUrls);
|
||||
} catch (_) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const host = props.displayTargetHost && isHostNotVisible(href, props.childrenPlain);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel='nofollow noopener noreferrer'
|
||||
target='_blank'
|
||||
title={props.href}
|
||||
>
|
||||
{props.children}
|
||||
{host && (
|
||||
<span className='text-xs lowercase'>{' '}[{host}]</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
interface IParsedContent {
|
||||
/** HTML content to display. */
|
||||
html: string;
|
||||
@ -177,34 +226,39 @@ function parseContent({
|
||||
domNode.greentext = true;
|
||||
}
|
||||
|
||||
let href = domNode.attribs.href;
|
||||
// let href = domNode.attribs.href;
|
||||
|
||||
if (cleanUrls) {
|
||||
try {
|
||||
href = Purify.clearUrl(href, cleanUrls, redirectUrls);
|
||||
} catch (_) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// if (cleanUrls) {
|
||||
// try {
|
||||
// href = Purify.clearUrl(href, cleanUrls, redirectUrls);
|
||||
// } catch (_) {
|
||||
// //
|
||||
// }
|
||||
// }
|
||||
|
||||
const host = displayTargetHost && isHostNotVisible(href, domNode.children as Array<DOMNode>);
|
||||
// const host = displayTargetHost && isHostNotVisible(href, domNode.children as Array<DOMNode>);
|
||||
|
||||
const fallback = (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<a
|
||||
{...domNode.attribs}
|
||||
href={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel='nofollow noopener noreferrer'
|
||||
target='_blank'
|
||||
title={domNode.attribs.href}
|
||||
>
|
||||
<ParsedUrl {...domNode.attribs as any} childrenPlain={nodesToText(domNode.children as Array<DOMNode>).trim()}>
|
||||
{domToReact(domNode.children as Array<DOMNode>, options)}
|
||||
{host && (
|
||||
<span className='text-xs lowercase'>{' '}[{host}]</span>
|
||||
)}
|
||||
</a>
|
||||
</ParsedUrl>
|
||||
);
|
||||
// (
|
||||
// // eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
// <a
|
||||
// {...domNode.attribs}
|
||||
// href={href}
|
||||
// onClick={(e) => e.stopPropagation()}
|
||||
// rel='nofollow noopener noreferrer'
|
||||
// target='_blank'
|
||||
// title={domNode.attribs.href}
|
||||
// >
|
||||
// {domToReact(domNode.children as Array<DOMNode>, options)}
|
||||
// {host && (
|
||||
// <span className='text-xs lowercase'>{' '}[{host}]</span>
|
||||
// )}
|
||||
// </a>
|
||||
// );
|
||||
|
||||
if (classes?.includes('mention')) {
|
||||
if (mentions) {
|
||||
@ -291,4 +345,4 @@ const ParsedContent: React.FC<IParsedContent> = React.memo((props) => {
|
||||
return parseContent(props, false);
|
||||
}, (prevProps, nextProps) => prevProps.html === nextProps.html);
|
||||
|
||||
export { ParsedContent, parseContent };
|
||||
export { ParsedContent, ParsedUrl, parseContent };
|
||||
|
||||
524
packages/pl-fe/src/components/parsed-mfm.tsx
Normal file
524
packages/pl-fe/src/components/parsed-mfm.tsx
Normal file
@ -0,0 +1,524 @@
|
||||
// ~~Shamelessly stolen~~ ported to React from Sharkey
|
||||
// https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/packages/frontend/src/components/global/MkMfm.ts
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import { clamp } from 'lodash';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useSettings } from 'pl-fe/hooks/use-settings';
|
||||
import { makeEmojiMap } from 'pl-fe/utils/normalizers';
|
||||
import nyaize from 'pl-fe/utils/nyaize';
|
||||
|
||||
import HashtagLink from './hashtag-link';
|
||||
import HoverAccountWrapper from './hover-account-wrapper';
|
||||
import { ParsedUrl } from './parsed-content';
|
||||
import Emoji from './ui/emoji';
|
||||
|
||||
import type { CustomEmoji, Mention } from 'pl-api';
|
||||
|
||||
const safeParseFloat = (str: unknown): number | null => {
|
||||
if (typeof str !== 'string' || str === '') return null;
|
||||
const num = parseFloat(str);
|
||||
if (isNaN(num)) return null;
|
||||
return num;
|
||||
};
|
||||
|
||||
const validTime = (t: string | boolean | null | undefined) => {
|
||||
if (t === null || t === undefined) return null;
|
||||
if (typeof t === 'boolean') return null;
|
||||
return t.match(/^\-?[0-9.]+s$/) ? t : null;
|
||||
};
|
||||
|
||||
const validColor = (c: unknown): string | null => {
|
||||
if (typeof c !== 'string') return null;
|
||||
return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
|
||||
};
|
||||
|
||||
interface IParsedMfm {
|
||||
text: string;
|
||||
emojis: Array<CustomEmoji>;
|
||||
mentions?: Array<Mention>;
|
||||
speakAsCat?: boolean;
|
||||
}
|
||||
|
||||
const ParsedMfm: React.FC<IParsedMfm> = React.memo(({ text, emojis, mentions, speakAsCat }) => {
|
||||
const rootAst = mfm.parse(text);
|
||||
const { renderAdvancedMfm, renderAnimatedMfm } = useSettings();
|
||||
|
||||
const emojiMap = makeEmojiMap(emojis);
|
||||
|
||||
const genPlainText = (ast: mfm.MfmNode[]) => ast.map((token): string => {
|
||||
if (token.type === 'text') return token.props.text;
|
||||
if (token.children) return genPlainText(token.children);
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): JSX.Element | string | (JSX.Element | string)[] => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
|
||||
if (speakAsCat) text = nyaize(text);
|
||||
|
||||
const res: (JSX.Element | string)[] = [];
|
||||
for (const t of text.split('\n')) {
|
||||
res.push(<br />);
|
||||
res.push(t);
|
||||
}
|
||||
res.shift();
|
||||
return res;
|
||||
}
|
||||
|
||||
case 'bold': {
|
||||
return (
|
||||
<b>{genEl(token.children, scale)}</b>
|
||||
);
|
||||
}
|
||||
|
||||
case 'strike': {
|
||||
return (
|
||||
<del>{genEl(token.children, scale)}</del>
|
||||
);
|
||||
}
|
||||
|
||||
case 'italic': {
|
||||
return (
|
||||
<i className='italic'>{genEl(token.children, scale)}</i>
|
||||
);
|
||||
}
|
||||
|
||||
case 'fn': {
|
||||
let style: CSSProperties | undefined;
|
||||
switch (token.props.name) {
|
||||
case 'tada': {
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = {
|
||||
fontSize: '150%',
|
||||
...renderAnimatedMfm ? { animation: `global-tada ${speed} linear infinite both`, animationDelay: delay } : {},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'jelly': {
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
if (renderAnimatedMfm) style = {
|
||||
animation: `mfm-rubber-band ${speed} linear infinite both`,
|
||||
animationDelay: delay,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'twitch': {
|
||||
const speed = validTime(token.props.args.speed) ?? '0.5s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = renderAnimatedMfm ? {
|
||||
animation: `mfm-twitch ${speed} ease infinite`,
|
||||
animationDelay: delay,
|
||||
} : {};
|
||||
break;
|
||||
}
|
||||
case 'shake': {
|
||||
const speed = validTime(token.props.args.speed) ?? '0.5s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = renderAnimatedMfm ? {
|
||||
animation: `mfm-shake ${speed} ease infinite`,
|
||||
animationDelay: delay,
|
||||
} : {};
|
||||
break;
|
||||
}
|
||||
case 'spin': {
|
||||
const direction =
|
||||
token.props.args.left ? 'reverse' :
|
||||
token.props.args.alternate ? 'alternate' :
|
||||
'normal';
|
||||
const anime =
|
||||
token.props.args.x ? 'mfm-spin-x' :
|
||||
token.props.args.y ? 'mfm-spin-y' :
|
||||
'mfm-spin';
|
||||
const speed = validTime(token.props.args.speed) ?? '1.5s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = renderAnimatedMfm ? { animation: `${anime} ${speed} linear infinite`, animationDirection: direction, animationDelay: delay } : {};
|
||||
break;
|
||||
}
|
||||
case 'jump': {
|
||||
const speed = validTime(token.props.args.speed) ?? '0.75s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = renderAnimatedMfm ? { animation: `mfm-jump ${speed} linear infinite`, animationDelay: delay } : {};
|
||||
break;
|
||||
}
|
||||
case 'bounce': {
|
||||
const speed = validTime(token.props.args.speed) ?? '0.75s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = renderAnimatedMfm ? { animation: `mfm-bounce ${speed} linear infinite`, transformOrigin: 'center bottom', animationDelay: delay } : {};
|
||||
break;
|
||||
}
|
||||
case 'flip': {
|
||||
const transform =
|
||||
(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
|
||||
token.props.args.v ? 'scaleY(-1)' :
|
||||
'scaleX(-1)';
|
||||
style = { transform };
|
||||
break;
|
||||
}
|
||||
case 'x2': {
|
||||
return (
|
||||
<span className={renderAdvancedMfm ? 'mfm-x2' : ''}>
|
||||
{genEl(token.children, scale * 2)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'x3': {
|
||||
return (
|
||||
<span className={renderAdvancedMfm ? 'mfm-x3' : ''}>
|
||||
{genEl(token.children, scale * 3)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'x4': {
|
||||
return (
|
||||
<span className={renderAdvancedMfm ? 'mfm-x4' : ''}>
|
||||
{genEl(token.children, scale * 4)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'font': {
|
||||
const family =
|
||||
token.props.args.serif ? 'serif' :
|
||||
token.props.args.monospace ? 'monospace' :
|
||||
token.props.args.cursive ? 'cursive' :
|
||||
token.props.args.fantasy ? 'fantasy' :
|
||||
token.props.args.emoji ? 'emoji' :
|
||||
token.props.args.math ? 'math' :
|
||||
null;
|
||||
if (family) style = { fontFamily: family };
|
||||
break;
|
||||
}
|
||||
case 'blur': {
|
||||
return (
|
||||
<span className='_mfm_blur_'>
|
||||
{genEl(token.children, scale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'rainbow': {
|
||||
if (!renderAnimatedMfm) {
|
||||
return (
|
||||
<span className='_mfm_rainbow_fallback_'>
|
||||
{genEl(token.children, scale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
style = { animation: `mfm-rainbow ${speed} linear infinite`, animationDelay: delay };
|
||||
break;
|
||||
}
|
||||
case 'sparkle': {
|
||||
// if (!renderAnimatedMfm) {
|
||||
return genEl(token.children, scale).flat();
|
||||
// }
|
||||
// return h(MkSparkle, {}, genEl(token.children, scale));
|
||||
}
|
||||
case 'fade': {
|
||||
if (!renderAnimatedMfm) {
|
||||
style = {};
|
||||
break;
|
||||
}
|
||||
|
||||
const direction = token.props.args.out
|
||||
? 'alternate-reverse'
|
||||
: 'alternate';
|
||||
const speed = validTime(token.props.args.speed) ?? '1.5s';
|
||||
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||
const loop = safeParseFloat(token.props.args.loop) ?? 'infinite';
|
||||
style = { animation: `mfm-fade ${speed} ${delay} linear ${loop}`, animationDirection: direction };
|
||||
break;
|
||||
}
|
||||
case 'rotate': {
|
||||
const degrees = safeParseFloat(token.props.args.deg) ?? 90;
|
||||
style = { transform: `rotate(${degrees}deg); transform-origin: center center` };
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO
|
||||
// case 'followmouse': {
|
||||
|
||||
case 'position': {
|
||||
if (!renderAdvancedMfm) break;
|
||||
const x = safeParseFloat(token.props.args.x) ?? 0;
|
||||
const y = safeParseFloat(token.props.args.y) ?? 0;
|
||||
style = { transform: `translateX(${x}em) translateY(${y}em)` };
|
||||
break;
|
||||
}
|
||||
case 'crop': {
|
||||
const top = Number.parseFloat(
|
||||
(token.props.args.top ?? '0').toString(),
|
||||
);
|
||||
const right = Number.parseFloat(
|
||||
(token.props.args.right ?? '0').toString(),
|
||||
);
|
||||
const bottom = Number.parseFloat(
|
||||
(token.props.args.bottom ?? '0').toString(),
|
||||
);
|
||||
const left = Number.parseFloat(
|
||||
(token.props.args.left ?? '0').toString(),
|
||||
);
|
||||
style = { clipPath: `inset(${top}% ${right}% ${bottom}% ${left}%)` };
|
||||
break;
|
||||
}
|
||||
case 'scale': {
|
||||
if (!renderAdvancedMfm) {
|
||||
style = {};
|
||||
break;
|
||||
}
|
||||
const x = clamp(safeParseFloat(token.props.args.x) ?? 1, -5, 5);
|
||||
const y = clamp(safeParseFloat(token.props.args.y) ?? 1, -5, 5);
|
||||
style = { transform: `scale(${x}, ${y})` };
|
||||
scale = scale * Math.max(Math.abs(x), Math.abs(y));
|
||||
break;
|
||||
}
|
||||
case 'fg': {
|
||||
let color = validColor(token.props.args.color);
|
||||
color = color ?? 'f00';
|
||||
style = { color: color, overflowWrap: 'anywhere' };
|
||||
break;
|
||||
}
|
||||
case 'bg': {
|
||||
let color = validColor(token.props.args.color);
|
||||
color = color ?? 'f00';
|
||||
style = { backgroundColor: color, overflowWrap: 'anywhere' };
|
||||
break;
|
||||
}
|
||||
case 'border': {
|
||||
let color = validColor(token.props.args.color);
|
||||
color = color ? `#${color}` : 'var(--MI_THEME-accent)';
|
||||
let b_style = token.props.args.style;
|
||||
if (
|
||||
typeof b_style !== 'string' ||
|
||||
!['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset']
|
||||
.includes(b_style)
|
||||
) b_style = 'solid';
|
||||
const width = safeParseFloat(token.props.args.width) ?? 1;
|
||||
const radius = safeParseFloat(token.props.args.radius) ?? 0;
|
||||
style = {
|
||||
border: `${width}px ${b_style} ${color}`,
|
||||
borderRadius: `${radius}px`,
|
||||
...token.props.args.noclip ? {} : { overflow: 'clip' },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ruby': {
|
||||
if (token.children.length === 1) {
|
||||
const child = token.children[0];
|
||||
let text = child.type === 'text' ? child.props.text : '';
|
||||
if (speakAsCat) {
|
||||
text = nyaize(text);
|
||||
}
|
||||
return (
|
||||
<ruby>
|
||||
{text.split(' ')[0]}
|
||||
<rt>
|
||||
{text.split(' ')[1]}
|
||||
</rt>
|
||||
</ruby>
|
||||
);
|
||||
} else {
|
||||
const rt = token.children.at(-1)!;
|
||||
let text = rt.type === 'text' ? rt.props.text : '';
|
||||
if (speakAsCat) {
|
||||
text = nyaize(text);
|
||||
}
|
||||
return (
|
||||
<ruby>
|
||||
{genEl(token.children.slice(0, token.children.length - 1), scale)}
|
||||
<rt>
|
||||
{text.trim()}
|
||||
</rt>
|
||||
</ruby>
|
||||
);
|
||||
}
|
||||
}
|
||||
case 'group': { // this is mostly a hack for the insides of `ruby`
|
||||
style = {};
|
||||
break;
|
||||
}
|
||||
// TODO
|
||||
// case 'unixtime': {
|
||||
// case 'clickable': {
|
||||
}
|
||||
if (style === undefined) {
|
||||
return (
|
||||
<span>
|
||||
{`$[${token.props.name} `}
|
||||
{genEl(token.children, scale)}
|
||||
]
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
className='inline-block'
|
||||
style={style}
|
||||
>
|
||||
{genEl(token.children, scale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'small': {
|
||||
return (
|
||||
<small className='opacity-70'>
|
||||
{genEl(token.children, scale)}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
case 'center': {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<bdi>
|
||||
{genEl(token.children, scale)}
|
||||
</bdi>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'url': {
|
||||
return (
|
||||
<bdi>
|
||||
<ParsedUrl href={token.props.url} childrenPlain={token.props.url}>
|
||||
{token.props.url}
|
||||
</ParsedUrl>
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
return (
|
||||
<bdi>
|
||||
<ParsedUrl href={token.props.url} displayTargetHost childrenPlain={genPlainText(token.children)}>
|
||||
{genEl(token.children, scale)}
|
||||
</ParsedUrl>
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
|
||||
case 'mention': {
|
||||
if (mentions) {
|
||||
const mention = mentions.find(({ acct }) => token.props.acct.slice(1) === acct);
|
||||
if (mention) {
|
||||
return (
|
||||
<bdi>
|
||||
<HoverAccountWrapper accountId={mention.id} element='span'>
|
||||
<Link
|
||||
to={`/@${mention.acct}`}
|
||||
className='text-primary-600 hover:underline dark:text-accent-blue'
|
||||
dir='ltr'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{mention.username}
|
||||
</Link>
|
||||
</HoverAccountWrapper>
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<bdi>
|
||||
<Link
|
||||
to={`/@${token.props.acct.slice(1)}`}
|
||||
className='text-primary-600 hover:underline dark:text-accent-blue'
|
||||
dir='ltr'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{token.props.username}
|
||||
</Link>
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
|
||||
case 'hashtag': {
|
||||
return (
|
||||
<HashtagLink hashtag={token.props.hashtag} />
|
||||
);
|
||||
}
|
||||
|
||||
case 'blockCode': {
|
||||
return (
|
||||
<bdi className='block'>
|
||||
<pre lang={token.props.lang || undefined}>
|
||||
{token.props.code}
|
||||
</pre>
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
|
||||
case 'inlineCode':
|
||||
return (
|
||||
<bdi>
|
||||
<code>
|
||||
{token.props.code}
|
||||
</code>
|
||||
</bdi>
|
||||
);
|
||||
|
||||
case 'quote': {
|
||||
return (
|
||||
<blockquote>
|
||||
<bdi>{genEl(token.children, scale)}</bdi>
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
case 'emojiCode': {
|
||||
const emoji = emojiMap[`:${token.props.name}:`];
|
||||
if (!emoji) return <bdi>{token.props.name}</bdi>;
|
||||
|
||||
const filename = emoji.static_url;
|
||||
|
||||
if (filename?.length > 0) {
|
||||
return <img draggable={false} className='emojione !h-[2em] !w-[2em] transition-transform hover:scale-125' alt={token.props.name} title={token.props.name} src={filename} />;
|
||||
}
|
||||
|
||||
return <bdi>{token.props.name}</bdi>;
|
||||
}
|
||||
|
||||
case 'unicodeEmoji': {
|
||||
return <Emoji emoji={token.props.emoji} className='!h-[2em] !w-[2em]' />;
|
||||
}
|
||||
|
||||
// TODO
|
||||
// case 'mathInline':
|
||||
// case 'mathBlock':
|
||||
// case 'search':
|
||||
|
||||
case 'plain': {
|
||||
return (
|
||||
<bdi>
|
||||
<span>
|
||||
{genEl(token.children, scale)}
|
||||
</span>
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error('unrecognized ast type:', token.type, token);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}).flat(Infinity);
|
||||
|
||||
return (
|
||||
<bdi className='plfe-mfm'>
|
||||
<span>
|
||||
{genEl(rootAst, 1)}
|
||||
</span>
|
||||
</bdi>
|
||||
);
|
||||
});
|
||||
|
||||
export { ParsedMfm };
|
||||
@ -19,6 +19,7 @@ import { getTextDirection } from '../utils/rtl';
|
||||
import HashtagsBar from './hashtags-bar';
|
||||
import Markup from './markup';
|
||||
import { parseContent } from './parsed-content';
|
||||
import { ParsedMfm } from './parsed-mfm';
|
||||
import Poll from './polls/poll';
|
||||
import StatusMedia from './status-media';
|
||||
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
||||
@ -82,7 +83,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
|
||||
preview,
|
||||
withMedia,
|
||||
}) => {
|
||||
const { urlPrivacy, displaySpoilers } = useSettings();
|
||||
const { urlPrivacy, displaySpoilers, renderMfm } = useSettings();
|
||||
const { greentext } = usePlFeConfig();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean | null>(null);
|
||||
@ -141,17 +142,26 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
|
||||
[status.content, translation, statusMeta.currentLanguage],
|
||||
);
|
||||
|
||||
const { content: parsedContent, hashtags } = useMemo(() => parseContent({
|
||||
html: content,
|
||||
mentions: status.mentions,
|
||||
hasQuote: !!status.quote_id,
|
||||
emojis: status.emojis,
|
||||
cleanUrls: urlPrivacy.clearLinksInContent,
|
||||
redirectUrls: urlPrivacy.redirectLinksMode !== 'off',
|
||||
displayTargetHost: urlPrivacy.displayTargetHost,
|
||||
greentext,
|
||||
speakAsCat: status.account.speak_as_cat,
|
||||
}, true), [content]);
|
||||
const { content: parsedContent, hashtags } = useMemo(() => {
|
||||
if (renderMfm && !translation && status.content_type === 'text/x.misskeymarkdown' && status.text) {
|
||||
return {
|
||||
content: <ParsedMfm text={status.text} emojis={status.emojis} mentions={status.mentions} />,
|
||||
hashtags: [],
|
||||
};
|
||||
}
|
||||
|
||||
return parseContent({
|
||||
html: content,
|
||||
mentions: status.mentions,
|
||||
hasQuote: !!status.quote_id,
|
||||
emojis: status.emojis,
|
||||
cleanUrls: urlPrivacy.clearLinksInContent,
|
||||
redirectUrls: urlPrivacy.redirectLinksMode !== 'off',
|
||||
displayTargetHost: urlPrivacy.displayTargetHost,
|
||||
greentext,
|
||||
speakAsCat: status.account.speak_as_cat,
|
||||
}, true);
|
||||
}, [content, renderMfm]);
|
||||
|
||||
useEffect(() => {
|
||||
setLineClamp(!spoilerNode.current || spoilerNode.current.clientHeight >= 96);
|
||||
|
||||
@ -352,6 +352,29 @@ const Preferences = () => {
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{instance.pleroma.metadata.post_formats.includes('text/x.misskeymarkdown') && (
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='preferences.fields.render_mfm_label' defaultMessage='Render Misskey Flavored Markdown' />}
|
||||
hint={<FormattedMessage id='preferences.fields.render_mfm_hint' defaultMessage='MFM support is experimental, not all node types are supported.' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['renderMfm']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='preferences.fields.render_advanced_mfm_label' defaultMessage='Enable advanced MFM' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['renderAdvancedMfm']} onChange={onToggleChange} disabled={!settings.renderMfm} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='preferences.fields.render_animated_mfm_label' defaultMessage='Enable animated MFM' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['renderAnimatedMfm']} onChange={onToggleChange} disabled={!settings.renderMfm} />
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
|
||||
{features.translations && (
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.auto_translate_label' defaultMessage='Automatically translate posts in unknown languages' />}>
|
||||
|
||||
@ -1354,6 +1354,10 @@
|
||||
"preferences.fields.preserve_spoilers_label": "Preserve content warning when replying",
|
||||
"preferences.fields.privacy_label": "Default post privacy",
|
||||
"preferences.fields.reduce_motion_label": "Reduce motion in animations",
|
||||
"preferences.fields.render_advanced_mfm_label": "Enable advanced MFM",
|
||||
"preferences.fields.render_animated_mfm_label": "Enable animated MFM",
|
||||
"preferences.fields.render_mfm_hint": "MFM support is experimental, not all node types are supported.",
|
||||
"preferences.fields.render_mfm_label": "Render Misskey Flavored Markdown",
|
||||
"preferences.fields.spoilers_display_label": "Automatically expand text behind spoilers",
|
||||
"preferences.fields.system_emoji_font_label": "Use system emoji font",
|
||||
"preferences.fields.system_font_label": "Use system's default font",
|
||||
|
||||
@ -10,6 +10,9 @@ const settingsSchema = v.object({
|
||||
onboarded: v.fallback(v.boolean(), false),
|
||||
skinTone: v.fallback(skinToneSchema, 1),
|
||||
reduceMotion: v.fallback(v.boolean(), false),
|
||||
renderMfm: v.fallback(v.boolean(), false),
|
||||
renderAdvancedMfm: v.fallback(v.boolean(), true),
|
||||
renderAnimatedMfm: v.fallback(v.boolean(), true),
|
||||
underlineLinks: v.fallback(v.boolean(), false),
|
||||
autoPlayGif: v.fallback(v.boolean(), true),
|
||||
displayMedia: v.fallback(v.picklist(['default', 'hide_all', 'show_all']), 'default'),
|
||||
|
||||
@ -18,3 +18,4 @@
|
||||
@use 'forms';
|
||||
@use 'utilities';
|
||||
@use 'components/datepicker';
|
||||
@use 'mfm';
|
||||
|
||||
155
packages/pl-fe/src/styles/mfm.scss
Normal file
155
packages/pl-fe/src/styles/mfm.scss
Normal file
@ -0,0 +1,155 @@
|
||||
// Shamelessly stolen from Sharkey
|
||||
// https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/packages/frontend/src/style.scss
|
||||
|
||||
.plfe-mfm {
|
||||
._mfm_blur_ {
|
||||
filter: blur(6px);
|
||||
transition: filter 0.3s;
|
||||
|
||||
&:hover {
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mfm-x2 {
|
||||
--mfm-zoom-size: 200%;
|
||||
}
|
||||
|
||||
.mfm-x3 {
|
||||
--mfm-zoom-size: 400%;
|
||||
}
|
||||
|
||||
.mfm-x4 {
|
||||
--mfm-zoom-size: 600%;
|
||||
}
|
||||
|
||||
.mfm-x2, .mfm-x3, .mfm-x4 {
|
||||
font-size: var(--mfm-zoom-size);
|
||||
|
||||
.mfm-x2, .mfm-x3, .mfm-x4 {
|
||||
/* only half effective */
|
||||
font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
|
||||
|
||||
.mfm-x2, .mfm-x3, .mfm-x4 {
|
||||
/* disabled */
|
||||
font-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._mfm_rainbow_fallback_ {
|
||||
background-image: linear-gradient(to right, rgb(255, 0, 0) 0%, rgb(255, 165, 0) 17%, rgb(255, 255, 0) 33%, rgb(0, 255, 0) 50%, rgb(0, 255, 255) 67%, rgb(0, 0, 255) 83%, rgb(255, 0, 255) 100%);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes mfm-spin-x {
|
||||
0% { transform: perspective(128px) rotateX(0deg); }
|
||||
100% { transform: perspective(128px) rotateX(360deg); }
|
||||
}
|
||||
|
||||
@keyframes mfm-spin-y {
|
||||
0% { transform: perspective(128px) rotateY(0deg); }
|
||||
100% { transform: perspective(128px) rotateY(360deg); }
|
||||
}
|
||||
|
||||
@keyframes mfm-jump {
|
||||
0% { transform: translateY(0); }
|
||||
25% { transform: translateY(-16px); }
|
||||
50% { transform: translateY(0); }
|
||||
75% { transform: translateY(-8px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes mfm-bounce {
|
||||
0% { transform: translateY(0) scale(1, 1); }
|
||||
25% { transform: translateY(-16px) scale(1, 1); }
|
||||
50% { transform: translateY(0) scale(1, 1); }
|
||||
75% { transform: translateY(0) scale(1.5, 0.75); }
|
||||
100% { transform: translateY(0) scale(1, 1); }
|
||||
}
|
||||
|
||||
// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
|
||||
// let css = '';
|
||||
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
|
||||
@keyframes mfm-twitch {
|
||||
0% { transform: translate(7px, -2px) }
|
||||
5% { transform: translate(-3px, 1px) }
|
||||
10% { transform: translate(-7px, -1px) }
|
||||
15% { transform: translate(0, -1px) }
|
||||
20% { transform: translate(-8px, 6px) }
|
||||
25% { transform: translate(-4px, -3px) }
|
||||
30% { transform: translate(-4px, -6px) }
|
||||
35% { transform: translate(-8px, -8px) }
|
||||
40% { transform: translate(4px, 6px) }
|
||||
45% { transform: translate(-3px, 1px) }
|
||||
50% { transform: translate(2px, -10px) }
|
||||
55% { transform: translate(-7px, 0) }
|
||||
60% { transform: translate(-2px, 4px) }
|
||||
65% { transform: translate(3px, -8px) }
|
||||
70% { transform: translate(6px, 7px) }
|
||||
75% { transform: translate(-7px, -2px) }
|
||||
80% { transform: translate(-7px, -8px) }
|
||||
85% { transform: translate(9px, 3px) }
|
||||
90% { transform: translate(-3px, -2px) }
|
||||
95% { transform: translate(-10px, 2px) }
|
||||
100% { transform: translate(-2px, -6px) }
|
||||
}
|
||||
|
||||
// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
|
||||
// let css = '';
|
||||
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
|
||||
@keyframes mfm-shake {
|
||||
0% { transform: translate(-3px, -1px) rotate(-8deg) }
|
||||
5% { transform: translate(0, -1px) rotate(-10deg) }
|
||||
10% { transform: translate(1px, -3px) rotate(0deg) }
|
||||
15% { transform: translate(1px, 1px) rotate(11deg) }
|
||||
20% { transform: translate(-2px, 1px) rotate(1deg) }
|
||||
25% { transform: translate(-1px, -2px) rotate(-2deg) }
|
||||
30% { transform: translate(-1px, 2px) rotate(-3deg) }
|
||||
35% { transform: translate(2px, 1px) rotate(6deg) }
|
||||
40% { transform: translate(-2px, -3px) rotate(-9deg) }
|
||||
45% { transform: translate(0, -1px) rotate(-12deg) }
|
||||
50% { transform: translate(1px, 2px) rotate(10deg) }
|
||||
55% { transform: translate(0, -3px) rotate(8deg) }
|
||||
60% { transform: translate(1px, -1px) rotate(8deg) }
|
||||
65% { transform: translate(0, -1px) rotate(-7deg) }
|
||||
70% { transform: translate(-1px, -3px) rotate(6deg) }
|
||||
75% { transform: translate(0, -2px) rotate(4deg) }
|
||||
80% { transform: translate(-2px, -1px) rotate(3deg) }
|
||||
85% { transform: translate(1px, -3px) rotate(-10deg) }
|
||||
90% { transform: translate(1px, 0) rotate(3deg) }
|
||||
95% { transform: translate(-2px, 0) rotate(-3deg) }
|
||||
100% { transform: translate(2px, 1px) rotate(2deg) }
|
||||
}
|
||||
|
||||
@keyframes mfm-rubber-band {
|
||||
0% { transform: scale3d(1, 1, 1); }
|
||||
30% { transform: scale3d(1.25, 0.75, 1); }
|
||||
40% { transform: scale3d(0.75, 1.25, 1); }
|
||||
50% { transform: scale3d(1.15, 0.85, 1); }
|
||||
65% { transform: scale3d(0.95, 1.05, 1); }
|
||||
75% { transform: scale3d(1.05, 0.95, 1); }
|
||||
100% { transform: scale3d(1, 1, 1); }
|
||||
}
|
||||
|
||||
@keyframes mfm-rainbow {
|
||||
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
|
||||
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
|
||||
}
|
||||
|
||||
@keyframes mfm-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user