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