Skip to content

Commit b8ef740

Browse files
committed
Fix oversight on state migration and improve hot reloading
1 parent 561162f commit b8ef740

File tree

8 files changed

+236
-221
lines changed

8 files changed

+236
-221
lines changed

.eslintrc

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"root": true,
33
"parser": "@typescript-eslint/parser",
44
"plugins": [
5-
"@typescript-eslint"
5+
"@typescript-eslint",
6+
"react-refresh"
67
],
78
"extends": [
89
"eslint:recommended",
@@ -103,6 +104,7 @@
103104
"varsIgnorePattern": "^_",
104105
"caughtErrorsIgnorePattern": "^_"
105106
}
106-
]
107+
],
108+
"react-refresh/only-export-components": "warn"
107109
}
108110
}

package-lock.json

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"@types/react-highlight": "^0.12.8",
7878
"@types/sanitize-html": "^2.11.0",
7979
"autoprefixer": "^10.4.18",
80+
"eslint-plugin-react-refresh": "^0.4.6",
8081
"happy-dom": "^14.3.3",
8182
"jest": "^29.7.0",
8283
"jsdom": "^24.0.0",
+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import Linkify from 'linkify-react';
2+
import { Fragment, createElement } from 'react';
3+
import { assertNever } from '@/lib/utils';
4+
import { bold, fakeCommand, greentext, inlineCode, italic, link, spoiler, strikethrough, subscript, superscript, options } from './chat';
5+
import { RoomLink } from './RoomLink';
6+
7+
const tokens = {
8+
'`': 'code',
9+
'|': 'spoiler',
10+
'*': 'bold',
11+
'_': 'italic',
12+
'~': 'strikethrough',
13+
'^': 'superscript',
14+
'\\': 'subscript',
15+
'[': 'link',
16+
'>': 'greentext',
17+
'<': 'roomlink',
18+
'/': 'fakeCommand',
19+
} as const;
20+
21+
type Token = typeof tokens[keyof typeof tokens];
22+
const elements: {
23+
[key in Token]: {
24+
pattern: RegExp;
25+
element: (props: any) => JSX.Element;
26+
};
27+
} = {
28+
code: { pattern: /`+(.+?)`+/g, element: inlineCode },
29+
spoiler: { pattern: /\|\|(.+?)\|\|/g, element: spoiler },
30+
bold: { pattern: /\*\*(.+?)\*\*/g, element: bold },
31+
italic: { pattern: /__(.+?)__/g, element: italic },
32+
strikethrough: { pattern: /~~(.+?)~~/g, element: strikethrough },
33+
superscript: { pattern: /\^\^(.+?)\^\^/g, element: superscript },
34+
subscript: { pattern: /\\\\(.+?)\\\\/g, element: subscript },
35+
link: { pattern: /\[\[(.+?)?\]\]/g, element: link },
36+
greentext: { pattern: /^>.*/g, element: greentext },
37+
fakeCommand: { pattern: /^\/\/.*/g, element: fakeCommand },
38+
roomlink: { pattern: /<<(.+?)?>>/g, element: RoomLink },
39+
} as const;
40+
41+
const cleanTag = (input: string, tag: keyof typeof elements) => {
42+
switch (tag) {
43+
case 'code':
44+
return input.replace(elements.code.pattern, '$1');
45+
case 'spoiler':
46+
return input.replace(elements.spoiler.pattern, '$1');
47+
case 'bold':
48+
return input.replace(elements.bold.pattern, '$1');
49+
case 'italic':
50+
return input.replace(elements.italic.pattern, '$1');
51+
case 'strikethrough':
52+
return input.replace(elements.strikethrough.pattern, '$1');
53+
case 'superscript':
54+
return input.replace(elements.superscript.pattern, '$1');
55+
case 'subscript':
56+
return input.replace(elements.subscript.pattern, '$1');
57+
case 'link':
58+
return input.replace(elements.link.pattern, '$1');
59+
case 'greentext':
60+
case 'fakeCommand': // e.g. //help should display as /help
61+
return input?.slice(1);
62+
case 'roomlink':
63+
return input.replace(elements.roomlink.pattern, '$1');
64+
default:
65+
assertNever(tag);
66+
console.error('cleanTag: unknown tag', tag);
67+
return '';
68+
}
69+
};
70+
let deepKey = 0;
71+
export const encloseInTag = (
72+
{ input, tag }:
73+
{
74+
input: string,
75+
tag: keyof typeof elements,
76+
}
77+
): null | { length: number; element: JSX.Element } => {
78+
// Find the closing tag if it exists
79+
elements[tag].pattern.lastIndex = 0;
80+
const matches = elements[tag].pattern.exec(input);
81+
if (matches) {
82+
return {
83+
length: matches[0].length,
84+
element: elements[tag].element({
85+
children: FormatMsgDisplay({
86+
msg: cleanTag(matches[0], tag),
87+
recursed: true,
88+
}),
89+
key: deepKey++,
90+
}),
91+
};
92+
}
93+
return null;
94+
};
95+
96+
export function FormatMsgDisplay(
97+
{ msg, recursed = false }: Readonly<{ msg: string; recursed?: boolean }>,
98+
) {
99+
if (!msg) return null;
100+
const jsxElements = [];
101+
let currentString = '';
102+
for (let i = 0; i < msg.length; i++) {
103+
const char = msg[i];
104+
if (char in tokens) {
105+
const tag = tokens[char as keyof typeof tokens];
106+
if ((char === '>' || char === '/') && i !== 0) {
107+
currentString += char;
108+
continue;
109+
}
110+
const result = encloseInTag({ input: msg.slice(i), tag });
111+
if (result) {
112+
i += result.length - 1;
113+
if (currentString) {
114+
if (recursed) {
115+
jsxElements.push(
116+
createElement(Fragment, { key: deepKey++ }, currentString),
117+
);
118+
} else {
119+
jsxElements.push(
120+
createElement(Linkify, { options, key: deepKey++ }, currentString),
121+
);
122+
}
123+
currentString = '';
124+
}
125+
jsxElements.push(result.element);
126+
} else {
127+
currentString += msg[i];
128+
}
129+
} else {
130+
currentString += msg[i];
131+
}
132+
}
133+
if (currentString) {
134+
if (recursed) {
135+
jsxElements.push(
136+
createElement(Fragment, { key: deepKey++ }, currentString),
137+
);
138+
} else {
139+
jsxElements.push(
140+
createElement(Linkify, { options, key: deepKey++ }, currentString),
141+
);
142+
}
143+
}
144+
return createElement(Fragment, { key: deepKey++ }, ...jsxElements);
145+
}
146+

src/UI/chatFormatting/chat.tsx

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import Linkify from 'linkify-react';
2-
import { HTMLAttributes, useState } from 'react';
2+
import { Fragment, HTMLAttributes, useState } from 'react';
33
import manageURL from '../../utils/manageURL';
44
import innerText from 'react-innertext';
55
import { twMerge } from 'tailwind-merge';
6+
import { assertNever } from '@/lib/utils';
67

78
// ``code here`` marks inline code
89
// ||text|| are spoilers
@@ -20,7 +21,7 @@ export interface ExtendedProps extends HTMLAttributes<HTMLSpanElement> {
2021
key?: number;
2122
}
2223

23-
const options = {
24+
export const options = {
2425
defaultProtocol: 'https',
2526
target: '_blank',
2627
attributes: {
@@ -69,7 +70,7 @@ export function spoiler(
6970

7071
export function bold(
7172
props: ExtendedProps,
72-
) {
73+
): React.ReactElement {
7374
const key = props.key;
7475
delete props.key;
7576
return <Linkify as="strong" {...props} key={key} options={options} />;
@@ -160,3 +161,14 @@ export function fakeCommand(
160161
</Linkify>
161162
);
162163
}
164+
165+
// ``code here`` marks inline code
166+
// ||text|| are spoilers
167+
// **text** is bold
168+
// __text__ is italic
169+
// ~~text~~ is strikethrough
170+
// ^^text^^ is superscript
171+
// \\text\\ is subscript
172+
// [[text]] is a link
173+
// >text is greentext
174+
// /me is an emote

0 commit comments

Comments
 (0)