(basic) MFM support

This commit is contained in:
Derek 2022-07-23 13:08:35 -04:00
parent 06adc19ea9
commit 4a93c2272b
11 changed files with 315 additions and 15 deletions

27
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@testing-library/user-event": "^13.5.0",
"canvas-confetti": "^1.5.1",
"customize-cra": "^1.0.0",
"mfm-js": "^0.22.1",
"polished": "^4.1.3",
"react": "^17.0.2",
"react-app-rewired": "^2.2.1",
@ -10982,6 +10983,14 @@
"node": ">= 0.6"
}
},
"node_modules/mfm-js": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/mfm-js/-/mfm-js-0.22.1.tgz",
"integrity": "sha512-UV5zvDKlWPpBFeABhyCzuOTJ3RwrNrmVpJ+zz/dFX6D/ntEywljgxkfsLamcy0ZSwUAr0O+WQxGHvAwyxUgsAQ==",
"dependencies": {
"twemoji-parser": "14.0.x"
}
},
"node_modules/micromatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
@ -15101,6 +15110,11 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -24211,6 +24225,14 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
},
"mfm-js": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/mfm-js/-/mfm-js-0.22.1.tgz",
"integrity": "sha512-UV5zvDKlWPpBFeABhyCzuOTJ3RwrNrmVpJ+zz/dFX6D/ntEywljgxkfsLamcy0ZSwUAr0O+WQxGHvAwyxUgsAQ==",
"requires": {
"twemoji-parser": "14.0.x"
}
},
"micromatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
@ -27067,6 +27089,11 @@
}
}
},
"twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="
},
"type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -8,6 +8,7 @@
"@testing-library/user-event": "^13.5.0",
"canvas-confetti": "^1.5.1",
"customize-cra": "^1.0.0",
"mfm-js": "^0.22.1",
"polished": "^4.1.3",
"react": "^17.0.2",
"react-app-rewired": "^2.2.1",

View file

@ -1,10 +1,13 @@
import { useMemo } from 'react';
import PropTypes from 'prop-types';
import { parse as mfmParse, toString as mfmUnparse } from 'mfm-js';
import XTERM_COLORS from '../../../xterm';
import { Wrapper, Author, Body, Emote, Attachments } from './message.style';
import * as mfmElements from './mfm';
const stringHashcode = (str) => {
var hash = 0, i, chr;
if (str.length === 0) return hash;
@ -16,18 +19,33 @@ const stringHashcode = (str) => {
return hash;
}
const emoteRegex = /:([^:\s]+):/g;
const elForType = {
image: (src) => <img src={src} />,
audio: (src) => <audio src={src} />,
video: (src) => <video src={src} />,
};
function Message({
user_id, user_name, user_type, user_color, text, emotes, attachments,
deemphasize,
}) {
const renderMfm = (mfmNode, note) => {
const ElForNode = mfmElements[mfmNode.type];
let node;
if (!ElForNode) {
const text = mfmUnparse(mfmNode);
const TextNode = mfmElements.text;
node = <TextNode note={note} text={text} />;
} else {
const children = mfmNode.children?.map((node) => renderMfm(node, note));
node = <ElForNode note={note} {...mfmNode.props} children={children} />;
}
return node;
};
function Message(note) {
const {
user_id, user_name, user_type, user_color, text, emotes, attachments, deemphasize,
} = note;
const fallbackColor = XTERM_COLORS[(stringHashcode(user_id.toString()) % 144) + 88];
const displayFiles = useMemo(() => {
if (attachments) {
@ -39,21 +57,17 @@ function Message({
return null;
}, [attachments]);
const mfmTree = useMemo(() => mfmParse(text), [text]);
const tree = mfmTree.map((node) => renderMfm(node, note));
return (
<Wrapper type={user_type} color={user_color ?? fallbackColor} deemphasize={deemphasize}>
<Author>
[ {user_name} ]
</Author>
{!!text && (
{mfmTree.length !== 0 && (
<Body>
{text.split(emoteRegex).map((textOrEmote, index) => {
if (textOrEmote === "") return null;
if (index % 2 === 0) {
return <span>{textOrEmote}</span>;
} else {
return <Emote src={emotes?.[textOrEmote]} title={textOrEmote} />;
}
}).filter((item) => item !== null)}
{tree}
</Body>
)}
{!!displayFiles && (

View file

@ -0,0 +1,11 @@
import styled from 'styled-components';
const Embolden = styled.span`
font-weight: bold;
`;
const BoldText = ({ children }) => (
<Embolden>{children}</Embolden>
);
export default BoldText;

View file

@ -0,0 +1,36 @@
import styled from 'styled-components';
import PropTypes from 'prop-types'
import Text from './text';
const Emote = styled.img`
height: 2em;
vertical-align: middle;
`;
const EmojiCode = ({
note, name,
}) => {
if (note.emotes?.[name]) {
return (
<Emote src={note.emotes[name]} title={name} />
);
}
return (
<Text text={`:${name}:`} />
);
};
EmojiCode.propTypes = {
note: PropTypes.shape({
emotes: PropTypes.shape({
[PropTypes.string]: PropTypes.string,
}),
}),
name: PropTypes.string.isRequired,
};
EmojiCode.defaultProps = {
note: { emotes: {} },
};
export default EmojiCode;

View file

@ -0,0 +1,178 @@
import styled, { css, keyframes } from 'styled-components';
const tada = keyframes`
from {
transform: scale3d(1, 1, 1);
}
10%, 20% {
transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg);
}
30%, 50%, 70%, 90% {
transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg);
}
40%, 60%, 80% {
transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
}
to {
transform: scale3d(1, 1, 1);
}
`;
const spin = keyframes`
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
`;
const spinX = keyframes`
0% { transform: perspective(128px) rotateX(0deg); }
100% { transform: perspective(128px) rotateX(360deg); }
`;
const spinY = keyframes`
0% { transform: perspective(128px) rotateY(0deg); }
100% { transform: perspective(128px) rotateY(360deg); }
`;
const jump = keyframes`
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
`;
const bounce = keyframes`
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 twitch = keyframes`
0% { transform: translate(7px, -2px) }
5% { transform: translate(-3px, 1px) }
10% { transform: translate(-7px, -1px) }
15% { transform: translate(0px, -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, 0px) }
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 shake = keyframes`
0% { transform: translate(-3px, -1px) rotate(-8deg) }
5% { transform: translate(0px, -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(0px, -1px) rotate(-12deg) }
50% { transform: translate(1px, 2px) rotate(10deg) }
55% { transform: translate(0px, -3px) rotate(8deg) }
60% { transform: translate(1px, -1px) rotate(8deg) }
65% { transform: translate(0px, -1px) rotate(-7deg) }
70% { transform: translate(-1px, -3px) rotate(6deg) }
75% { transform: translate(0px, -2px) rotate(4deg) }
80% { transform: translate(-2px, -1px) rotate(3deg) }
85% { transform: translate(1px, -3px) rotate(-10deg) }
90% { transform: translate(1px, 0px) rotate(3deg) }
95% { transform: translate(-2px, 0px) rotate(-3deg) }
100% { transform: translate(2px, 1px) rotate(2deg) }
`;
const rubberBand = keyframes`
from { 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); }
to { transform: scale3d(1, 1, 1); }
`;
const rainbow = keyframes`
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
`;
const validTime = (t, d = null) => {
if (t == null) return d;
return t.match(/^[0-9.]+s$/) ? t : d;
};
// TODO: font, sparkle
const mfmFnStyles = {
tada: css`
font-size: 150%;
animation: ${tada} 1s linear infinite both;
`,
jelly: css`animation: ${rubberBand} ${(props) => validTime(props.speed, '1s')} linear infinite both;`,
twitch: css`animation: ${twitch} ${(props) => validTime(props.speed, '0.5s')} ease infinite;`,
shake: css`animation: ${shake} ${(props) => validTime(props.speed, '0.5s')} ease infinite;`,
spin: (props) => {
let direction;
if (props.left) direction = 'reverse'
else if (props.alternate) direction = 'alternate'
else direction = 'normal';
let anime;
if (props.x) anime = spinX
else if (props.y) anime = spinY
else anime = spin;
return css`
animation: ${anime} ${(props) => validTime(props.speed, '1.5s')} linear infinite;
animation-direction: ${direction};
`;
},
jump: css`animation: ${jump} 0.75s linear infinite;`,
bounce: css`
animation: ${bounce} 0.75s linear infinite;
transform-origin: center bottom;
`,
flip: (props) => {
let transRights;
if (props.h && props.v) transRights = 'scale(-1, -1)';
else if (props.v) transRights = 'scaleY(-1)';
else transRights = 'scaleX(-1)';
return css`
transform: ${transRights};
`;
},
x2: css`font-size: 200%;`,
x3: css`font-size: 400%;`,
x4: css`font-size: 600%;`,
rainbow: css`
color: #f42069;
animation: ${rainbow} 1s linear infinite;`,
rotate: css`
transform: rotate(${(props) => parseInt(props.deg) || '90'}deg);
transform-origin: center center;
`,
};
const MfmFnRunner = styled.span`
> span {
display: inline-block;
${(props) => mfmFnStyles[props.mfmFn]};
}
`;
const MfmFunction = ({ children, name, args }) => (
<MfmFnRunner mfmFn={name} {...args}>
<span>
{children}
</span>
</MfmFnRunner>
);
export default MfmFunction;

View file

@ -0,0 +1,7 @@
export { default as emojiCode } from './emojiCode';
export { default as italic } from './italic';
export { default as bold } from './bold';
export { default as fn } from './fn';
export { default as unicodeEmoji } from './unicodeEmoji';
export { default as url } from './url';
export { default as text } from './text';

View file

@ -0,0 +1,11 @@
import styled from 'styled-components';
const Italic = styled.span`
font-style: italic;
`;
const ItalicText = ({ children }) => (
<Italic>{children}</Italic>
);
export default ItalicText;

View file

@ -0,0 +1,5 @@
const Text = ({ text }) => (
<span>{text}</span>
);
export default Text;

View file

@ -0,0 +1,5 @@
const UnicodeEmoji = ({ emoji }) => (
<span>{emoji}</span>
);
export default UnicodeEmoji;

View file

@ -0,0 +1,5 @@
const Url = ({ url }) => (
<a>{url}</a>
);
export default Url;