1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-24 19:32:34 +01:00
This commit is contained in:
Puyodead1 2023-09-19 21:38:04 -04:00
parent e9f5994260
commit 1ab749a762
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
17 changed files with 1808 additions and 300 deletions

View File

@ -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",

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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;
});

View 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;

View 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;
}

View File

@ -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));

View File

@ -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: () => {

View File

@ -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>
);
});

View File

@ -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} />;
})}
</>
);

View File

@ -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)",
}}
>

View File

@ -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;

View File

@ -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);

View File

@ -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;
};
};

View File

@ -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";

View File

@ -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;
}

View File

@ -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);
}