new-client/src/components/MessageRenderer.tsx

Summary

Maintainability
C
1 day
Test Coverage
import React, { FunctionComponent, useContext, useState, KeyboardEvent } from 'react';
import {
  Button,
  Box,
  Flex,
  Icon,
  Text,
  Badge,
  Image,
  IconButton,
  Menu,
  MenuButton,
  MenuList,
  MenuItem,
  Input,
  Portal
} from '@chakra-ui/react';
import styled from '@emotion/styled';
import { IoMenu, IoPencil } from 'react-icons/io5';
import {
  Callout,
  CodeBlock,
  createIFrameHandler,
  createLinkHandler,
  Doc,
  Heading,
  MarkMap,
  RemirrorRenderer,
  TextHandler
} from '@remirror/react';
import { ProsemirrorNode } from '@remirror/core';
import { observer } from 'mobx-react-lite';
import { ServerContext } from './ServerContext';
import { ImageModal, YouTubePreview, UserInfoPopover, NickLabel } from '.';
import { ModalContext } from './ModalContext';
import MessageModel from '../models/Message';
import UserModel from '../models/User';
import { Network } from '../types/notifications';
import { UserStore } from '../stores';

//const TWEMOJI_CDN_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/13.0.1';

const OverridesWrapper = styled.div`
  &,
  & > div,
  & > div > p {
    display: inline;
  }
`;

const renderMention = (user: UserModel, network: Network, insertAt = false) => (
  <UserInfoPopover key={user.nick[network]} user={user}>
    <NickLabel>
      {insertAt ? '@' : ''}
      {user.nick[network]}
    </NickLabel>
  </UserInfoPopover>
);

const createMentionHandler = (userStore: UserStore, network: Network) => {
  const mentionHandler: FunctionComponent<{ node: ProsemirrorNode }> = ({ node }) => {
    const user = userStore.users.get(node.attrs.id);

    if (!user) {
      return <span>{node.attrs.label}</span>;
    }

    return renderMention(user, network, true);
  };

  return mentionHandler;
};

const createEmojiHandler = () => {
  const emojiHandler: FunctionComponent<{ node: ProsemirrorNode }> = ({ node }) => node.attrs.unicode;

  return emojiHandler;
};

interface MessageRowProps {
  message: MessageModel;
  isUnread: boolean;
}

const MessageRow: FunctionComponent<MessageRowProps> = ({ message, isUnread }: MessageRowProps) => {
  const modal = useContext(ModalContext);
  const { windowStore, userStore } = useContext(ServerContext);
  const [isFocused, setFocused] = useState<boolean>(false);
  const [editedBody, setEditedBody] = useState<string | null>(null);

  const showModal = (url: URI) => modal.onShow(<ImageModal src={url.toString()} />);

  const markMap: MarkMap = {
    italic: 'em',
    bold: 'strong',
    code: 'code',
    link: createLinkHandler({ target: '_blank' }),
    underline: 'u'
  };

  const typeMap: MarkMap = {
    blockquote: 'blockquote',
    bulletList: 'ul',
    callout: Callout,
    codeBlock: CodeBlock,
    doc: Doc,
    hardBreak: 'br',
    heading: Heading,
    horizontalRule: 'hr',
    iframe: createIFrameHandler(),
    image: 'img',
    listItem: 'li',
    paragraph: 'p',
    orderedList: 'ol',
    text: TextHandler,
    mentionAtom: createMentionHandler(userStore, message.window.network),
    messageEmoji: createEmojiHandler()
  };

  const renderImagePreviews = () =>
    message.images.map(image => (
      <Image
        onClick={() => showModal(image.url)}
        key={image.url.toString()}
        maxHeight="8rem"
        m="1rem"
        src={image.url.toString()}
      />
    ));

  const renderVideoPreviews = () =>
    message.videos.map(video => (
      <YouTubePreview key={video.videoId} videoId={video.videoId} startTime={video.startTime} />
    ));

  const renderUserMessage = () => {
    if (message.doc) {
      return (
        <OverridesWrapper className="testing">
          <RemirrorRenderer json={message.doc} typeMap={typeMap} markMap={markMap} />
        </OverridesWrapper>
      );
    }

    return message.body;
  };

  const editedLabel = () =>
    message.edited && (
      <Text as="span" fontSize="xs" ml="0.5rem">
        <Icon as={IoPencil} /> {message.updatedTime}
      </Text>
    );

  const cancelEdit = () => {
    setEditedBody(null);
  };

  const saveEdit = () => {
    windowStore.editMessage(message, editedBody || '');
    setEditedBody(null);
  };

  const onKeyUp = (event: KeyboardEvent) => {
    if (event.key === 'Enter') {
      saveEdit();
    }

    if (event.key === 'Escape') {
      cancelEdit();
    }
  };

  const renderMessage = () => {
    const color = message.isFromMe ? 'blue.600' : undefined;
    const nickColor = message.isFromMe ? 'blue.600' : '#617eb5';

    if (editedBody) {
      return (
        <>
          <Input onKeyUp={onKeyUp} onChange={e => setEditedBody(e.target.value)} value={editedBody} autoFocus />
          <Button onClick={saveEdit} size="xs" my="0.5rem">
            Change
          </Button>
          <Button onClick={cancelEdit} size="xs" variant="ghost" ml="0.5rem" my="0.5rem">
            Cancel
          </Button>
        </>
      );
    }

    return (
      <>
        <UserInfoPopover user={message.user}>
          <Text fontWeight="extrabold" display="inline-block" flex="1" color={nickColor}>
            {message.nick}:
          </Text>
        </UserInfoPopover>{' '}
        {message.deleted ? (
          <Badge variant="subtle" colorScheme="red">
            DELETED
          </Badge>
        ) : (
          <Box overflowWrap="break-word" wordBreak="break-word" as="span" color={color}>
            {renderUserMessage()}
            {editedLabel()}
          </Box>
        )}
      </>
    );
  };

  const renderNotMessage = () => {
    if (message.isChannelAction) {
      return (
        <Text overflowWrap="break-word" wordBreak="break-word" as="span">
          {renderMention(message.user, message.window.network)} {message.channelAction}
        </Text>
      );
    } else if (message.isBanner) {
      return (
        <Text fontFamily="monospace" whiteSpace="pre">
          {message.body}
        </Text>
      );
    } else if (message.isServerNote) {
      return <Text color="yellow.600">{message.body}</Text>;
    } else if (message.isInfo) {
      return <Text color="green.600">{message.body}</Text>;
    } else if (message.isError) {
      return <Text color="red.600">{message.body}</Text>;
    }

    return message.body;
  };

  const handleFocused = () => {
    setFocused(true);
  };

  const handleUnfocused = () => {
    setFocused(false);
  };

  const handleDelete = () => {
    windowStore.editMessage(message, '');
  };

  const handleEdit = () => {
    setEditedBody(message.body);
  };

  return (
    <Flex
      key={message.gid}
      flexDirection="row"
      fontSize="15px"
      width="100%"
      bgColor={isUnread ? '#ff024d1a' : 'transparent'}
      transition="background-color 1s ease-in"
    >
      <Flex
        width="100%"
        onMouseEnter={handleFocused}
        onMouseLeave={handleUnfocused}
        bgColor={isFocused ? '#253f726b' : 'transparent'}
      >
        {message.isMessageFromUser && message.isFromMe && !editedBody && (
          <Box position="absolute" right="0">
            <Menu isLazy flip={false} placement="left">
              <MenuButton
                visibility={isFocused ? 'visible' : 'hidden'}
                as={IconButton}
                width="1.1rem"
                minWidth="1.2rem"
                height="1.2rem"
                variant="ghost"
                aria-label="Menu"
                fontSize="1rem"
                icon={<IoMenu />}
              />
              <Portal>
                <MenuList minWidth="0">
                  <MenuItem onClick={handleEdit}>Edit</MenuItem>
                  <MenuItem onClick={handleDelete}>Delete</MenuItem>
                </MenuList>
              </Portal>
            </Menu>
          </Box>
        )}
        <Box minWidth="50px">{message.createdTime}</Box>
        <Box flex="1">
          {message.isMessageFromUser ? renderMessage() : renderNotMessage()}
          {message.hasImages && <Flex flexDirection="row">{renderImagePreviews()}</Flex>}
          {message.hasVideos && (
            <Flex flexDirection="row" height="180px" m="1rem">
              {renderVideoPreviews()}
            </Flex>
          )}
        </Box>
      </Flex>
    </Flex>
  );
};

export default observer(MessageRow);