|
| 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 | + |
0 commit comments