diff --git a/package.json b/package.json
index 11f959b..6c15896 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"react-secure-storage": "^1.3.0",
"react-select-search": "^4.1.6",
"react-spinners": "^0.13.8",
+ "react-string-replace": "^1.1.1",
"reoverlay": "^1.0.3",
"styled-components": "^5.3.10"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 920aa64..13446bc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -134,6 +134,9 @@ dependencies:
react-spinners:
specifier: ^0.13.8
version: 0.13.8(react-dom@18.2.0)(react@18.2.0)
+ react-string-replace:
+ specifier: ^1.1.1
+ version: 1.1.1
reoverlay:
specifier: ^1.0.3
version: 1.0.3(react-dom@18.2.0)(react@18.2.0)
@@ -10729,6 +10732,11 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /react-string-replace@1.1.1:
+ resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==}
+ engines: {node: '>=0.12.0'}
+ dev: false
+
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
diff --git a/src/components/messaging/Message.tsx b/src/components/messaging/Message.tsx
index f2521d4..57f37a0 100644
--- a/src/components/messaging/Message.tsx
+++ b/src/components/messaging/Message.tsx
@@ -1,7 +1,7 @@
import { MessageType } from "@spacebarchat/spacebar-api-types/v9";
-import dayjs from "dayjs";
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 { MessageLike } from "../../stores/objects/Message";
@@ -9,7 +9,10 @@ import Avatar from "../Avatar";
import { Link } from "../Link";
import { IContextMenuItem } from "./../ContextMenuItem";
import MessageAttachment from "./MessageAttachment";
+import MessageBase from "./MessageBase";
import MessageEmbed from "./MessageEmbed";
+import MessageTimestamp from "./MessageTimestamp";
+import SystemMessage from "./SystemMessage";
import AttachmentUploadProgress from "./attachments/AttachmentUploadProgress";
const MessageListItem = styled.li`
@@ -37,6 +40,7 @@ const MessageHeader = styled.div`
display: flex;
flex: 1;
flex-direction: row;
+ align-items: center;
`;
const MessageAuthor = styled.div`
@@ -44,13 +48,6 @@ const MessageAuthor = styled.div`
font-weight: var(--font-weight-medium);
`;
-const MessageTimestamp = styled.div`
- font-size: 14px;
- font-weight: var(--font-weight-regular);
- margin-left: 10px;
- color: var(--text-secondary);
-`;
-
const MessageContent = styled.div<{ sending?: boolean; failed?: boolean }>`
font-size: 16px;
font-weight: var(--font-weight-light);
@@ -60,31 +57,22 @@ const MessageContent = styled.div<{ sending?: boolean; failed?: boolean }>`
color: ${(props) => (props.failed ? "var(--error)" : undefined)};
`;
-// converts URLs in a string to html links
-const Linkify = ({ children }: { children: string }) => {
- const urlPattern = /\bhttps?:\/\/\S+\b\/?/g;
- const matches = children.match(urlPattern);
- if (!matches) return <>{children}>;
+const MessageHeaderWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
- const elements = [];
- let lastIndex = 0;
+function parseMessageContent(content?: string | null) {
+ if (!content) return null;
+ // replace links with Link components
+ const replacedText = reactStringReplace(content, /(https?:\/\/\S+)/g, (match, i) => (
+
+ {match}
+
+ ));
- for (const match of matches) {
- const matchIndex = children.indexOf(match, lastIndex);
- if (matchIndex > lastIndex) elements.push(children.substring(lastIndex, matchIndex));
-
- elements.push(
-
- {match}
- ,
- );
- lastIndex = matchIndex + match.length;
- }
-
- if (lastIndex < children.length) elements.push(children.substring(lastIndex));
-
- return <>{elements}>;
-};
+ return replacedText;
+}
interface Props {
message: MessageLike;
@@ -110,90 +98,99 @@ function Message({ message, isHeader, isSending, isFailed }: Props) {
},
]);
- // construct the context menu options
- // React.useEffect(() => {
- // // if the message is queued, we don't need a context menu
- // if (isSending) {
- // return;
- // }
-
- // // add delete/resend option if the current user is the message author
- // // if (author?.id === domain.account?.id) {
- // // items.push({
- // // label: failed ? 'Resend Message' : 'Delete Message',
- // // onPress: () => {
- // // // TODO: implement
- // // console.debug(
- // // failed ? 'should resend message' : 'should delete message',
- // // );
- // // },
- // // color: theme.colors.palette.red40,
- // // iconProps: {
- // // name: failed ? 'reload' : 'delete',
- // // },
- // // });
- // // }
-
- // // setContextMenuOptions(items);
- // }, [isSending, isFailed]);
-
- // handles creating the message content based on the message type
- // TODO: probably move this to a separate component
- const renderMessageContent = React.useCallback(() => {
- switch (message.type) {
- case MessageType.Default:
- return (
-
- {message.content ? {message.content} : null}
- {"attachments" in message
- ? message.attachments.map((attachment, index) => (
-
-
-
- ))
- : null}
- {"embeds" in message
- ? message.embeds.map((embed, index) => (
-
-
-
- ))
- : null}
-
- );
- case MessageType.UserJoin: {
- // TODO: render only the join message and timestamp, will require a bit of refactoring
- const msg = message.getJoinMessage();
- const split = msg.split("{author}");
- return (
- (
+
+ {showHeader && (
+
- {split[0]}
-
- {message.author.username}
-
- {split[1]}
-
- );
- }
- default:
- return (
-
+ />
+ )}
+
+
+ {showHeader && (
+
+ {message.author.username}
+
+
+ )}
+
+ {children}
+
+ {"files" in message && message.files?.length !== 0 && (
+
+ )}
+
+
+ ),
+ [message, contextMenuItems],
+ );
+
+ const constructDefaultMessage = React.useCallback(
+ () =>
+ withMessageHeader(
+
+ {message.type !== MessageType.Default && (
MessageType({MessageType[message.type]})
- {message.content}
-
- );
+ )}
+ {parseMessageContent(message.content)}
+ {"attachments" in message
+ ? message.attachments.map((attachment, index) => (
+
+
+
+ ))
+ : null}
+ {"embeds" in message
+ ? message.embeds.map((embed, index) => (
+
+
+
+ ))
+ : null}
+ ,
+ isHeader,
+ ),
+ [message, isHeader],
+ );
+
+ const constructJoinMessage = React.useCallback(() => {
+ const joinMessage = message.getJoinMessage();
+ return (
+
+ {reactStringReplace(joinMessage, "{author}", (_, i) => (
+
+ {message.author.username}
+
+ ))}
+
+ );
+ }, [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]);
@@ -212,32 +209,7 @@ function Message({ message, isHeader, isSending, isFailed }: Props) {
});
}}
>
- {isHeader && (
-
- )}
-
-
- {isHeader && (
-
- {message.author.username}
- {dayjs(message.timestamp).calendar()}
-
- )}
-
- {renderMessageContent()}
-
- {"files" in message && message.files?.length !== 0 && (
-
- )}
-
+ {renderMessageContent()}
);
diff --git a/src/components/messaging/MessageBase.tsx b/src/components/messaging/MessageBase.tsx
new file mode 100644
index 0000000..f6de43b
--- /dev/null
+++ b/src/components/messaging/MessageBase.tsx
@@ -0,0 +1,17 @@
+import styled from "styled-components";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: row;
+ position: relative;
+ padding: 2px 12px;
+
+ &:hover {
+ background-color: var(--background-primary-highlight);
+ }
+`;
+function MessageBase({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+export default MessageBase;
diff --git a/src/components/messaging/MessageTimestamp.tsx b/src/components/messaging/MessageTimestamp.tsx
new file mode 100644
index 0000000..d037c15
--- /dev/null
+++ b/src/components/messaging/MessageTimestamp.tsx
@@ -0,0 +1,22 @@
+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;
+`;
+
+function MessageTimestamp({ date }: { date: string | number | Date | dayjs.Dayjs }) {
+ return (
+
+ {dayjs(date).calendar(undefined, calendarStrings)}
+
+ );
+}
+
+export default MessageTimestamp;
diff --git a/src/components/messaging/SystemMessage.tsx b/src/components/messaging/SystemMessage.tsx
new file mode 100644
index 0000000..1f4bf03
--- /dev/null
+++ b/src/components/messaging/SystemMessage.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import styled from "styled-components";
+import { MessageLike } from "../../stores/objects/Message";
+import Icon, { IconProps } from "../Icon";
+import MessageTimestamp from "./MessageTimestamp";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+interface Props {
+ message: MessageLike;
+ children: React.ReactNode;
+ iconProps?: IconProps;
+}
+
+function SystemMessage({ message, children, iconProps }: Props) {
+ return (
+
+ {iconProps && }
+
+ {children}
+
+
+
+ );
+}
+
+export default SystemMessage;
diff --git a/src/index.tsx b/src/index.tsx
index 4999070..f4b0901 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -23,9 +23,10 @@ import { BannerContextProvider } from "./contexts/BannerContext";
import { ContextMenuContextProvider } from "./contexts/ContextMenuContext";
import Theme from "./contexts/Theme";
import "./index.css";
+import { calendarStrings } from "./utils/i18n";
dayjs.extend(relativeTime);
-dayjs.extend(calendar);
+dayjs.extend(calendar, calendarStrings);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
diff --git a/src/stores/MessageStore.ts b/src/stores/MessageStore.ts
index 5291995..3110d0f 100644
--- a/src/stores/MessageStore.ts
+++ b/src/stores/MessageStore.ts
@@ -1,4 +1,4 @@
-import type { APIMessage } from "@spacebarchat/spacebar-api-types/v9";
+import { MessageType, type APIMessage } from "@spacebarchat/spacebar-api-types/v9";
import type { IObservableArray } from "mobx";
import { action, computed, makeObservable, observable } from "mobx";
import useLogger from "../hooks/useLogger";
@@ -89,6 +89,8 @@ export default class MessageStore {
if (
lastMessage &&
lastMessage.author.id === message.author.id &&
+ lastMessage.type === message.type &&
+ message.type === MessageType.Default &&
message.timestamp.getTime() - lastMessage.timestamp.getTime() <= 10 * 60 * 1000
) {
// add to last group
diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts
index d694bbf..5f5e4e1 100644
--- a/src/utils/i18n.ts
+++ b/src/utils/i18n.ts
@@ -1,8 +1,7 @@
export const calendarStrings = {
- sameDay: "[Today at] h:mm A", // The same day ( Today at 2:30 AM )
- nextDay: "[Tomorrow at] h:mm A", // The next day ( Tomorrow at 2:30 AM )
- nextWeek: "dddd [at] h:mm A", // The next week ( Sunday at 2:30 AM )
- lastDay: "[Yesterday at] h:mm A", // The day before ( Yesterday at 2:30 AM )
- lastWeek: "[Last] dddd [at] h:mm A", // Last week ( Last Monday at 2:30 AM )
- sameElse: "MM/DD/YYYY h:mm A", // Everything else ( 17/10/2011 )
+ sameDay: "[Today at] h:mm A", // The same day (Today at 2:30 AM)
+ nextDay: "[Tomorrow at] h:mm A", // The next day (Tomorrow at 2:30 AM)
+ lastDay: "[Yesterday at] h:mm A", // The day before (Yesterday at 2:30 AM)
+ lastWeek: "[Last] dddd [at] h:mm A", // Last week (Last Monday at 2:30 AM)
+ sameElse: "MM/DD/YYYY h:mm A", // Everything else (01/19/2018 2:30 AM)
};