mirror of
https://github.com/spacebarchat/client.git
synced 2024-11-24 19:32:34 +01:00
markdown
This commit is contained in:
parent
e9f5994260
commit
1ab749a762
13
package.json
13
package.json
@ -7,7 +7,7 @@
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^4.5.8",
|
||||
"@fontsource/source-code-pro": "^4.5.14",
|
||||
"@fontsource/roboto-mono": "^5.0.8",
|
||||
"@hcaptcha/react-hcaptcha": "^1.8.1",
|
||||
"@mattjennings/react-modal-stack": "^1.0.4",
|
||||
"@mdi/js": "^7.2.96",
|
||||
@ -34,6 +34,7 @@
|
||||
"missing-native-js-functions": "^1.4.3",
|
||||
"mobx": "^6.10.2",
|
||||
"mobx-react-lite": "^3.4.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-advanced-cropper": "^0.18.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
@ -42,14 +43,21 @@
|
||||
"react-hook-form": "^7.46.1",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-secure-storage": "^1.3.0",
|
||||
"react-select-search": "^4.1.6",
|
||||
"react-spinners": "^0.13.8",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-use-error-boundary": "^3.0.0",
|
||||
"rehype-prism": "^2.2.2",
|
||||
"rehype-react": "^8.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.0.0",
|
||||
"reoverlay": "^1.0.3",
|
||||
"styled-components": "^5.3.10"
|
||||
"styled-components": "^5.3.10",
|
||||
"unified": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
@ -57,6 +65,7 @@
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/loadable__component": "^5.13.5",
|
||||
"@types/node": "^16.18.50",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/styled-components": "^5.1.27",
|
||||
|
1146
pnpm-lock.yaml
1146
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
17
src/components/markdown/Markdown.tsx
Normal file
17
src/components/markdown/Markdown.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
const Renderer = lazy(() => import("./RemarkRenderer"));
|
||||
|
||||
export interface MarkdownProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function Markdown(props: MarkdownProps) {
|
||||
if (!props.content) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={props.content}>
|
||||
<Renderer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
186
src/components/markdown/RemarkRenderer.tsx
Normal file
186
src/components/markdown/RemarkRenderer.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
// adapted from Revite
|
||||
// https://github.com/revoltchat/revite/blob/fe63c6633f32b54aa1989cb34627e72bb3377efd/src/components/markdown/RemarkRenderer.tsx
|
||||
|
||||
import React from "react";
|
||||
import * as prod from "react/jsx-runtime";
|
||||
import rehypePrism from "rehype-prism";
|
||||
import rehypeReact from "rehype-react";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import styled from "styled-components";
|
||||
import { unified } from "unified";
|
||||
import Link from "../Link";
|
||||
import { MarkdownProps } from "./Markdown";
|
||||
import RenderCodeblock from "./plugins/Codeblock";
|
||||
import "./prism";
|
||||
|
||||
/**
|
||||
* Null element
|
||||
*/
|
||||
const Null: React.FC = () => null;
|
||||
|
||||
/**
|
||||
* Custom Markdown components
|
||||
*/
|
||||
const components = {
|
||||
// emoji: RenderEmoji,
|
||||
// mention: RenderMention,
|
||||
// spoiler: RenderSpoiler,
|
||||
// channel: RenderChannel,
|
||||
a: Link,
|
||||
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: var(--text);
|
||||
background: var(--background-secondary);
|
||||
|
||||
font-size: 90%;
|
||||
font-family: var(--font-family-code);
|
||||
|
||||
border-radius: 4px;
|
||||
box-decoration-break: clone;
|
||||
`,
|
||||
table: styled.table`
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px;
|
||||
border: 1px solid var(--background-teritary);
|
||||
}
|
||||
`,
|
||||
ul: styled.ul`
|
||||
list-style-position: inside;
|
||||
padding: 0;
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
ol: styled.ol`
|
||||
list-style-position: inside;
|
||||
padding: 0;
|
||||
margin: 0.2em 0;
|
||||
`,
|
||||
|
||||
blockquote: styled.blockquote`
|
||||
margin: 2px 0;
|
||||
padding: 2px 0;
|
||||
background: red;
|
||||
border-radius: 4px;
|
||||
border-inline-start: 4px solid var(--background-teritary);
|
||||
|
||||
> * {
|
||||
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,
|
||||
};
|
||||
|
||||
const render = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype)
|
||||
.use(rehypePrism)
|
||||
// @ts-expect-error typescript doesn't like this
|
||||
.use(rehypeReact, { Fragment: prod.Fragment, jsx: prod.jsx, jsxs: prod.jsxs, components });
|
||||
|
||||
/**
|
||||
* Regex for matching execessive recursion of blockquotes and lists
|
||||
*/
|
||||
const RE_RECURSIVE = /(^(?:[>*+-][^\S\r\n]*){5})(?:[>*+-][^\S\r\n]*)+(.*$)/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching multi-line blockquotes
|
||||
*/
|
||||
const RE_BLOCKQUOTE = /^([^\S\r\n]*>[^\n]+\n?)+/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching HTML tags
|
||||
*/
|
||||
const RE_HTML_TAGS = /^(<\/?[a-zA-Z0-9]+>)(.*$)/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching empty lines
|
||||
*/
|
||||
const RE_EMPTY_LINE = /^\s*?$/gm;
|
||||
|
||||
/**
|
||||
* Regex for matching line starting with plus
|
||||
*/
|
||||
const RE_PLUS = /^\s*\+(?:$|[^+])/gm;
|
||||
|
||||
/**
|
||||
* Sanitise Markdown input before rendering
|
||||
* @param content Input string
|
||||
* @returns Sanitised string
|
||||
*/
|
||||
function sanitize(content: string) {
|
||||
return (
|
||||
content
|
||||
// Strip excessive blockquote or list indentation
|
||||
.replace(RE_RECURSIVE, (_, m0, m1) => m0 + m1)
|
||||
|
||||
// Append empty character if string starts with html tag
|
||||
// This is to avoid inconsistencies in rendering Markdown inside/after HTML tags
|
||||
// https://github.com/revoltchat/revite/issues/733
|
||||
.replace(RE_HTML_TAGS, (match) => `\u200E${match}`)
|
||||
|
||||
// Append empty character if line starts with a plus
|
||||
// which would usually open a new list but we want
|
||||
// to avoid that behaviour in our case.
|
||||
.replace(RE_PLUS, (match) => `\u200E${match}`)
|
||||
|
||||
// Replace empty lines with non-breaking space
|
||||
// because remark renderer is collapsing empty
|
||||
// or otherwise whitespace-only lines of text
|
||||
.replace(RE_EMPTY_LINE, "")
|
||||
|
||||
// Ensure empty line after blockquotes for correct rendering
|
||||
.replace(RE_BLOCKQUOTE, (match) => `${match}\n`)
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(({ content }: MarkdownProps) => {
|
||||
const sanitizedContent = React.useMemo(() => sanitize(content), [content]);
|
||||
|
||||
const [parsedContent, setParsedContent] = React.useState<React.ReactElement>(null!);
|
||||
|
||||
React.useEffect(() => {
|
||||
render.process(sanitizedContent).then((file) => setParsedContent(file.result));
|
||||
}, [sanitizedContent]);
|
||||
|
||||
return parsedContent;
|
||||
});
|
74
src/components/markdown/plugins/Codeblock.tsx
Normal file
74
src/components/markdown/plugins/Codeblock.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
// adapted from Revite
|
||||
// https://github.com/revoltchat/revite/blob/fe63c6633f32b54aa1989cb34627e72bb3377efd/src/components/markdown/plugins/Codeblock.tsx
|
||||
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "../../Tooltip";
|
||||
|
||||
/**
|
||||
* Base codeblock styles
|
||||
*/
|
||||
const Base = styled.pre`
|
||||
padding: 1em;
|
||||
overflow-x: scroll;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Copy codeblock contents button styles
|
||||
*/
|
||||
const Lang = styled.div`
|
||||
width: fit-content;
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
background: var(--background-tertiary);
|
||||
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a codeblock with copy text button
|
||||
*/
|
||||
|
||||
function RenderCodeblock(props: Props) {
|
||||
const ref = React.useRef<HTMLPreElement>(null);
|
||||
|
||||
let text = "Copy";
|
||||
if (props.class) {
|
||||
text = props.class.split("-")[1];
|
||||
}
|
||||
|
||||
const onCopy = React.useCallback(() => {
|
||||
const text = ref.current?.querySelector("code")?.innerText;
|
||||
text && navigator.clipboard.writeText(text);
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<Base ref={ref}>
|
||||
<Lang>
|
||||
<Tooltip title="Copy to Clipboard" placement="top">
|
||||
<a onClick={onCopy}>{text}</a>
|
||||
</Tooltip>
|
||||
</Lang>
|
||||
{props.children}
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenderCodeblock;
|
140
src/components/markdown/prism.css
Normal file
140
src/components/markdown/prism.css
Normal file
@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Synthwave '84 Theme originally by Robb Owen [@Robb0wen] for Visual Studio Code
|
||||
* Demo: https://marc.dev/demo/prism-synthwave84
|
||||
*
|
||||
* Ported for PrismJS by Marc Backes [@themarcba]
|
||||
*/
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: #f92aad;
|
||||
text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;
|
||||
background: none;
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 1em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background-color: transparent !important;
|
||||
background-image: linear-gradient(to bottom, #2a2139 75%, #34294f);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #8e8e8e;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.attr-name,
|
||||
.token.namespace,
|
||||
.token.number,
|
||||
.token.unit,
|
||||
.token.hexcode,
|
||||
.token.deleted {
|
||||
color: #e2777a;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.selector {
|
||||
color: #72f1b8;
|
||||
text-shadow: 0 0 2px #100c0f, 0 0 10px #257c5575, 0 0 35px #21272475;
|
||||
}
|
||||
|
||||
.token.function-name {
|
||||
color: #6196cc;
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.selector .token.id,
|
||||
.token.function {
|
||||
color: #fdfdfd;
|
||||
text-shadow: 0 0 2px #001716, 0 0 3px #03edf975, 0 0 5px #03edf975, 0 0 8px #03edf975;
|
||||
}
|
||||
|
||||
.token.class-name {
|
||||
color: #fff5f6;
|
||||
text-shadow: 0 0 2px #000, 0 0 10px #fc1f2c75, 0 0 5px #fc1f2c75, 0 0 25px #fc1f2c75;
|
||||
}
|
||||
|
||||
.token.constant,
|
||||
.token.symbol {
|
||||
color: #f92aad;
|
||||
text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.atrule,
|
||||
.token.keyword,
|
||||
.token.selector .token.class,
|
||||
.token.builtin {
|
||||
color: #f4eee4;
|
||||
text-shadow: 0 0 2px #393a33, 0 0 8px #f39f0575, 0 0 2px #f39f0575;
|
||||
}
|
||||
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.regex,
|
||||
.token.variable {
|
||||
color: #f87c32;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: #67cdcc;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.token.inserted {
|
||||
color: green;
|
||||
}
|
@ -1,222 +1,52 @@
|
||||
import { MessageType } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { Fragment } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import styled from "styled-components";
|
||||
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
|
||||
import { memo } from "react";
|
||||
import { MessageLike } from "../../stores/objects/Message";
|
||||
import { QueuedMessageStatus } from "../../stores/objects/QueuedMessage";
|
||||
import Avatar from "../Avatar";
|
||||
import Link from "../Link";
|
||||
import { IContextMenuItem } from "./../ContextMenuItem";
|
||||
import Markdown from "../markdown/RemarkRenderer";
|
||||
import MessageAttachment from "./MessageAttachment";
|
||||
import MessageAuthor from "./MessageAuthor";
|
||||
import MessageBase from "./MessageBase";
|
||||
import MessageEmbed from "./MessageEmbed";
|
||||
import MessageTimestamp from "./MessageTimestamp";
|
||||
import SystemMessage from "./SystemMessage";
|
||||
import MessageBase, { MessageContent, MessageDetails, MessageInfo } from "./MessageBase";
|
||||
import AttachmentUploadProgress from "./attachments/AttachmentUploadProgress";
|
||||
|
||||
const MessageListItem = styled.li`
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
const Container = styled.div<{ isHeader?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
padding: 2px 12px;
|
||||
margin-top: ${(props) => (props.isHeader ? "20px" : undefined)};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-primary-highlight);
|
||||
}
|
||||
`;
|
||||
|
||||
const MessageContentContainer = styled.div<{ isHeader?: boolean }>`
|
||||
flex: 1;
|
||||
margin-left: ${(props) => (props.isHeader ? undefined : "50px")};
|
||||
`;
|
||||
|
||||
const MessageHeader = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const MessageContent = styled.div<{ sending?: boolean; failed?: boolean }>`
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-weight-light);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: anywhere;
|
||||
opacity: ${(props) => (props.sending ? 0.5 : undefined)};
|
||||
color: ${(props) => (props.failed ? "var(--error)" : undefined)};
|
||||
`;
|
||||
|
||||
const MessageHeaderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
function parseMessageContent(content?: string | null) {
|
||||
if (!content) return null;
|
||||
// replace links with Link components
|
||||
const replacedText = reactStringReplace(content, /(https?:\/\/\S+)/g, (match, i) => (
|
||||
<Link key={match + i} href={match} target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return replacedText;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: MessageLike;
|
||||
isHeader?: boolean;
|
||||
isSending?: boolean;
|
||||
isFailed?: boolean;
|
||||
header?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering a single message
|
||||
*/
|
||||
function Message({ message, isHeader, isSending, isFailed }: Props) {
|
||||
const contextMenu = React.useContext(ContextMenuContext);
|
||||
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([
|
||||
{
|
||||
label: "Copy Message ID",
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(message.id);
|
||||
},
|
||||
iconProps: {
|
||||
icon: "mdiIdentifier",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const withMessageHeader = React.useCallback(
|
||||
(children: React.ReactNode, showHeader = false) => (
|
||||
<MessageHeaderWrapper>
|
||||
{showHeader && (
|
||||
<Avatar
|
||||
key={message.author.id}
|
||||
user={message.author}
|
||||
size={40}
|
||||
style={{
|
||||
marginRight: 10,
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageContentContainer isHeader={showHeader}>
|
||||
{showHeader && (
|
||||
<MessageHeader>
|
||||
<MessageAuthor message={message} />
|
||||
<MessageTimestamp date={message.timestamp} />
|
||||
</MessageHeader>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{"files" in message && message.files?.length !== 0 && (
|
||||
<AttachmentUploadProgress message={message} />
|
||||
)}
|
||||
</MessageContentContainer>
|
||||
</MessageHeaderWrapper>
|
||||
),
|
||||
[message, contextMenuItems],
|
||||
);
|
||||
|
||||
const constructDefaultMessage = React.useCallback(
|
||||
() =>
|
||||
withMessageHeader(
|
||||
<MessageContent sending={isSending} failed={isFailed}>
|
||||
{message.type !== MessageType.Default && (
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: "12px" }}>
|
||||
MessageType({MessageType[message.type]})
|
||||
</div>
|
||||
)}
|
||||
{parseMessageContent(message.content)}
|
||||
{"edited_timestamp" in message && message.edited_timestamp && (
|
||||
<MessageTimestamp date={message.edited_timestamp}>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: "12px", paddingLeft: "5px" }}>
|
||||
(edited)
|
||||
</span>
|
||||
</MessageTimestamp>
|
||||
)}
|
||||
{"attachments" in message
|
||||
? message.attachments.map((attachment, index) => (
|
||||
<Fragment key={index}>
|
||||
<MessageAttachment
|
||||
key={index}
|
||||
attachment={attachment}
|
||||
contextMenuItems={contextMenuItems}
|
||||
/>
|
||||
</Fragment>
|
||||
))
|
||||
: null}
|
||||
{"embeds" in message
|
||||
? message.embeds.map((embed, index) => (
|
||||
<Fragment key={index}>
|
||||
<MessageEmbed key={index} embed={embed} contextMenuItems={contextMenuItems} />
|
||||
</Fragment>
|
||||
))
|
||||
: null}
|
||||
</MessageContent>,
|
||||
isHeader,
|
||||
),
|
||||
[message, isHeader],
|
||||
);
|
||||
|
||||
const constructJoinMessage = React.useCallback(() => {
|
||||
const joinMessage = message.getJoinMessage();
|
||||
return (
|
||||
<SystemMessage
|
||||
message={message}
|
||||
iconProps={{ icon: "mdiArrowRight", size: "16px", color: "var(--success)" }}
|
||||
>
|
||||
{reactStringReplace(joinMessage, "{author}", (_, i) => (
|
||||
<Link color="var(--text)" style={{ fontWeight: "var(--font-weight-medium)" }} key={i}>
|
||||
{message.author.username}
|
||||
</Link>
|
||||
))}
|
||||
</SystemMessage>
|
||||
);
|
||||
}, [message]);
|
||||
|
||||
// handles creating the message content based on the message type
|
||||
const renderMessageContent = React.useCallback(() => {
|
||||
switch (message.type) {
|
||||
case MessageType.Default:
|
||||
return constructDefaultMessage();
|
||||
case MessageType.UserJoin:
|
||||
return constructJoinMessage();
|
||||
default:
|
||||
return constructDefaultMessage();
|
||||
}
|
||||
}, [message, isSending, isFailed]);
|
||||
|
||||
function Message({ message, header }: Props) {
|
||||
return (
|
||||
<MessageListItem>
|
||||
<Container
|
||||
isHeader={isHeader}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.open({
|
||||
position: {
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
},
|
||||
items: contextMenuItems,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MessageBase>{renderMessageContent()}</MessageBase>
|
||||
</Container>
|
||||
</MessageListItem>
|
||||
<MessageBase
|
||||
header={header}
|
||||
sending={"status" in message && message.status === QueuedMessageStatus.SENDING}
|
||||
failed={"status" in message && message.status === QueuedMessageStatus.FAILED}
|
||||
>
|
||||
<MessageInfo click={typeof header !== "undefined"}>
|
||||
{header ? (
|
||||
<Avatar key={message.author.id} user={message.author} size={40} />
|
||||
) : (
|
||||
<MessageDetails message={message} position="left" />
|
||||
)}
|
||||
</MessageInfo>
|
||||
<MessageContent>
|
||||
{header && (
|
||||
<span className="message-details">
|
||||
<MessageAuthor message={message} />
|
||||
<MessageDetails message={message} position="top" />
|
||||
</span>
|
||||
)}
|
||||
{message.content && <Markdown content={message.content} />}
|
||||
{"attachments" in message &&
|
||||
message.attachments.map((attachment, index) => (
|
||||
<MessageAttachment key={index} attachment={attachment} />
|
||||
))}
|
||||
{/* {message.embeds?.map((embed, index) => (
|
||||
<MessageEmbed key={index} embed={embed} />
|
||||
))} */}
|
||||
{"files" in message && message.files?.length !== 0 && <AttachmentUploadProgress message={message} />}
|
||||
</MessageContent>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Message);
|
||||
export default memo(observer(Message));
|
||||
|
@ -23,7 +23,7 @@ const Image = styled.img`
|
||||
|
||||
interface AttachmentProps {
|
||||
attachment: APIAttachment;
|
||||
contextMenuItems: IContextMenuItem[];
|
||||
contextMenuItems?: IContextMenuItem[];
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
@ -70,7 +70,7 @@ export default function MessageAttachment({ attachment, contextMenuItems, maxWid
|
||||
y: e.pageY,
|
||||
},
|
||||
items: [
|
||||
...contextMenuItems,
|
||||
...(contextMenuItems ?? []),
|
||||
{
|
||||
label: "Copy Attachment URL",
|
||||
onClick: () => {
|
||||
|
@ -1,17 +1,128 @@
|
||||
import dayjs from "dayjs";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import styled from "styled-components";
|
||||
import Message, { MessageLike } from "../../stores/objects/Message";
|
||||
import { calendarStrings } from "../../utils/i18n";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const Container = styled.div`
|
||||
interface Props {
|
||||
header?: boolean;
|
||||
failed?: boolean;
|
||||
sending?: boolean;
|
||||
mention?: boolean;
|
||||
}
|
||||
|
||||
export default styled.div<Props>`
|
||||
display: flex;
|
||||
overflow: none;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
padding: 2px 12px;
|
||||
${(props) => props.header && "margin-top: 20px;"}
|
||||
${(props) => props.failed && "color: var(--error);"}
|
||||
${(props) => props.sending && "opacity: 0.5;"}
|
||||
${(props) => props.mention && "background-color: var(--mention);"}
|
||||
|
||||
.message-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-details > .name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-primary-highlight);
|
||||
|
||||
time,
|
||||
.edited {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
function MessageBase({ children }: { children: React.ReactNode }) {
|
||||
return <Container>{children}</Container>;
|
||||
}
|
||||
|
||||
export default MessageBase;
|
||||
export const MessageInfo = styled.div<{ click: boolean }>`
|
||||
width: 62px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
.messageTimestampWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
time,
|
||||
.edited {
|
||||
opacity: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageContent = styled.div`
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-right: 48px;
|
||||
`;
|
||||
|
||||
export const DetailBase = styled.div`
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 4px;
|
||||
align-self: center;
|
||||
|
||||
.edited {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageDetails = observer(({ message, position }: { message: MessageLike; position: "left" | "top" }) => {
|
||||
if (position === "left") {
|
||||
if (message instanceof Message && message.edited_timestamp) {
|
||||
return (
|
||||
<div className="messageTimestampWrapper">
|
||||
<Tooltip title={dayjs(message.timestamp).format("dddd, MMMM MM, h:mm A")}>
|
||||
<time className="copyTime" dateTime={message.edited_timestamp.toISOString()}>
|
||||
{dayjs(message.edited_timestamp).format("h:mm A")}
|
||||
</time>
|
||||
</Tooltip>
|
||||
<span className="edited">
|
||||
<Tooltip title={dayjs(message.edited_timestamp).format("dddd, MMMM MM, h:mm A")}>
|
||||
<span>(edited)</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<time dateTime={message.timestamp.toISOString()}>{dayjs(message.timestamp).format("h:mm A")}</time>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailBase>
|
||||
<Tooltip title={dayjs(message.timestamp).format("dddd, MMMM MM, h:mm A")}>
|
||||
<time className="copyTime" dateTime={message.timestamp.toISOString()}>
|
||||
{dayjs(message.timestamp).calendar(undefined, calendarStrings)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{message instanceof Message && message.edited_timestamp && (
|
||||
<Tooltip title={dayjs(message.edited_timestamp).format("dddd, MMMM MM, h:mm A")}>
|
||||
<span className="edited">(edited)</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DetailBase>
|
||||
);
|
||||
});
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { MessageType } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useAppStore } from "../../stores/AppStore";
|
||||
import { MessageGroup as MessageGroupType } from "../../stores/MessageStore";
|
||||
import { QueuedMessageStatus } from "../../stores/objects/QueuedMessage";
|
||||
import Message from "./Message";
|
||||
import SystemMessage from "./SystemMessage";
|
||||
|
||||
interface Props {
|
||||
group: MessageGroupType;
|
||||
@ -11,19 +13,15 @@ interface Props {
|
||||
* Component that handles rendering a group of messages from the same author
|
||||
*/
|
||||
function MessageGroup({ group }: Props) {
|
||||
const app = useAppStore();
|
||||
const { messages } = group;
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message, index) => {
|
||||
return (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isHeader={index === messages.length - 1}
|
||||
isSending={"status" in message && message.status === QueuedMessageStatus.SENDING}
|
||||
isFailed={"status" in message && message.status === QueuedMessageStatus.FAILED}
|
||||
/>
|
||||
);
|
||||
if (message.type === MessageType.Default || message.type === MessageType.Reply) {
|
||||
return <Message key={index} message={message} header={index === messages.length - 1} />;
|
||||
} else return <SystemMessage key={index} message={message} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
@ -90,7 +90,7 @@ function MessageList({ guild, channel }: Props) {
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
marginBottom: "30px",
|
||||
marginBottom: 30,
|
||||
}} // to put endMessage and loader to the top.
|
||||
hasMore={hasMore}
|
||||
inverse={true}
|
||||
@ -100,7 +100,7 @@ function MessageList({ guild, channel }: Props) {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignContent: "center",
|
||||
marginBottom: "30px",
|
||||
marginBottom: 30,
|
||||
}}
|
||||
color="var(--primary)"
|
||||
/>
|
||||
@ -121,8 +121,8 @@ function MessageList({ guild, channel }: Props) {
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "30px",
|
||||
paddingLeft: "20px",
|
||||
marginBottom: 30,
|
||||
paddingLeft: 20,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
|
@ -1,27 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import styled from "styled-components";
|
||||
import { calendarStrings } from "../../utils/i18n";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const Container = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
margin-left: 10px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
date: string | number | Date | dayjs.Dayjs;
|
||||
children?: React.ReactElement;
|
||||
}
|
||||
|
||||
function MessageTimestamp({ date, children }: Props) {
|
||||
return (
|
||||
<Tooltip title={dayjs(date).format("dddd, MMMM MM, h:mm A")} placement="top">
|
||||
{children ? children : <Container>{dayjs(date).calendar(undefined, calendarStrings)}</Container>}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageTimestamp;
|
@ -1,36 +1,82 @@
|
||||
import React from "react";
|
||||
import * as Icons from "@mdi/js";
|
||||
import { MessageType } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import styled from "styled-components";
|
||||
import { MessageLike } from "../../stores/objects/Message";
|
||||
import Icon, { IconProps } from "../Icon";
|
||||
import MessageTimestamp from "./MessageTimestamp";
|
||||
import Icon from "../Icon";
|
||||
import MessageBase, { MessageDetails, MessageInfo } from "./MessageBase";
|
||||
|
||||
const Container = styled.div`
|
||||
const SystemContent = styled.div`
|
||||
display: flex;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const SystemUser = styled.span`
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
message: MessageLike;
|
||||
children: React.ReactNode;
|
||||
iconProps?: IconProps;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function SystemMessage({ message, children, iconProps }: Props) {
|
||||
const ICONS: Partial<Record<MessageType, { icon: keyof typeof Icons; color?: string }>> = {
|
||||
[MessageType.UserJoin]: {
|
||||
icon: "mdiArrowRight",
|
||||
color: "var(--success)",
|
||||
},
|
||||
};
|
||||
|
||||
function SystemMessage({ message, highlight }: Props) {
|
||||
const icon = ICONS[message.type] ?? {
|
||||
icon: "mdiInformation",
|
||||
};
|
||||
|
||||
let children;
|
||||
switch (message.type) {
|
||||
case MessageType.UserJoin: {
|
||||
const joinMessage = message.getJoinMessage();
|
||||
children = (
|
||||
<div>
|
||||
{reactStringReplace(joinMessage, "{author}", (_, i) => (
|
||||
<SystemUser key={i}>{message.author.username}</SystemUser>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.Default:
|
||||
children = <ReactMarkdown children={message.content} />;
|
||||
break;
|
||||
default:
|
||||
// children = <span>Unimplemented system message type '{MessageType[message.type]}'</span>;
|
||||
children = <ReactMarkdown children={message.content} />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div style={{ margin: "0 10px", display: "flex" }}>{iconProps && <Icon {...iconProps} />}</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontWeight: "var(--font-weight-regular)",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<MessageTimestamp date={message.timestamp} />
|
||||
</Container>
|
||||
<MessageBase header>
|
||||
<MessageInfo click={false}>
|
||||
<Icon icon={icon.icon} size="16px" color={icon.color ?? "var(--text-secondary)"} />
|
||||
</MessageInfo>
|
||||
<SystemContent>{children}</SystemContent>
|
||||
<MessageDetails message={message} position="top" />
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemMessage;
|
||||
export default observer(SystemMessage);
|
||||
|
@ -12,6 +12,7 @@ const font: ThemeFont["font"] = {
|
||||
black: 900,
|
||||
},
|
||||
family: "Roboto, Arial, Helvetica, sans-serif",
|
||||
familyCode: '"Roboto Mono", monospace',
|
||||
};
|
||||
|
||||
export type ThemeVariables =
|
||||
@ -70,6 +71,7 @@ export type ThemeFont = {
|
||||
black?: number;
|
||||
};
|
||||
family: string;
|
||||
familyCode: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
import "@fontsource/source-code-pro";
|
||||
// import "@fontsource/source-sans-pro/200.css";
|
||||
// import "@fontsource/source-sans-pro/300.css";
|
||||
// import "@fontsource/source-sans-pro/400.css";
|
||||
// import "@fontsource/source-sans-pro/600.css";
|
||||
// import "@fontsource/source-sans-pro/700.css";
|
||||
// import "@fontsource/source-sans-pro/900.css";
|
||||
import "@fontsource/roboto-mono/100.css";
|
||||
import "@fontsource/roboto-mono/200.css";
|
||||
import "@fontsource/roboto-mono/300.css";
|
||||
import "@fontsource/roboto-mono/400.css";
|
||||
import "@fontsource/roboto-mono/500.css";
|
||||
import "@fontsource/roboto-mono/600.css";
|
||||
import "@fontsource/roboto-mono/700.css";
|
||||
import "@fontsource/roboto/100.css";
|
||||
import "@fontsource/roboto/300.css";
|
||||
import "@fontsource/roboto/400.css";
|
||||
|
@ -28,7 +28,6 @@ export default class GuildMemberStore {
|
||||
}
|
||||
const m = new GuildMember(this.app, this.guild, member);
|
||||
this.members.set(member.user.id, m);
|
||||
console.log(`added member ${m.user?.username}`);
|
||||
return m;
|
||||
}
|
||||
|
||||
|
@ -103,7 +103,6 @@ export default class Guild {
|
||||
this.roles.addAll(data.roles);
|
||||
// FIXME: hack to prevent errors after guild creation where channels is undefined
|
||||
if (data.channels) {
|
||||
console.log(data.channels);
|
||||
this.channels.addAll(data.channels);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user