mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-25 16:40:58 -05:00
feat: switch to remark
from markdown-it
(big)
* replaces old mentions with avatar and display name * renders things directly through React * replaces most of the markdown hacks with custom AST components * adds a tooltip to codeblock "copy to clipboard"
This commit is contained in:
parent
a766183f01
commit
34bb2bbc13
17 changed files with 1815 additions and 623 deletions
18
package.json
18
package.json
|
@ -73,7 +73,7 @@
|
||||||
"@hcaptcha/react-hcaptcha": "^0.3.6",
|
"@hcaptcha/react-hcaptcha": "^0.3.6",
|
||||||
"@insertish/vite-plugin-babel-macros": "^1.0.5",
|
"@insertish/vite-plugin-babel-macros": "^1.0.5",
|
||||||
"@preact/preset-vite": "^2.0.0",
|
"@preact/preset-vite": "^2.0.0",
|
||||||
"@revoltchat/ui": "1.0.69",
|
"@revoltchat/ui": "1.0.70",
|
||||||
"@rollup/plugin-replace": "^2.4.2",
|
"@rollup/plugin-replace": "^2.4.2",
|
||||||
"@styled-icons/boxicons-logos": "^10.38.0",
|
"@styled-icons/boxicons-logos": "^10.38.0",
|
||||||
"@styled-icons/boxicons-regular": "^10.38.0",
|
"@styled-icons/boxicons-regular": "^10.38.0",
|
||||||
|
@ -86,7 +86,6 @@
|
||||||
"@types/lodash": "^4",
|
"@types/lodash": "^4",
|
||||||
"@types/lodash.defaultsdeep": "^4.6.6",
|
"@types/lodash.defaultsdeep": "^4.6.6",
|
||||||
"@types/lodash.isequal": "^4.5.5",
|
"@types/lodash.isequal": "^4.5.5",
|
||||||
"@types/markdown-it": "^12.0.2",
|
|
||||||
"@types/node": "^15.12.4",
|
"@types/node": "^15.12.4",
|
||||||
"@types/preact-i18n": "^2.3.0",
|
"@types/preact-i18n": "^2.3.0",
|
||||||
"@types/prismjs": "^1.16.5",
|
"@types/prismjs": "^1.16.5",
|
||||||
|
@ -116,8 +115,7 @@
|
||||||
"lodash.defaultsdeep": "^4.6.1",
|
"lodash.defaultsdeep": "^4.6.1",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"long": "^5.2.0",
|
"long": "^5.2.0",
|
||||||
"markdown-it": "^12.0.6",
|
"mdast-util-to-hast": "^12.1.2",
|
||||||
"markdown-it-emoji": "^2.0.0",
|
|
||||||
"mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
|
"mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
|
||||||
"mobx": "^6.6.0",
|
"mobx": "^6.6.0",
|
||||||
"mobx-react-lite": "3.4.0",
|
"mobx-react-lite": "3.4.0",
|
||||||
|
@ -135,7 +133,15 @@
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scroll": "^1.8.2",
|
"react-scroll": "^1.8.2",
|
||||||
"react-virtuoso": "^2.12.0",
|
"react-virtuoso": "^2.12.0",
|
||||||
"revolt.js": "6.0.3",
|
"rehype-katex": "^6.0.2",
|
||||||
|
"rehype-prism": "^2.1.3",
|
||||||
|
"rehype-react": "^7.1.1",
|
||||||
|
"remark-breaks": "^3.0.2",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
|
"remark-math": "^5.1.1",
|
||||||
|
"remark-parse": "^10.0.1",
|
||||||
|
"remark-rehype": "^10.1.0",
|
||||||
|
"revolt.js": "6.0.5",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"sass": "^1.35.1",
|
"sass": "^1.35.1",
|
||||||
"semver": "^7.3.7",
|
"semver": "^7.3.7",
|
||||||
|
@ -147,6 +153,8 @@
|
||||||
"styled-components": "^5.3.0",
|
"styled-components": "^5.3.0",
|
||||||
"typescript": "^4.4.2",
|
"typescript": "^4.4.2",
|
||||||
"ulid": "^2.3.0",
|
"ulid": "^2.3.0",
|
||||||
|
"unified": "^10.1.2",
|
||||||
|
"unist-util-visit": "^4.1.0",
|
||||||
"use-resize-observer": "^7.0.0",
|
"use-resize-observer": "^7.0.0",
|
||||||
"vite-plugin-pwa": "^0.11.13",
|
"vite-plugin-pwa": "^0.11.13",
|
||||||
"workbox-precaching": "^6.1.5"
|
"workbox-precaching": "^6.1.5"
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { emojiDictionary } from "../../assets/emojis";
|
||||||
|
|
||||||
export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji";
|
export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji";
|
||||||
|
|
||||||
let EMOJI_PACK: EmojiPack = "mutant";
|
let EMOJI_PACK: EmojiPack = "mutant";
|
||||||
|
@ -40,7 +42,7 @@ function toCodePoint(rune: string) {
|
||||||
.join("-");
|
.join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEmoji(emoji: string) {
|
export function parseEmoji(emoji: string) {
|
||||||
if (emoji.startsWith("custom:")) {
|
if (emoji.startsWith("custom:")) {
|
||||||
return `https://dl.insrt.uk/projects/revolt/emotes/${emoji.substring(
|
return `https://dl.insrt.uk/projects/revolt/emotes/${emoji.substring(
|
||||||
7,
|
7,
|
||||||
|
|
|
@ -1,220 +0,0 @@
|
||||||
.markdown {
|
|
||||||
user-select: text;
|
|
||||||
|
|
||||||
:global(.emoji) {
|
|
||||||
object-fit: contain;
|
|
||||||
|
|
||||||
height: 1.25em;
|
|
||||||
width: 1.25em;
|
|
||||||
margin: 0 0.05em 0 0.1em;
|
|
||||||
vertical-align: -0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-large-emojis="true"] :global(.emoji) {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
margin-right: 2px;
|
|
||||||
vertical-align: -0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
pre {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&[data-type="mention"] {
|
|
||||||
padding: 0 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--secondary-background);
|
|
||||||
border-radius: calc(var(--border-radius) * 2);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6,
|
|
||||||
ul,
|
|
||||||
ol,
|
|
||||||
blockquote {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
&:not(:first-child) {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
list-style-position: inside;
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
margin: 2px 0;
|
|
||||||
padding: 2px 0;
|
|
||||||
background: var(--hover);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
border-inline-start: 4px solid var(--tertiary-background);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
padding: 1em;
|
|
||||||
overflow-x: scroll;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--block) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
p > code {
|
|
||||||
padding: 1px 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: white;
|
|
||||||
font-size: 90%;
|
|
||||||
background: var(--block);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
font-family: var(--monospace-font), monospace;
|
|
||||||
border-radius: 3px;
|
|
||||||
-webkit-box-decoration-break: clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
margin-right: 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 6px;
|
|
||||||
border: 1px solid var(--tertiary-foreground);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.katex-block) {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.spoiler) {
|
|
||||||
padding: 0 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
color: transparent;
|
|
||||||
background: #151515;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:global(.shown) {
|
|
||||||
cursor: auto;
|
|
||||||
user-select: all;
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--secondary-background);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.code) {
|
|
||||||
font-family: var(--monospace-font), monospace;
|
|
||||||
|
|
||||||
:global(.lang) {
|
|
||||||
width: fit-content;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
|
|
||||||
div {
|
|
||||||
color: #111;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
user-select: none;
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--accent);
|
|
||||||
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
box-shadow: 0 2px #787676;
|
|
||||||
border-radius: calc(var(--border-radius) / 3);
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
box-shadow: 0 1px #787676;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] + label:before {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
content: "a";
|
|
||||||
font-size: 10px;
|
|
||||||
margin-right: 6px;
|
|
||||||
line-height: 12px;
|
|
||||||
background: white;
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"][checked="true"] + label:before {
|
|
||||||
content: "✓";
|
|
||||||
align-items: center;
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] + label {
|
|
||||||
line-height: 12px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Suspense, lazy } from "preact/compat";
|
import { Suspense, lazy } from "preact/compat";
|
||||||
|
|
||||||
const Renderer = lazy(() => import("./Renderer"));
|
const Renderer = lazy(() => import("./RemarkRenderer"));
|
||||||
|
|
||||||
export interface MarkdownProps {
|
export interface MarkdownProps {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
|
|
192
src/components/markdown/RemarkRenderer.tsx
Normal file
192
src/components/markdown/RemarkRenderer.tsx
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
import "katex/dist/katex.min.css";
|
||||||
|
import rehypeKatex from "rehype-katex";
|
||||||
|
import rehypePrism from "rehype-prism";
|
||||||
|
import rehypeReact from "rehype-react";
|
||||||
|
import remarkBreaks from "remark-breaks";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkMath from "remark-math";
|
||||||
|
import remarkParse from "remark-parse";
|
||||||
|
import remarkRehype from "remark-rehype";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import { unified } from "unified";
|
||||||
|
|
||||||
|
import { createElement } from "preact";
|
||||||
|
import { memo } from "preact/compat";
|
||||||
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { MarkdownProps } from "./Markdown";
|
||||||
|
import { RenderCodeblock } from "./plugins/Codeblock";
|
||||||
|
import { RenderAnchor } from "./plugins/anchors";
|
||||||
|
import { remarkChannels, RenderChannel } from "./plugins/channels";
|
||||||
|
import { isOnlyEmoji, remarkEmoji, RenderEmoji } from "./plugins/emoji";
|
||||||
|
import { remarkMention, RenderMention } from "./plugins/mentions";
|
||||||
|
import { passThroughComponents } from "./plugins/remarkRegexComponent";
|
||||||
|
import { remarkSpoiler, RenderSpoiler } from "./plugins/spoiler";
|
||||||
|
import { remarkTimestamps, timestampHandler } from "./plugins/timestamps";
|
||||||
|
import "./prism";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null element
|
||||||
|
*/
|
||||||
|
const Null: React.FC = () => null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Markdown components
|
||||||
|
*/
|
||||||
|
const components = {
|
||||||
|
emoji: RenderEmoji,
|
||||||
|
mention: RenderMention,
|
||||||
|
spoiler: RenderSpoiler,
|
||||||
|
channel: RenderChannel,
|
||||||
|
a: RenderAnchor,
|
||||||
|
p: styled.p`
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
> code {
|
||||||
|
padding: 1px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
h1: styled.h1`
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
h2: styled.h2`
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
h3: styled.h3`
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
h4: styled.h4`
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
h5: styled.h5`
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
h6: styled.h6`
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
pre: RenderCodeblock,
|
||||||
|
code: styled.code`
|
||||||
|
color: white;
|
||||||
|
background: var(--block);
|
||||||
|
|
||||||
|
font-size: 90%;
|
||||||
|
font-family: var(--monospace-font), monospace;
|
||||||
|
|
||||||
|
border-radius: 3px;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
`,
|
||||||
|
table: styled.table`
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--tertiary-foreground);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
ul: styled.ul`
|
||||||
|
list-style-position: inside;
|
||||||
|
padding-left: 10px;
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
ol: styled.ol`
|
||||||
|
list-style-position: inside;
|
||||||
|
padding-left: 10px;
|
||||||
|
margin: 0.2em 0;
|
||||||
|
`,
|
||||||
|
li: styled.li`
|
||||||
|
${(props) =>
|
||||||
|
props.class === "task-list-item" &&
|
||||||
|
css`
|
||||||
|
list-style-type: none;
|
||||||
|
`}
|
||||||
|
`,
|
||||||
|
blockquote: styled.blockquote`
|
||||||
|
margin: 2px 0;
|
||||||
|
padding: 2px 0;
|
||||||
|
background: var(--hover);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border-inline-start: 4px solid var(--tertiary-background);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
// Block image elements
|
||||||
|
img: Null,
|
||||||
|
// Catch literally everything else just in case
|
||||||
|
video: Null,
|
||||||
|
figure: Null,
|
||||||
|
picture: Null,
|
||||||
|
source: Null,
|
||||||
|
audio: Null,
|
||||||
|
script: Null,
|
||||||
|
style: Null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified Markdown renderer
|
||||||
|
*/
|
||||||
|
const render = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkBreaks)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkSpoiler)
|
||||||
|
.use(remarkChannels)
|
||||||
|
.use(remarkTimestamps)
|
||||||
|
.use(remarkEmoji)
|
||||||
|
.use(remarkMention)
|
||||||
|
.use(remarkRehype, {
|
||||||
|
handlers: {
|
||||||
|
...passThroughComponents("emoji", "spoiler", "mention", "channel"),
|
||||||
|
timestamp: timestampHandler,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.use(rehypeKatex, {
|
||||||
|
maxSize: 10,
|
||||||
|
maxExpand: 0,
|
||||||
|
trust: false,
|
||||||
|
strict: false,
|
||||||
|
output: "html",
|
||||||
|
throwOnError: false,
|
||||||
|
errorColor: "var(--error)",
|
||||||
|
})
|
||||||
|
.use(rehypePrism)
|
||||||
|
// @ts-expect-error typings do not
|
||||||
|
// match between Preact and React
|
||||||
|
.use(rehypeReact, {
|
||||||
|
createElement,
|
||||||
|
Fragment,
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown parent container
|
||||||
|
*/
|
||||||
|
const Container = styled.div<{ largeEmoji: boolean }>`
|
||||||
|
.math-display {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
--emoji-size: ${(props) => (props.largeEmoji ? "3em" : "1.25em")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remark renderer component
|
||||||
|
*/
|
||||||
|
export default memo(({ content, disallowBigEmoji }: MarkdownProps) => {
|
||||||
|
const [Content, setContent] = useState<React.ReactElement>(null!);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
render.process(content!).then((file) => setContent(file.result));
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const largeEmoji = useMemo(
|
||||||
|
() => !disallowBigEmoji && isOnlyEmoji(content!),
|
||||||
|
[content, disallowBigEmoji],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Container largeEmoji={largeEmoji}>{Content}</Container>;
|
||||||
|
});
|
|
@ -1,290 +0,0 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
|
||||||
import MarkdownKatex from "@traptitech/markdown-it-katex";
|
|
||||||
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
|
|
||||||
import "katex/dist/katex.min.css";
|
|
||||||
import MarkdownIt from "markdown-it";
|
|
||||||
// @ts-expect-error No typings.
|
|
||||||
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
|
|
||||||
import { RE_MENTIONS } from "revolt.js";
|
|
||||||
|
|
||||||
import styles from "./Markdown.module.scss";
|
|
||||||
import { useCallback, useContext } from "preact/hooks";
|
|
||||||
|
|
||||||
import { internalEmit } from "../../lib/eventEmitter";
|
|
||||||
import { determineLink } from "../../lib/links";
|
|
||||||
|
|
||||||
import { dayjs } from "../../context/Locale";
|
|
||||||
|
|
||||||
import { emojiDictionary } from "../../assets/emojis";
|
|
||||||
import { useClient } from "../../controllers/client/ClientController";
|
|
||||||
import { modalController } from "../../controllers/modals/ModalController";
|
|
||||||
import { generateEmoji } from "../common/Emoji";
|
|
||||||
import { MarkdownProps } from "./Markdown";
|
|
||||||
import Prism from "./prism";
|
|
||||||
|
|
||||||
// TODO: global.d.ts file for defining globals
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
copycode: (element: HTMLDivElement) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler for code block copy.
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.copycode = function (element: HTMLDivElement) {
|
|
||||||
try {
|
|
||||||
const code = element.parentElement?.parentElement?.children[1];
|
|
||||||
if (code) {
|
|
||||||
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const md: MarkdownIt = MarkdownIt({
|
|
||||||
breaks: true,
|
|
||||||
linkify: true,
|
|
||||||
highlight: (str, lang) => {
|
|
||||||
const v = Prism.languages[lang];
|
|
||||||
if (v) {
|
|
||||||
const out = Prism.highlight(str, v, lang);
|
|
||||||
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<pre class="code"><code>${md.utils.escapeHtml(
|
|
||||||
str,
|
|
||||||
)}</code></pre>`;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.disable("image")
|
|
||||||
.use(MarkdownEmoji, { defs: emojiDictionary })
|
|
||||||
.use(MarkdownSpoilers)
|
|
||||||
.use(MarkdownKatex, {
|
|
||||||
throwOnError: false,
|
|
||||||
maxExpand: 0,
|
|
||||||
maxSize: 10,
|
|
||||||
strict: false,
|
|
||||||
errorColor: "var(--error)",
|
|
||||||
});
|
|
||||||
|
|
||||||
md.linkify.set({ fuzzyLink: false });
|
|
||||||
|
|
||||||
// TODO: global.d.ts file for defining globals
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
internalHandleURL: (element: HTMLAnchorElement) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include emojis.
|
|
||||||
md.renderer.rules.emoji = function (token, idx) {
|
|
||||||
return generateEmoji(token[idx].content);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Force line breaks.
|
|
||||||
// https://github.com/markdown-it/markdown-it/issues/211#issuecomment-508380611
|
|
||||||
const defaultParagraphRenderer =
|
|
||||||
md.renderer.rules.paragraph_open ||
|
|
||||||
((tokens, idx, options, env, self) =>
|
|
||||||
self.renderToken(tokens, idx, options));
|
|
||||||
|
|
||||||
md.renderer.rules.paragraph_open = function (tokens, idx, options, env, self) {
|
|
||||||
let result = "";
|
|
||||||
if (idx > 1) {
|
|
||||||
const inline = tokens[idx - 2];
|
|
||||||
const paragraph = tokens[idx];
|
|
||||||
if (
|
|
||||||
inline.type === "inline" &&
|
|
||||||
inline.map &&
|
|
||||||
inline.map[1] &&
|
|
||||||
paragraph.map &&
|
|
||||||
paragraph.map[0]
|
|
||||||
) {
|
|
||||||
const diff = paragraph.map[0] - inline.map[1];
|
|
||||||
if (diff > 0) {
|
|
||||||
result = "<br>".repeat(diff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result + defaultParagraphRenderer(tokens, idx, options, env, self);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RE_TWEMOJI = /:(\w+):/g;
|
|
||||||
|
|
||||||
// ! FIXME: Move to library
|
|
||||||
const RE_CHANNELS = /<#([A-z0-9]{26})>/g;
|
|
||||||
|
|
||||||
const RE_TIME = /<t:([0-9]+):(\w)>/g;
|
|
||||||
|
|
||||||
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
|
|
||||||
const client = useClient();
|
|
||||||
|
|
||||||
if (typeof content === "undefined") return null;
|
|
||||||
if (!content || content.length === 0) return null;
|
|
||||||
|
|
||||||
// We replace the message with the mention at the time of render.
|
|
||||||
// We don't care if the mention changes.
|
|
||||||
const newContent = content
|
|
||||||
.replace(RE_TIME, (sub: string, ...args: unknown[]) => {
|
|
||||||
if (isNaN(args[0] as number)) return sub;
|
|
||||||
const date = dayjs.unix(args[0] as number);
|
|
||||||
const format = args[1] as string;
|
|
||||||
let final = "";
|
|
||||||
switch (format) {
|
|
||||||
case "t":
|
|
||||||
final = date.format("hh:mm");
|
|
||||||
break;
|
|
||||||
case "T":
|
|
||||||
final = date.format("hh:mm:ss");
|
|
||||||
break;
|
|
||||||
case "R":
|
|
||||||
final = date.fromNow();
|
|
||||||
break;
|
|
||||||
case "D":
|
|
||||||
final = date.format("DD MMMM YYYY");
|
|
||||||
break;
|
|
||||||
case "F":
|
|
||||||
final = date.format("dddd, DD MMMM YYYY hh:mm");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
final = date.format("DD MMMM YYYY hh:mm");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return `\`${final}\``;
|
|
||||||
})
|
|
||||||
.replace(RE_MENTIONS, (sub: string, ...args: unknown[]) => {
|
|
||||||
const id = args[0] as string,
|
|
||||||
user = client.users.get(id);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
return `[@${user.username}](/@${id})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
})
|
|
||||||
.replace(RE_CHANNELS, (sub: string, ...args: unknown[]) => {
|
|
||||||
const id = args[0] as string,
|
|
||||||
channel = client.channels.get(id);
|
|
||||||
|
|
||||||
if (
|
|
||||||
channel?.channel_type === "TextChannel" ||
|
|
||||||
channel?.channel_type === "VoiceChannel"
|
|
||||||
) {
|
|
||||||
return `[#${channel.name}](/server/${channel.server_id}/channel/${id})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
});
|
|
||||||
|
|
||||||
const useLargeEmojis = disallowBigEmoji
|
|
||||||
? false
|
|
||||||
: content.replace(RE_TWEMOJI, "").trim().length === 0;
|
|
||||||
|
|
||||||
const toggle = useCallback((ev: MouseEvent) => {
|
|
||||||
if (ev.currentTarget) {
|
|
||||||
const element = ev.currentTarget as HTMLDivElement;
|
|
||||||
if (element.classList.contains("spoiler")) {
|
|
||||||
element.classList.add("shown");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLink = useCallback((ev: MouseEvent) => {
|
|
||||||
if (ev.currentTarget) {
|
|
||||||
const element = ev.currentTarget as HTMLAnchorElement;
|
|
||||||
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
switch (element.dataset.type) {
|
|
||||||
case "mention": {
|
|
||||||
internalEmit(
|
|
||||||
"MessageBox",
|
|
||||||
"append",
|
|
||||||
`<@${element.dataset.mentionId}>`,
|
|
||||||
"mention",
|
|
||||||
);
|
|
||||||
ev.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "channel_mention": {
|
|
||||||
internalEmit(
|
|
||||||
"MessageBox",
|
|
||||||
"append",
|
|
||||||
`<#${element.dataset.mentionId}>`,
|
|
||||||
"channel_mention",
|
|
||||||
);
|
|
||||||
ev.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalController.openLink(element.href)) {
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
|
||||||
el.querySelectorAll<HTMLDivElement>(".spoiler").forEach(
|
|
||||||
(element) => {
|
|
||||||
element.removeEventListener("click", toggle);
|
|
||||||
element.addEventListener("click", toggle);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
el.querySelectorAll<HTMLAnchorElement>("a").forEach(
|
|
||||||
(element) => {
|
|
||||||
element.removeEventListener("click", handleLink);
|
|
||||||
element.addEventListener("click", handleLink);
|
|
||||||
element.removeAttribute("data-type");
|
|
||||||
element.removeAttribute("data-mention-id");
|
|
||||||
element.removeAttribute("target");
|
|
||||||
|
|
||||||
const link = determineLink(element.href);
|
|
||||||
switch (link.type) {
|
|
||||||
case "profile": {
|
|
||||||
element.setAttribute(
|
|
||||||
"data-type",
|
|
||||||
"mention",
|
|
||||||
);
|
|
||||||
element.setAttribute(
|
|
||||||
"data-mention-id",
|
|
||||||
link.id,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "navigate": {
|
|
||||||
if (link.navigation_type === "channel") {
|
|
||||||
element.setAttribute(
|
|
||||||
"data-type",
|
|
||||||
"channel_mention",
|
|
||||||
);
|
|
||||||
element.setAttribute(
|
|
||||||
"data-mention-id",
|
|
||||||
link.channel_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "external": {
|
|
||||||
element.setAttribute("target", "_blank");
|
|
||||||
element.setAttribute("rel", "noreferrer");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={styles.markdown}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: md.render(newContent),
|
|
||||||
}}
|
|
||||||
data-large-emojis={useLargeEmojis}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
78
src/components/markdown/plugins/Codeblock.tsx
Normal file
78
src/components/markdown/plugins/Codeblock.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { useCallback, useRef } from "preact/hooks";
|
||||||
|
|
||||||
|
import { Tooltip } from "@revoltchat/ui";
|
||||||
|
|
||||||
|
import { modalController } from "../../../controllers/modals/ModalController";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base codeblock styles
|
||||||
|
*/
|
||||||
|
const Base = styled.pre`
|
||||||
|
padding: 1em;
|
||||||
|
overflow-x: scroll;
|
||||||
|
background: var(--block);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy codeblock contents button styles
|
||||||
|
*/
|
||||||
|
const Lang = styled.div`
|
||||||
|
width: fit-content;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #111;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--accent);
|
||||||
|
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: 0 2px #787676;
|
||||||
|
border-radius: calc(var(--border-radius) / 3);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 1px #787676;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a codeblock with copy text button
|
||||||
|
*/
|
||||||
|
export const RenderCodeblock: React.FC<{ class: string }> = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
|
let text = "text";
|
||||||
|
if (props.class) {
|
||||||
|
text = props.class.split("-")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCopy = useCallback(() => {
|
||||||
|
const text = ref.current?.querySelector("code")?.innerText;
|
||||||
|
text && modalController.writeText(text);
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Base ref={ref}>
|
||||||
|
<Lang>
|
||||||
|
<Tooltip content="Copy to Clipboard" placement="top">
|
||||||
|
{/**
|
||||||
|
// @ts-expect-error Preact-React */}
|
||||||
|
<a onClick={onCopy}>{text}</a>
|
||||||
|
</Tooltip>
|
||||||
|
</Lang>
|
||||||
|
{children}
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
};
|
34
src/components/markdown/plugins/anchors.tsx
Normal file
34
src/components/markdown/plugins/anchors.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { determineLink } from "../../../lib/links";
|
||||||
|
|
||||||
|
import { modalController } from "../../../controllers/modals/ModalController";
|
||||||
|
|
||||||
|
export function RenderAnchor({
|
||||||
|
href,
|
||||||
|
...props
|
||||||
|
}: JSX.HTMLAttributes<HTMLAnchorElement>) {
|
||||||
|
// Pass-through no href or if anchor
|
||||||
|
if (!href || href.startsWith("#")) return <a href={href} {...props} />;
|
||||||
|
|
||||||
|
// Determine type of link
|
||||||
|
const link = determineLink(href);
|
||||||
|
if (link.type === "none") return <a {...props} />;
|
||||||
|
|
||||||
|
// Render direct link if internal
|
||||||
|
if (link.type === "navigate") {
|
||||||
|
return <Link to={href} children={props.children} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(ev) =>
|
||||||
|
modalController.openLink(href) && ev.preventDefault()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/markdown/plugins/channels.tsx
Normal file
21
src/components/markdown/plugins/channels.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { clientController } from "../../../controllers/client/ClientController";
|
||||||
|
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||||
|
|
||||||
|
export function RenderChannel({ match }: CustomComponentProps) {
|
||||||
|
const channel = clientController.getAvailableClient().channels.get(match)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`${
|
||||||
|
channel.server_id ? `/server/${channel.server_id}` : ""
|
||||||
|
}/channel/${match}`}>{`#${channel.name}`}</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const remarkChannels = createComponent(
|
||||||
|
"channel",
|
||||||
|
/<#([A-z0-9]{26})>/g,
|
||||||
|
(match) => clientController.getAvailableClient().channels.has(match),
|
||||||
|
);
|
43
src/components/markdown/plugins/emoji.tsx
Normal file
43
src/components/markdown/plugins/emoji.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { emojiDictionary } from "../../../assets/emojis";
|
||||||
|
import { clientController } from "../../../controllers/client/ClientController";
|
||||||
|
import { parseEmoji } from "../../common/Emoji";
|
||||||
|
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||||
|
|
||||||
|
const Emoji = styled.img`
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
height: var(--emoji-size);
|
||||||
|
width: var(--emoji-size);
|
||||||
|
margin: 0 0.05em 0 0.1em;
|
||||||
|
vertical-align: -0.2em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function RenderEmoji({ match }: CustomComponentProps) {
|
||||||
|
return (
|
||||||
|
<Emoji
|
||||||
|
alt={match}
|
||||||
|
loading="lazy"
|
||||||
|
className="emoji"
|
||||||
|
draggable={false}
|
||||||
|
src={parseEmoji(
|
||||||
|
emojiDictionary[match as keyof typeof emojiDictionary],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RE_EMOJI = /:([a-zA-Z0-9_]+):/g;
|
||||||
|
|
||||||
|
export const remarkEmoji = createComponent(
|
||||||
|
"emoji",
|
||||||
|
RE_EMOJI,
|
||||||
|
(match) =>
|
||||||
|
match in emojiDictionary ||
|
||||||
|
clientController.getAvailableClient().emojis?.has(match),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isOnlyEmoji(text: string) {
|
||||||
|
return text.replaceAll(RE_EMOJI, "").trim().length === 0;
|
||||||
|
}
|
42
src/components/markdown/plugins/mentions.tsx
Normal file
42
src/components/markdown/plugins/mentions.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { RE_MENTIONS } from "revolt.js";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { clientController } from "../../../controllers/client/ClientController";
|
||||||
|
import UserShort from "../../common/user/UserShort";
|
||||||
|
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||||
|
|
||||||
|
const Mention = styled.a`
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--secondary-background);
|
||||||
|
border-radius: calc(var(--border-radius) * 2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function RenderMention({ match }: CustomComponentProps) {
|
||||||
|
return (
|
||||||
|
<Mention>
|
||||||
|
<UserShort
|
||||||
|
showServerIdentity
|
||||||
|
user={clientController.getAvailableClient().users.get(match)}
|
||||||
|
/>
|
||||||
|
</Mention>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const remarkMention = createComponent("mention", RE_MENTIONS, (match) =>
|
||||||
|
clientController.getAvailableClient().users.has(match),
|
||||||
|
);
|
108
src/components/markdown/plugins/remarkRegexComponent.ts
Normal file
108
src/components/markdown/plugins/remarkRegexComponent.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import type { Handler } from "mdast-util-to-hast";
|
||||||
|
import type { Plugin } from "unified";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props given to custom components
|
||||||
|
*/
|
||||||
|
export interface CustomComponentProps {
|
||||||
|
type: string;
|
||||||
|
match: string;
|
||||||
|
arg1: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new custom component matched by a given RegExp
|
||||||
|
* @param type hast node type
|
||||||
|
* @param regex Regex to match (must have one capture group)
|
||||||
|
* @returns Unified Plugin
|
||||||
|
*/
|
||||||
|
export function createComponent(
|
||||||
|
type: string,
|
||||||
|
regex: RegExp,
|
||||||
|
validator?: (match: string) => boolean,
|
||||||
|
): Plugin {
|
||||||
|
/**
|
||||||
|
* Plugin which transforms a given RegExp into a custom component with given name.
|
||||||
|
*/
|
||||||
|
return () => {
|
||||||
|
return (tree) => {
|
||||||
|
visit(
|
||||||
|
tree,
|
||||||
|
"text",
|
||||||
|
(
|
||||||
|
node: { value: string },
|
||||||
|
index: number,
|
||||||
|
parent: { children: any[] },
|
||||||
|
) => {
|
||||||
|
const result = [];
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
regex.lastIndex = 0;
|
||||||
|
|
||||||
|
let match = regex.exec(node.value);
|
||||||
|
|
||||||
|
while (match) {
|
||||||
|
if (!validator || validator(match[1])) {
|
||||||
|
const position = match.index;
|
||||||
|
|
||||||
|
if (start !== position) {
|
||||||
|
result.push({
|
||||||
|
type: "text",
|
||||||
|
value: node.value.slice(start, position),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
type,
|
||||||
|
match: match[1],
|
||||||
|
arg1: match[2],
|
||||||
|
});
|
||||||
|
start = position + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
match = regex.exec(node.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.length > 0 &&
|
||||||
|
parent &&
|
||||||
|
typeof index === "number"
|
||||||
|
) {
|
||||||
|
if (start < node.value.length) {
|
||||||
|
result.push({
|
||||||
|
type: "text",
|
||||||
|
value: node.value.slice(start),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children.splice(index, 1, ...result);
|
||||||
|
return index + result.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass-through a component as-is from remark to rehype
|
||||||
|
* @param name Tag name
|
||||||
|
* @returns Handler
|
||||||
|
*/
|
||||||
|
export const passThroughRehype: (name: string) => Handler =
|
||||||
|
(name: string) => (h, node) =>
|
||||||
|
h(node, name, node);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass-through multiple components at once
|
||||||
|
* @param keys Tags
|
||||||
|
* @returns Handlers
|
||||||
|
*/
|
||||||
|
export const passThroughComponents = (...keys: string[]) => {
|
||||||
|
const obj: Record<string, Handler> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
obj[key] = passThroughRehype(key);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
45
src/components/markdown/plugins/spoiler.tsx
Normal file
45
src/components/markdown/plugins/spoiler.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
|
||||||
|
|
||||||
|
const Spoiler = styled.span<{ shown: boolean }>`
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: transparent;
|
||||||
|
background: #151515;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.shown &&
|
||||||
|
css`
|
||||||
|
cursor: auto;
|
||||||
|
user-select: all;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--secondary-background);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: unset;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function RenderSpoiler({ match }: CustomComponentProps) {
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spoiler shown={shown} onClick={() => setShown(true)}>
|
||||||
|
{match}
|
||||||
|
</Spoiler>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const remarkSpoiler = createComponent("spoiler", /!!([^!]+)!!/g);
|
39
src/components/markdown/plugins/timestamps.ts
Normal file
39
src/components/markdown/plugins/timestamps.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Handler } from "mdast-util-to-hast";
|
||||||
|
|
||||||
|
import { dayjs } from "../../../context/Locale";
|
||||||
|
|
||||||
|
import { createComponent } from "./remarkRegexComponent";
|
||||||
|
|
||||||
|
export const timestampHandler: Handler = (h, { match, arg1 }) => {
|
||||||
|
if (isNaN(match)) return { type: "text", value: match };
|
||||||
|
const date = dayjs.unix(match);
|
||||||
|
|
||||||
|
let value = "";
|
||||||
|
switch (arg1) {
|
||||||
|
case "t":
|
||||||
|
value = date.format("hh:mm");
|
||||||
|
break;
|
||||||
|
case "T":
|
||||||
|
value = date.format("hh:mm:ss");
|
||||||
|
break;
|
||||||
|
case "R":
|
||||||
|
value = date.fromNow();
|
||||||
|
break;
|
||||||
|
case "D":
|
||||||
|
value = date.format("DD MMMM YYYY");
|
||||||
|
break;
|
||||||
|
case "F":
|
||||||
|
value = date.format("dddd, DD MMMM YYYY hh:mm");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = date.format("DD MMMM YYYY hh:mm");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(null, "code", {}, [{ type: "text", value }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remarkTimestamps = createComponent(
|
||||||
|
"timestamp",
|
||||||
|
/<t:([0-9]+)(?::(\w))?>/g,
|
||||||
|
);
|
|
@ -210,10 +210,6 @@ class ModalControllerExtended extends ModalController<Modal> {
|
||||||
const settings = getApplicationState().settings;
|
const settings = getApplicationState().settings;
|
||||||
|
|
||||||
switch (link.type) {
|
switch (link.type) {
|
||||||
case "profile": {
|
|
||||||
this.push({ type: "user_profile", user_id: link.id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "navigate": {
|
case "navigate": {
|
||||||
history.push(link.path);
|
history.push(link.path);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
type LinkType =
|
type LinkType =
|
||||||
| { type: "profile"; id: string }
|
|
||||||
| { type: "navigate"; path: string; navigation_type?: null }
|
|
||||||
| {
|
| {
|
||||||
type: "navigate";
|
type: "navigate";
|
||||||
path: string;
|
path: string;
|
||||||
navigation_type: "channel";
|
|
||||||
channel_id: string;
|
|
||||||
}
|
}
|
||||||
| { type: "external"; href: string; url: URL }
|
| { type: "external"; href: string; url: URL }
|
||||||
| { type: "none" };
|
| { type: "none" };
|
||||||
|
@ -17,9 +13,6 @@ const ALLOWED_ORIGINS = [
|
||||||
"local.revolt.chat",
|
"local.revolt.chat",
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHANNEL_PATH_RE =
|
|
||||||
/^\/server\/[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}\/channel\/[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/;
|
|
||||||
|
|
||||||
export function determineLink(href?: string): LinkType {
|
export function determineLink(href?: string): LinkType {
|
||||||
let internal,
|
let internal,
|
||||||
url: URL | null = null;
|
url: URL | null = null;
|
||||||
|
@ -30,29 +23,12 @@ export function determineLink(href?: string): LinkType {
|
||||||
|
|
||||||
if (ALLOWED_ORIGINS.includes(url.hostname)) {
|
if (ALLOWED_ORIGINS.includes(url.hostname)) {
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
if (path.startsWith("/@")) {
|
return { type: "navigate", path };
|
||||||
const id = path.substr(2);
|
|
||||||
if (/[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}/.test(id)) {
|
|
||||||
return { type: "profile", id };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (CHANNEL_PATH_RE.test(path)) {
|
|
||||||
return {
|
|
||||||
type: "navigate",
|
|
||||||
path,
|
|
||||||
navigation_type: "channel",
|
|
||||||
channel_id: path.slice(43),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { type: "navigate", path };
|
|
||||||
}
|
|
||||||
|
|
||||||
internal = true;
|
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
|
|
||||||
if (!internal && url) {
|
if (!internal && url) {
|
||||||
if (url.protocol !== "javascript") {
|
if (!url.protocol.startsWith("javascript")) {
|
||||||
return { type: "external", href, url };
|
return { type: "external", href, url };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue