diff --git a/app/Root.jsx b/app/Root.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9afe52fec0dc5a78db0d3e590e500489f45351c9
--- /dev/null
+++ b/app/Root.jsx
@@ -0,0 +1,119 @@
+/* eslint-disable no-param-reassign */
+import React from 'react'
+import { BrowserRouter } from 'react-router-dom'
+import PropTypes from 'prop-types'
+import { ThemeProvider } from 'styled-components'
+import { ApolloProvider } from '@apollo/react-components'
+import { ApolloClient } from 'apollo-client'
+import { WebSocketLink } from 'apollo-link-ws'
+import { split, ApolloLink } from 'apollo-link'
+import { getMainDefinition } from 'apollo-utilities'
+import { setContext } from 'apollo-link-context'
+import {
+  InMemoryCache,
+  IntrospectionFragmentMatcher,
+} from 'apollo-cache-inmemory'
+import { createUploadLink } from 'apollo-upload-client'
+
+import introspectionQueryResultData from './fragmentTypes.json'
+
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+  introspectionQueryResultData,
+})
+
+// See https://github.com/apollographql/apollo-feature-requests/issues/6#issuecomment-465305186
+export function stripTypenames(obj) {
+  Object.keys(obj).forEach(property => {
+    if (
+      obj[property] !== null &&
+      typeof obj[property] === 'object' &&
+      !(obj[property] instanceof File)
+    ) {
+      delete obj.property
+      const newData = stripTypenames(obj[property], '__typename')
+      obj[property] = newData
+    } else if (property === '__typename') {
+      delete obj[property]
+    }
+  })
+  return obj
+}
+// Construct an ApolloClient. If a function is passed as the first argument,
+// it will be called with the default client config as an argument, and should
+// return the desired config.
+const makeApolloClient = (makeConfig, connectToWebSocket) => {
+  const uploadLink = createUploadLink()
+  const authLink = setContext((_, { headers }) => {
+    const token = localStorage.getItem('token')
+    return {
+      headers: {
+        ...headers,
+        authorization: token ? `Bearer ${token}` : '',
+      },
+    }
+  })
+
+  const removeTypename = new ApolloLink((operation, forward) => {
+    if (operation.variables) {
+      operation.variables = stripTypenames(operation.variables)
+    }
+    return forward(operation)
+  })
+
+  let link = ApolloLink.from([removeTypename, authLink, uploadLink])
+
+  if (connectToWebSocket) {
+    const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
+    const wsLink = new WebSocketLink({
+      uri: `${wsProtocol}://${window.location.host}/subscriptions`,
+      options: {
+        reconnect: true,
+        connectionParams: () => ({ authToken: localStorage.getItem('token') }),
+      },
+    })
+    link = split(
+      ({ query }) => {
+        const { kind, operation } = getMainDefinition(query)
+        return kind === 'OperationDefinition' && operation === 'subscription'
+      },
+      wsLink,
+      link,
+    )
+  }
+  const config = {
+    link,
+    cache: new InMemoryCache({ fragmentMatcher }),
+  }
+  return new ApolloClient(makeConfig ? makeConfig(config) : config)
+}
+
+const Root = ({
+  makeApolloConfig,
+  routes,
+  theme,
+  connectToWebSocket = true,
+}) => (
+  <div>
+    <ApolloProvider
+      client={makeApolloClient(makeApolloConfig, connectToWebSocket)}
+    >
+      <BrowserRouter>
+        <ThemeProvider theme={theme}>{routes}</ThemeProvider>
+      </BrowserRouter>
+    </ApolloProvider>
+  </div>
+)
+
+Root.defaultProps = {
+  makeApolloConfig: config => config,
+  connectToWebSocket: true,
+}
+Root.propTypes = {
+  makeApolloConfig: PropTypes.func,
+  routes: PropTypes.node.isRequired,
+  // eslint-disable-next-line react/forbid-prop-types
+  theme: PropTypes.object.isRequired,
+  connectToWebSocket: PropTypes.bool,
+}
+
+export default Root
diff --git a/app/app.js b/app/app.js
index b49bf20d8f02403a4ad7fc7d1476969b2118f7ff..d733852964afb483e1e805f4f8891b9282c300b9 100644
--- a/app/app.js
+++ b/app/app.js
@@ -2,7 +2,7 @@ import 'regenerator-runtime/runtime'
 import React from 'react'
 import ReactDOM from 'react-dom'
 import { hot } from 'react-hot-loader'
-import { Root } from 'pubsweet-client'
+import Root from './Root'
 import { createBrowserHistory } from 'history'
 import theme from './theme'
 
diff --git a/app/components/App.js b/app/components/App.js
index 17c90fdba0f1227a13c30f84b5d5ccf6d706efa1..ca0ab2b6a1421ac00002f9d7f8a74405eb9b9d88 100644
--- a/app/components/App.js
+++ b/app/components/App.js
@@ -61,6 +61,7 @@ const App = ({ authorized, children, history, match }) => {
   const showLinks = pathname.match(/submit|manuscript/g)
   let links = []
   const formBuilderLink = `/admin/form-builder`
+  const profileLink = `/profile`
 
   if (showLinks) {
     const params = getParams(pathname)
@@ -88,6 +89,15 @@ const App = ({ authorized, children, history, match }) => {
       : null
   }
 
+  links.push(
+    <Action
+      active={window.location.pathname === profileLink ? 'active' : null}
+      to={profileLink}
+    >
+      Profile
+    </Action>,
+  )
+
   if (currentUser && currentUser.admin) {
     links.push(
       <Action
diff --git a/app/components/ConditionalWrap.jsx b/app/components/ConditionalWrap.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0db9a1a141f6785bb2ef2f9404ae854c34d09a67
--- /dev/null
+++ b/app/components/ConditionalWrap.jsx
@@ -0,0 +1,4 @@
+export default function ConditionalWrap({ condition, wrap, children }) {
+  console.log(children, wrap)
+  return condition ? wrap(children) : children
+}
diff --git a/app/components/NextPageButton/index.js b/app/components/NextPageButton/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..ed990882a86ce66932225042c8bbc4fe161ae717
--- /dev/null
+++ b/app/components/NextPageButton/index.js
@@ -0,0 +1,66 @@
+import VisibilitySensor from 'react-visibility-sensor'
+import { Link } from 'react-router-dom'
+import React from 'react'
+import PropTypes from 'prop-types'
+import Spinner from '../Spinner'
+import { HasNextPage, NextPageButton } from './style'
+
+const NextPageButtonWrapper = props => {
+  const {
+    isFetchingMore,
+    fetchMore,
+    href,
+    children,
+    automatic = true,
+    topOffset = -250,
+    bottomOffset = -250,
+  } = props
+  const onChange = isVisible => {
+    if (isFetchingMore || !isVisible) return undefined
+    return fetchMore()
+  }
+  return (
+    <HasNextPage
+      as={href ? Link : 'div'}
+      data-cy="load-previous-messages"
+      onClick={evt => {
+        evt.preventDefault()
+        onChange(true)
+      }}
+      to={href}
+    >
+      <VisibilitySensor
+        active={automatic !== false && !isFetchingMore}
+        delayedCall
+        intervalDelay={150}
+        offset={{
+          top: topOffset,
+          bottom: bottomOffset,
+        }}
+        onChange={onChange}
+        partialVisibility
+        scrollCheck
+      >
+        <NextPageButton>
+          {isFetchingMore ? (
+            <Spinner color="brand.default" size={16} />
+          ) : (
+            children || 'Load more'
+          )}
+        </NextPageButton>
+      </VisibilitySensor>
+    </HasNextPage>
+  )
+}
+
+NextPageButtonWrapper.propTypes = {
+  isFetchingMore: PropTypes.bool,
+  href: PropTypes.object,
+  fetchMore: PropTypes.func.isRequired,
+  children: PropTypes.string,
+  automatic: PropTypes.bool,
+  topOffset: PropTypes.number,
+  bottomOffset: PropTypes.number,
+}
+
+export default NextPageButtonWrapper
diff --git a/app/components/NextPageButton/style.js b/app/components/NextPageButton/style.js
new file mode 100644
index 0000000000000000000000000000000000000000..4a61a29251c583028ea13e34ad8578d6279c8fc0
--- /dev/null
+++ b/app/components/NextPageButton/style.js
@@ -0,0 +1,37 @@
+import { Link } from 'react-router-dom'
+import styled from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+import { hexa } from '../../globals'
+import theme from '../../theme'
+
+export const HasNextPage = styled(Link)`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  text-decoration: none;
+  background: ${th('colorBackground')};
+  width: 100%;
+`
+
+export const NextPageButton = styled.span`
+  display: flex;
+  flex: 1;
+  margin-top: 16px;
+  justify-content: center;
+  padding: 8px;
+  // background: ${hexa(theme.colorPrimary, 0.04)};
+  color: ${theme.colorPrimary};
+  border-top: 1px solid ${hexa(theme.colorPrimary, 0.06)};
+  border-bottom: 1px solid ${hexa(theme.colorPrimary, 0.06)};
+  font-size: 15px;
+  font-weight: 500;
+  position: relative;
+  min-height: 40px;
+  width: 100%;
+
+  &:hover {
+    color: ${theme.colorPrimary};
+    cursor: pointer;
+    background: ${hexa(theme.colorPrimary, 0.08)};
+  }
+`
diff --git a/app/components/Spinner.jsx b/app/components/Spinner.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..61dbc73f606310b173fb35ff42f553b70e4bf368
--- /dev/null
+++ b/app/components/Spinner.jsx
@@ -0,0 +1,37 @@
+import React from 'react'
+import styled from 'styled-components'
+import { rotate360, th } from '@pubsweet/ui-toolkit'
+
+// Courtesy of loading.io/css
+const Spinner = styled.div`
+  display: inline-block;
+  height: 64px;
+  width: 64px;
+
+  &:after {
+    animation: ${rotate360} 1s linear infinite;
+    border: 5px solid ${th('colorPrimary')};
+    border-color: ${th('colorPrimary')} transparent ${th('colorPrimary')}
+      transparent;
+    border-radius: 50%;
+    content: ' ';
+    display: block;
+    height: 46px;
+    margin: 1px;
+    width: 46px;
+  }
+`
+
+const LoadingPage = styled.div`
+  align-items: center;
+  display: flex;
+  height: 100%;
+  justify-content: center;
+  padding-bottom: calc(${th('gridUnit')} * 2);
+`
+
+export default () => (
+  <LoadingPage>
+    <Spinner />
+  </LoadingPage>
+)
diff --git a/app/components/component-avatar/src/UserAvatar.js b/app/components/component-avatar/src/UserAvatar.js
new file mode 100644
index 0000000000000000000000000000000000000000..55f93f0e121c12507954bd131d6bd4a06cd6283c
--- /dev/null
+++ b/app/components/component-avatar/src/UserAvatar.js
@@ -0,0 +1,123 @@
+// @flow
+import * as React from 'react'
+import { useQuery } from '@apollo/react-hooks'
+import styled from 'styled-components'
+// import { GET_USER } from '../../queries'
+// import { UserHoverProfile } from 'src/components/hoverProfile';
+import AvatarImage from './image'
+import { Container, AvatarLink, OnlineIndicator } from './style'
+import ConditionalWrap from '../../ConditionalWrap'
+import gql from 'graphql-tag'
+
+export const GET_USER = gql`
+  query user($id: ID, $username: String) {
+    user(id: $id, username: $username) {
+      id
+      username
+      profilePicture
+      online
+    }
+  }
+`
+const UserHoverProfile = styled.div``
+
+const GetUserByUsername = props => {
+  const { username, showHoverProfile = true } = props
+  const { data } = useQuery(GET_USER, { variables: { username } })
+
+  if (!data || !data.user) return null
+  return (
+    <ConditionalWrap
+      condition={showHoverProfile}
+      wrap={() => (
+        <UserHoverProfile username={props.username}>
+          <Avatar user={data.user} {...props} />
+        </UserHoverProfile>
+      )}
+    >
+      <Avatar user={data.user} {...props} />
+    </ConditionalWrap>
+  )
+}
+
+const Avatar = props => {
+  const {
+    user,
+    dataCy,
+    size = 32,
+    mobilesize,
+    style,
+    showOnlineStatus = true,
+    isClickable = true,
+    onlineBorderColor = null,
+  } = props
+
+  const src = user.profilePicture
+
+  const userFallback = '/static/profiles/default_avatar.svg'
+  const source = [src, userFallback]
+
+  return (
+    <Container
+      data-cy={dataCy}
+      mobileSize={mobilesize}
+      size={size}
+      style={style}
+      type="user"
+    >
+      {showOnlineStatus && user.online && (
+        <OnlineIndicator onlineBorderColor={onlineBorderColor} />
+      )}
+      <ConditionalWrap
+        condition={!!user.username && isClickable}
+        wrap={() => (
+          <AvatarLink to={`/users/${user.username}`}>
+            <AvatarImage
+              mobilesize={mobilesize}
+              size={size}
+              src={source}
+              type="user"
+            />
+          </AvatarLink>
+        )}
+      >
+        <AvatarImage
+          mobilesize={mobilesize}
+          size={size}
+          src={source}
+          type="user"
+        />
+      </ConditionalWrap>
+    </Container>
+  )
+}
+
+const AvatarHandler = props => {
+  const { showHoverProfile = true, isClickable } = props
+
+  if (props.user) {
+    const { user } = props
+    return (
+      <ConditionalWrap
+        condition={showHoverProfile}
+        wrap={() => (
+          <UserHoverProfile username={user.username}>
+            <Avatar {...props} />
+          </UserHoverProfile>
+        )}
+      >
+        <Avatar {...props} />
+      </ConditionalWrap>
+    )
+  }
+
+  if (!props.user && props.username) {
+    return (
+      <GetUserByUsername isClickable={isClickable} username={props.username} />
+    )
+  }
+
+  return null
+}
+
+export default AvatarHandler
diff --git a/app/components/component-avatar/src/image.js b/app/components/component-avatar/src/image.js
new file mode 100644
index 0000000000000000000000000000000000000000..ca7d8aa6d1b031eca25a25f696fc36075b9652b0
--- /dev/null
+++ b/app/components/component-avatar/src/image.js
@@ -0,0 +1,48 @@
+import * as React from 'react'
+import VisibilitySensor from 'react-visibility-sensor'
+
+import { Img, FallbackImg, LoadingImg } from './style'
+
+// type Props = {
+//   src: any,
+//   type: 'user' | 'community',
+//   size: number,
+//   mobilesize?: number,
+//   isClickable?: boolean,
+// };
+
+export default function Image(props) {
+  const { type, size, mobilesize } = props
+  const { ...rest } = props
+  const fallbackSrc =
+    type === 'user'
+      ? '/static/profiles/default_avatar.svg'
+      : '/static/profiles/default_community.svg'
+
+  return (
+    <VisibilitySensor>
+      <Img
+        {...rest}
+        decode={false}
+        loader={
+          <LoadingImg
+            alt=""
+            mobilesize={mobilesize}
+            size={size}
+            src={fallbackSrc}
+            type={type}
+          />
+        }
+        unloader={
+          <FallbackImg
+            alt=""
+            mobilesize={mobilesize}
+            size={size}
+            src={fallbackSrc}
+            type={type}
+          />
+        }
+      />
+    </VisibilitySensor>
+  )
+}
diff --git a/app/components/component-avatar/src/index.js b/app/components/component-avatar/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..959dab127935b2cecdeef7967d5fad88d4b4e9ac
--- /dev/null
+++ b/app/components/component-avatar/src/index.js
@@ -0,0 +1,5 @@
+// import CommunityAvatar from './communityAvatar';
+import UserAvatar from './UserAvatar'
+
+// export { CommunityAvatar, UserAvatar };
+export { UserAvatar }
diff --git a/app/components/component-avatar/src/style.js b/app/components/component-avatar/src/style.js
new file mode 100644
index 0000000000000000000000000000000000000000..e674d291fa16434f22e81067e40c3cf8f882c7d0
--- /dev/null
+++ b/app/components/component-avatar/src/style.js
@@ -0,0 +1,121 @@
+// @flow
+// import theme from 'shared/theme'
+import styled, { css } from 'styled-components'
+import { Img as ReactImage } from 'react-image'
+import { Link } from 'react-router-dom'
+import { th } from '@pubsweet/ui-toolkit'
+// import { ProfileHeaderAction } from '../profile/style'
+import { MEDIA_BREAK } from '../../layout'
+// import { zIndex } from '../../globals'
+
+export const Container = styled.div`
+  position: relative;
+  display: inline-block;
+  width: ${props => (props.size ? `${props.size}px` : '32px')};
+  height: ${props => (props.size ? `${props.size}px` : '32px')};
+  border-radius: ${props =>
+    props.type === 'community' ? `${props.size / 8}px` : '100%'};
+  border: none;
+  background-color: ${th('colorBackground')};
+
+  ${props =>
+    props.mobilesize &&
+    css`
+      @media (max-width: ${MEDIA_BREAK}px) {
+        width: ${props => `${props.mobilesize}px`};
+        height: ${props => `${props.mobilesize}px`};
+      }
+    `};
+`
+
+export const AvatarLink = styled(Link)`
+  display: flex;
+  flex: none;
+  flex-direction: column;
+  height: 100%;
+  width: 100%;
+  justify-content: center;
+  align-items: center;
+  pointer-events: auto;
+  border-radius: ${props =>
+    props.type === 'community' ? `${props.size / 8}px` : '100%'};
+`
+
+// export const CoverAction = styled(ProfileHeaderAction)`
+//   position: absolute;
+//   top: 12px;
+//   right: 12px;
+//   z-index: ${zIndex.tooltip + 1};
+// `
+
+export const Img = styled(ReactImage)`
+  display: inline-block;
+  width: ${props => (props.size ? `${props.size}px` : '32px')};
+  height: ${props => (props.size ? `${props.size}px` : '32px')};
+  border-radius: ${props =>
+    props.type === 'community' ? `${props.size / 8}px` : '100%'};
+  object-fit: cover;
+  background-color: ${th('colorBackground')};
+
+  ${props =>
+    props.mobilesize &&
+    css`
+      @media (max-width: ${MEDIA_BREAK}px) {
+        width: ${props => `${props.mobilesize}px`};
+        height: ${props => `${props.mobilesize}px`};
+      }
+    `};
+`
+
+export const FallbackImg = styled.img`
+  display: inline-block;
+  width: ${props => (props.size ? `${props.size}px` : '32px')};
+  height: ${props => (props.size ? `${props.size}px` : '32px')};
+  border-radius: ${props =>
+    props.type === 'community' ? `${props.size / 8}px` : '100%'};
+  object-fit: cover;
+  background-color: ${th('colorSecondary')};
+
+  ${props =>
+    props.mobilesize &&
+    css`
+      @media (max-width: ${MEDIA_BREAK}px) {
+        width: ${props => `${props.mobilesize}px`};
+        height: ${props => `${props.mobilesize}px`};
+      }
+    `};
+`
+
+export const LoadingImg = styled.div`
+  display: inline-block;
+  width: ${props => (props.size ? `${props.size}px` : '32px')};
+  height: ${props => (props.size ? `${props.size}px` : '32px')};
+  border-radius: ${props =>
+    props.type === 'community' ? `${props.size / 8}px` : '100%'};
+  background: ${th('colorSecondary')};
+
+  ${props =>
+    props.mobilesize &&
+    css`
+      @media (max-width: ${MEDIA_BREAK}px) {
+        width: ${props => `${props.mobilesize}px`};
+        height: ${props => `${props.mobilesize}px`};
+      }
+    `};
+`
+
+export const OnlineIndicator = styled.span`
+  position: absolute;
+  width: 10px;
+  height: 10px;
+  border: 2px solid
+    ${props =>
+      props.onlineBorderColor
+        ? props.onlineBorderColor(props.theme)
+        : th('colorTextReverse')};
+  background: ${th('colorSuccess')};
+  border-radius: 5px;
+  bottom: 0;
+  right: 0;
+  z-index: 1;
+`
diff --git a/app/components/component-chat/package.json b/app/components/component-chat/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..a4962b5d2708d2c1edb4657088a957ca8815ea53
--- /dev/null
+++ b/app/components/component-chat/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "pubsweet-component-chat",
+  "version": "0.0.1",
+  "main": "src",
+  "author": "Collaborative Knowledge Foundation",
+  "license": "MIT",
+  "files": [
+    "src",
+    "dist"
+  ],
+  "dependencies": {
+  },
+  "peerDependencies": {
+  }
+}
diff --git a/app/components/component-chat/src/Action.js b/app/components/component-chat/src/Action.js
new file mode 100644
index 0000000000000000000000000000000000000000..5e7d6bc45746c9f3bf9292a54be785d2a9b3b64c
--- /dev/null
+++ b/app/components/component-chat/src/Action.js
@@ -0,0 +1,49 @@
+/*
+  Actions arose from current designs (like the Appbar) where we had to blend
+  links and buttons, but make them appear the same.
+
+  The Action centralizes the visual part of this scenario, while leaving the
+  underlying mechanics of links and buttons intact.
+
+  -- TODO
+  THIS COULD BE REMOVED IN THE FUTURE, AS IT IS UNCLEAR WHETHER WE SHOULD
+  HAVE LINKS AND BUTTONS THAT LOOK THE SAME IN THE FIRST PLACE.
+*/
+
+import React from 'react'
+import styled, { css } from 'styled-components'
+import { th, override } from '@pubsweet/ui-toolkit'
+
+import { Button } from '@pubsweet/ui'
+
+const common = css`
+  color: ${th('colorPrimary')};
+  font: ${th('fontInterface')};
+  font-size: ${th('fontSizeBase')};
+  font-weight: ${props => (props.active ? 'bold' : 'normal')};
+  text-decoration: none;
+  text-transform: none;
+  transition: ${th('transitionDuration')} ${th('transitionTimingFunction')};
+
+  &:hover,
+  &:active {
+    background: none;
+    color: ${th('colorPrimary')};
+    text-decoration: underline;
+  }
+`
+
+const ActionButton = styled.button`
+  background: none;
+  border: none;
+  min-width: 0;
+  padding: 0;
+
+  ${common};
+`
+
+const Action = props => {
+  return <ActionButton {...props}>{props.children}</ActionButton>
+}
+
+export default Action
diff --git a/app/components/component-chat/src/MentionsInput/MentionsInput.jsx b/app/components/component-chat/src/MentionsInput/MentionsInput.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ac03e902f11096057cdb18454e3c93ffae38dea4
--- /dev/null
+++ b/app/components/component-chat/src/MentionsInput/MentionsInput.jsx
@@ -0,0 +1,143 @@
+// @flow
+import React from 'react'
+import { MentionsInput, Mention } from 'react-mentions'
+import { useApolloClient } from '@apollo/react-hooks'
+import { MentionsInputStyle } from './style'
+import MentionSuggestion from './mentionSuggestion'
+import { SEARCH_USERS } from '../../../../queries'
+// import type { UserInfoType } from 'shared/graphql/fragments/user/userInfo';
+// import type { ApolloClient } from 'apollo-client';
+
+// type Props = {
+//   value: string,
+//   onChange: string => void,
+//   staticSuggestions?: Array<UserInfoType>,
+//   client: ApolloClient,
+//   placeholder?: string,
+//   hasAttachment?: boolean,
+//   onFocus?: Function,
+//   onBlur?: Function,
+//   onKeyDown?: Function,
+//   inputRef?: Function,
+//   dataCy?: string,
+//   networkDisabled?: boolean,
+// };
+
+const cleanSuggestionUserObject = user => {
+  if (!user) return null
+  return {
+    ...user,
+    id: user.username,
+    display: user.username,
+    filterName: (user.name && user.name.toLowerCase()) || user.username.toLowerCase(),
+  }
+}
+
+const sortSuggestions = (a, b, queryString) => {
+  const aUsernameIndex = a.username.indexOf(queryString || '')
+  const bUsernameIndex = b.username.indexOf(queryString || '')
+  const aNameIndex = a.filterName.indexOf(queryString || '')
+  const bNameIndex = b.filterName.indexOf(queryString || '')
+  if (aNameIndex === 0) return -1
+  if (aUsernameIndex === 0) return -1
+  if (aNameIndex === 0) return -1
+  if (aUsernameIndex === 0) return -1
+  return aNameIndex - bNameIndex || aUsernameIndex - bUsernameIndex
+}
+
+const CustomMentionsInput = props => {
+  const client = useApolloClient()
+  const searchUsers = async (queryString, callback) => {
+    const staticSuggestions = !props.staticSuggestions
+      ? []
+      : props.staticSuggestions
+          .map(cleanSuggestionUserObject)
+          .filter(Boolean)
+          .filter(
+            user =>
+              user.username &&
+              (user.username.indexOf(queryString || '') > -1 ||
+                user.filterName.indexOf(queryString || '') > -1),
+          )
+          .sort((a, b) => sortSuggestions(a, b, queryString))
+          .slice(0, 8)
+
+    callback(staticSuggestions)
+
+    if (!queryString || queryString.length === 0)
+      return callback(staticSuggestions)
+
+    const {
+      data: { searchUsers: rawSearchUsers },
+    } = await client.query({
+      query: SEARCH_USERS,
+      variables: {
+        query: queryString,
+      },
+    })
+
+    if (!rawSearchUsers || rawSearchUsers.length === 0) {
+      if (staticSuggestions && staticSuggestions.length > 0)
+        return staticSuggestions
+      return
+    }
+
+    const cleanSearchUsers = rawSearchUsers.map(user => cleanSuggestionUserObject(user))
+
+
+    // Prepend the filtered participants in case a user is tabbing down right now
+    const fullResults = [...staticSuggestions, ...cleanSearchUsers]
+    const uniqueResults = []
+    const done = []
+
+    fullResults.forEach(item => {
+      if (done.indexOf(item.username) === -1) {
+        uniqueResults.push(item)
+        done.push(item.username)
+      }
+    })
+
+    return callback(uniqueResults.slice(0, 8))
+  }
+
+  const {
+    dataCy,
+    networkDisabled,
+    staticSuggestions,
+    hasAttachment,
+    ...rest
+  } = props
+
+  return (
+    <MentionsInput
+      data-cy={props.dataCy}
+      {...rest}
+      style={{ ...(props.style || {}), ...MentionsInputStyle }}
+    >
+      <Mention
+        appendSpaceOnAdd
+        displayTransform={username => `@${username}`}
+        markup="@[__id__]"
+        data={searchUsers}
+        renderSuggestion={(
+          entry,
+          search,
+          highlightedDisplay,
+          index,
+          focused,
+        ) => (
+          <MentionSuggestion
+            entry={entry}
+            focused={focused}
+            highlightedDisplay={highlightedDisplay}
+            index={index}
+            search={search}
+          />
+        )}
+        trigger="@"
+      />
+    </MentionsInput>
+  )
+}
+
+export default CustomMentionsInput
diff --git a/app/components/component-chat/src/MentionsInput/mentionSuggestion.js b/app/components/component-chat/src/MentionsInput/mentionSuggestion.js
new file mode 100644
index 0000000000000000000000000000000000000000..da1a110d5239890213c4404d930be22151d62352
--- /dev/null
+++ b/app/components/component-chat/src/MentionsInput/mentionSuggestion.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import { UserAvatar } from '../../../component-avatar/src'
+
+import {
+  StyledMentionSuggestion,
+  MentionContent,
+  MentionName,
+  MentionUsername,
+} from './style'
+
+// import type { UserInfoType } from 'shared/graphql/fragments/user/userInfo';
+
+// type Props = {
+//   focused: boolean,
+//   search: string,
+//   entry: UserInfoType,
+// };
+
+const MentionSuggestion = ({ entry, search, focused }) => (
+  <StyledMentionSuggestion focused={focused}>
+    <UserAvatar size={32} user={entry} />
+    <MentionContent>
+      <MentionName focused={focused}>{entry.name}</MentionName>
+      <MentionUsername focused={focused}>@{entry.username}</MentionUsername>
+    </MentionContent>
+  </StyledMentionSuggestion>
+)
+
+export default MentionSuggestion
diff --git a/app/components/component-chat/src/MentionsInput/style.js b/app/components/component-chat/src/MentionsInput/style.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd277d83d72f913332e897a0e5d4d08ff1c0fcc0
--- /dev/null
+++ b/app/components/component-chat/src/MentionsInput/style.js
@@ -0,0 +1,55 @@
+import styled, { css } from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+// import theme from 'shared/theme';
+
+import { Truncate } from '../../../../globals'
+
+export const MentionsInputStyle = {
+  overflow: 'visible',
+  suggestions: {
+    zIndex: 99999,
+    list: {
+      backgroundColor: '#fff', // theme.bg.default,
+      boxShadow: '1px 0 12px rgba(0,0,0,0.12)',
+      borderRadius: '4px',
+      overflow: 'hidden',
+      bottom: '28px',
+      position: 'absolute',
+    },
+  },
+}
+
+export const StyledMentionSuggestion = styled.div`
+  display: flex;
+  padding: 8px 12px;
+  align-items: center;
+  background: ${props =>
+    props.focused ? th('colorBackgroundHue') : th('colorBackground')};
+  min-width: 156px;
+  line-height: 1.3;
+  border-bottom: 1px solid ${th('colorBorder')};
+`
+
+export const MentionContent = styled.div`
+  display: flex;
+  flex-direction: column;
+`
+
+export const MentionName = styled.span`
+  margin-left: 12px;
+  width: calc(184px - 62px);
+  ${Truncate};
+  font-size: 14px;
+  font-weight: 500;
+  color: ${props => (props.focused ? th('colorPrimary') : th('colorText'))};
+`
+
+export const MentionUsername = styled.span`
+  margin-left: 12px;
+  font-size: 13px;
+  font-weight: 400;
+  width: calc(184px - 62px);
+  ${Truncate};
+  color: ${props => (props.focused ? th('colorPrimary') : th('colorWarning'))};
+`
diff --git a/app/components/component-chat/src/Messages/Icon.jsx b/app/components/component-chat/src/Messages/Icon.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..47aeddc434dd162cfa16ed1dd7cbed3d886ce27e
--- /dev/null
+++ b/app/components/component-chat/src/Messages/Icon.jsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import _ from 'lodash'
+import * as icons from 'react-feather'
+import styled from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+const IconWrapper = styled.div`
+  display: flex;
+  -webkit-box-align: center;
+  align-items: center;
+  -webkit-box-pack: center;
+  justify-content: center;
+  opacity: 1;
+  position: relative;
+  border-radius: 6px;
+  padding: ${props => (props.noPadding ? '0' : '8px 12px')};
+
+  svg {
+    stroke: ${props => props.color || props.theme.colorText};
+    width: calc(${props => props.size} * ${th('gridUnit')});
+    height: calc(${props => props.size} * ${th('gridUnit')});
+  }
+`
+
+const Icon = ({
+  className,
+  children,
+  color,
+  size = 3,
+  noPadding,
+  ...props
+}) => {
+  const name = _.upperFirst(_.camelCase(children))
+
+  return (
+    <IconWrapper
+      className={className}
+      color={color}
+      noPadding={noPadding}
+      role="img"
+      size={size}
+    >
+      {icons[name]({})}
+    </IconWrapper>
+  )
+}
+
+Icon.defaultProps = {
+  size: 3,
+  color: '#111',
+}
+
+Icon.propTypes = {
+  children: PropTypes.string.isRequired,
+  size: PropTypes.number,
+  color: PropTypes.string,
+}
+
+export default Icon
diff --git a/app/components/component-chat/src/Messages/MessageRenderer.jsx b/app/components/component-chat/src/Messages/MessageRenderer.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d495fb8cd77206d3e91d18c8826e286c3325bcae
--- /dev/null
+++ b/app/components/component-chat/src/Messages/MessageRenderer.jsx
@@ -0,0 +1,118 @@
+import React from 'react'
+import ReactMarkdown from 'react-markdown'
+import htmlParser from 'react-markdown/plugins/html-parser'
+import { useQuery } from '@apollo/react-hooks'
+import gql from 'graphql-tag'
+import styled from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+import PaperEmbed from './PaperEmbed'
+
+import { AspectRatio, EmbedContainer, EmbedComponent } from './style'
+
+const ExternalEmbed = props => {
+  let { aspectratio, url, src, width = '100%', height = 200 } = props
+
+  if (!src && url) src = url
+  if (typeof src !== 'string') return null
+
+  // if an aspect ratio is passed in, we need to use the EmbedComponent which does some trickery with padding to force an aspect ratio. Otherwise we should just use a regular iFrame
+  if (aspectratio && aspectratio !== undefined) {
+    return (
+      <AspectRatio ratio={aspectratio} style={{ height }}>
+        <EmbedComponent
+          allowFullScreen
+          frameBorder="0"
+          height={height}
+          src={src}
+          title={`iframe-${src}`}
+          width={width}
+        />
+      </AspectRatio>
+    )
+  }
+  return (
+    <EmbedContainer style={{ height }}>
+      <iframe
+        allowFullScreen
+        frameBorder="0"
+        height={height}
+        src={src}
+        title={`iframe-${src}`}
+        width={width}
+      />
+    </EmbedContainer>
+  )
+}
+
+const InternalEmbed = props => {
+  if (props.entity !== 'thread') return null
+  return 'INTERNAL'
+  // return <ThreadAttachment id={props.id} />
+}
+
+const Embed = props => {
+  // if (props.type === 'internal') {
+  //   return <InternalEmbed {...props} />
+  // }
+
+  // if (props.type === 'paper') {
+  //   return <PaperEmbed {...props} />
+  // }
+  return <></>
+  // return <ExternalEmbed {...props} />
+}
+
+// var preprocessingInstructions = [
+//   {
+//     shouldPreprocessNode: function (node) {
+//       return node.attribs && node.attribs['data-process'] === 'shared';
+//     },
+//     preprocessNode: function (node) {
+//       node.attribs = {id: `preprocessed-${node.attribs.id}`,};
+//     },
+//   }
+// ];
+const processingInstructions = [
+  {
+    shouldProcessNode(node) {
+      return node.name === 'embed'
+    },
+    processNode(node, children, index) {
+      return <Embed {...node.attribs} id={node.attribs.id} key={index} />
+    },
+  },
+  {
+    shouldProcessNode(node) {
+      return node.name === 'p'
+    },
+    processNode(node, children, index) {
+      return (
+        <div {...node.attribs} id={node.attribs.id} key={index}>
+          {children}
+        </div>
+      )
+    },
+  },
+]
+
+const parseHtml = htmlParser({
+  processingInstructions,
+})
+
+const MessageRenderer = React.memo(({ message }) => {
+  const p = props => <div>{props.children}</div>
+
+  return message.enhanced ? (
+    <ReactMarkdown
+      astPlugins={[parseHtml]}
+      escapeHtml={false}
+      renderers={{ paragraph: p }}
+      source={message.enhanced}
+    />
+  ) : (
+    <ReactMarkdown source={message.content} />
+  )
+})
+
+export default MessageRenderer
diff --git a/app/components/component-chat/src/Messages/Messages.jsx b/app/components/component-chat/src/Messages/Messages.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..488c8002e4da727d00404788c56961b2916d47e4
--- /dev/null
+++ b/app/components/component-chat/src/Messages/Messages.jsx
@@ -0,0 +1,260 @@
+import React, { useEffect } from 'react'
+import gql from 'graphql-tag'
+// import styled from 'styled-components'
+import { useQuery } from '@apollo/react-hooks'
+import PropTypes from 'prop-types'
+// import ReactMarkdown from 'react-markdown/with-html'
+// import Icon from './Icon'
+import { UserAvatar } from '../../../component-avatar/src'
+import { sortAndGroupMessages } from '../../../../sortAndGroup'
+import NextPageButton from '../../../NextPageButton'
+import { convertTimestampToDate } from '../../../../shared/time-formatting'
+import MessageRenderer from './MessageRenderer'
+// import { SmallProfileImage } from './ProfileImage'
+
+import {
+  Timestamp,
+  Time,
+  Message,
+  MessageGroupContainer,
+  MessagesGroup,
+  GutterContainer,
+  Bubble,
+  InnerMessageContainer,
+  Byline,
+} from './style'
+
+const GET_MESSAGES = gql`
+  query messages($channelId: ID, $before: String) {
+    messages(channelId: $channelId, before: $before) {
+      edges {
+        id
+        content
+        created
+        updated
+        user {
+          id
+          username
+          profilePicture
+          online
+        }
+      }
+      pageInfo {
+        startCursor
+        hasPreviousPage
+      }
+    }
+  }
+`
+
+const MESSAGES_SUBSCRIPTION = gql`
+  subscription messageCreated($channelId: ID) {
+    messageCreated(channelId: $channelId) {
+      id
+      created
+      updated
+      content
+      user {
+        id
+        username
+        profilePicture
+        online
+      }
+    }
+  }
+`
+
+// const MESSAGES_ENHANCED_SUBSCRIPTION = gql`
+//   subscription messageEnhanced($channelId: ID) {
+//     messageEnhanced(channelId: $channelId) {
+//       id
+//       created
+//       updated
+//       content
+//       user {
+//         id
+//         username
+//         profilePicture
+//         online
+//       }
+//     }
+//   }
+// `
+
+// const subscribeToEnhancedMessages = (subscribeToMore, channelId) =>
+//   subscribeToMore({
+//     document: MESSAGES_ENHANCED_SUBSCRIPTION,
+//     variables: { channelId },
+//     updateQuery: (prev, { subscriptionData }) => {
+//       if (!subscriptionData.data) return prev
+//       const { messageEnhanced } = subscriptionData.data
+//       const existingMessage = prev.messages.edges.find(
+//         ({ id }) => id === messageEnhanced.id,
+//       )
+//       if (existingMessage) {
+//         return Object.assign({}, prev, {
+//           messages: {
+//             ...prev.messages,
+//             edges: prev.messages.edges.map(edge => {
+//               // Replace the optimstic update with the actual db message
+//               if (edge.id === existingMessage.id)
+//                 return {
+//                   ...edge,
+//                   // cursor: btoa(newMessage.id),
+//                   enhanced: subscriptionData.data.messageEnhanced.enhanced,
+//                 }
+
+//               return edge
+//             }),
+//           },
+//         })
+//       }
+//       return Object.assign({}, prev, {
+//         messages: {
+//           ...prev.messages,
+//           edges: [...prev.messages.edges, messageEnhanced],
+//         },
+//       })
+//     },
+//   })
+
+const subscribeToNewMessages = (subscribeToMore, channelId) =>
+  subscribeToMore({
+    document: MESSAGES_SUBSCRIPTION,
+    variables: { channelId },
+    updateQuery: (prev, { subscriptionData }) => {
+      if (!subscriptionData.data) return prev
+      const { messageCreated } = subscriptionData.data
+      const exists = prev.messages.edges.find(
+        ({ id }) => id === messageCreated.id,
+      )
+      if (exists) return prev
+
+      return Object.assign({}, prev, {
+        messages: {
+          ...prev.messages,
+          edges: [...prev.messages.edges, messageCreated],
+        },
+      })
+    },
+  })
+const Messages = ({ channelId }) => {
+  const { loading, error, data, subscribeToMore, fetchMore } = useQuery(
+    GET_MESSAGES,
+    {
+      variables: { channelId },
+    },
+  )
+
+  const scrollToBottom = () => {
+    const main = document.getElementById('messages')
+
+    if (!main || !data || !data.messages || data.messages.length === 0) {
+      return
+    }
+    const { scrollHeight, clientHeight } = main
+    main.scrollTop = scrollHeight - clientHeight
+  }
+
+  useEffect(() => {
+    scrollToBottom()
+
+    const unsubscribeToNewMessages = subscribeToNewMessages(
+      subscribeToMore,
+      channelId,
+    )
+    // const unsubscribeToEnhancedMessages = subscribeToEnhancedMessages(
+    //   subscribeToMore,
+    //   channelId,
+    // )
+
+    return () => {
+      unsubscribeToNewMessages()
+      // unsubscribeToEnhancedMessages()
+    }
+  })
+
+  if (loading) return 'Loading...'
+  if (error) return JSON.stringify(error)
+
+  const firstMessage = data.messages.edges[0]
+
+  const fetchMoreOptions = {
+    variables: { channelId, before: firstMessage && firstMessage.id },
+    updateQuery: (prev, { fetchMoreResult }) => {
+      if (!fetchMoreResult) return prev
+      return Object.assign({}, prev, {
+        messages: {
+          ...prev.messages,
+          edges: [...fetchMoreResult.messages.edges, ...prev.messages.edges],
+          pageInfo: fetchMoreResult.messages.pageInfo,
+        },
+      })
+    },
+  }
+
+  const messages = sortAndGroupMessages(data.messages.edges)
+  const { hasPreviousPage } = data.messages.pageInfo
+  return (
+    <MessagesGroup id="messages">
+      {hasPreviousPage && (
+        <NextPageButton
+          fetchMore={() => fetchMore(fetchMoreOptions)}
+          // href={{
+          //   pathname: this.props.location.pathname,
+          //   search: queryString.stringify({
+          //     ...queryString.parse(this.props.location.search),
+          //     msgsbefore: messageConnection.edges[0].cursor,
+          //     msgsafter: undefined,
+          //   }),
+          // }}
+          // automatic={!!thread.watercooler}
+          isFetchingMore={false} // isFetchingMore}
+        >
+          Show previous messages
+        </NextPageButton>
+      )}
+      {messages.map(group => {
+        const initialMessage = group[0]
+        if (
+          initialMessage.user.id === 'robo' &&
+          initialMessage.type === 'timestamp'
+        ) {
+          return (
+            <Timestamp key={initialMessage.created}>
+              <hr />
+              <Time>
+                {convertTimestampToDate(new Date(initialMessage.created))}
+              </Time>
+              <hr />
+            </Timestamp>
+          )
+        }
+
+        return (
+          <MessageGroupContainer key={initialMessage.id}>
+            {group.map((message, index) => (
+              <Message key={message.id}>
+                <GutterContainer>
+                  {index === 0 && <UserAvatar user={message.user} />}
+                </GutterContainer>
+                <InnerMessageContainer>
+                  {index === 0 && <Byline>{message.user.username}</Byline>}
+                  <Bubble>
+                    <MessageRenderer message={message} />
+                  </Bubble>
+                </InnerMessageContainer>
+              </Message>
+            ))}
+          </MessageGroupContainer>
+        )
+      })}
+    </MessagesGroup>
+  )
+}
+
+Messages.propTypes = {
+  channelId: PropTypes.string.isRequired,
+}
+
+export default Messages
diff --git a/app/components/component-chat/src/Messages/PaperEmbed.jsx b/app/components/component-chat/src/Messages/PaperEmbed.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7fe32cc02203bbc18e312e95fadbeb5e9c3dbd1c
--- /dev/null
+++ b/app/components/component-chat/src/Messages/PaperEmbed.jsx
@@ -0,0 +1,85 @@
+import React, { useState } from 'react'
+import { useQuery } from '@apollo/react-hooks'
+import gql from 'graphql-tag'
+import styled from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+import Action from '../Action'
+
+const GET_PAPER_BY_DOI = gql`
+  query getPaperByDOI($doi: String!) {
+    paperByDOI(doi: $doi) {
+      id
+      doi
+      created
+      updated
+      abstract
+      title
+      authors
+    }
+  }
+`
+
+const PaperStyled = styled.div`
+  padding: 24px;
+  border: 1px solid ${th('colorBorder')};
+`
+
+const PaperHeading = styled.div`
+  font-weight: bold;
+`
+
+const PaperAuthors = styled.p`
+  font-style: italic;
+`
+
+const PaperAbstract = styled.p`
+  // height: ${props => (props.readMore ? 'inherit' : '48px')};
+  // // -webkit-line-clamp: ${props => (props.readMore ? 'none' : 3)};
+  // // display: -webkit-box;
+  // // -webkit-box-orient: vertical;
+  // overflow: hidden;
+  // &::after {
+  //   position: absolute;
+  //   top: 48px;
+  //   content: "${props => (props.readMore ? '...' : '')}";
+  // }
+  // // text-overflow: ellipsis;
+`
+
+const PaperEmbed = ({ doi, ...props }) => {
+  const [readMore, setReadMore] = useState(false)
+  const { data, loading, error } = useQuery(GET_PAPER_BY_DOI, {
+    variables: { doi },
+  })
+
+  if (loading) {
+    return 'Loading...'
+  }
+  if (error) {
+    return 'Error!'
+  }
+
+  return (
+    <PaperStyled>
+      <PaperHeading>{data.paperByDOI.title}</PaperHeading>
+      <PaperAuthors>{data.paperByDOI.authors}</PaperAuthors>
+      {data.paperByDOI.abstract && (
+        <>
+          <PaperAbstract readMore={readMore}>
+            {readMore
+              ? data.paperByDOI.abstract
+              : `${data.paperByDOI.abstract
+                  .split(' ')
+                  .slice(0, 30)
+                  .join(' ')}...`}
+          </PaperAbstract>
+          <Action onClick={() => setReadMore(!readMore)}>
+            {readMore ? 'See less' : 'Read more'}
+          </Action>
+        </>
+      )}
+    </PaperStyled>
+  )
+}
+
+export default PaperEmbed
diff --git a/app/components/component-chat/src/Messages/style.js b/app/components/component-chat/src/Messages/style.js
new file mode 100644
index 0000000000000000000000000000000000000000..22b2e2dde37cf0d448e16d83ff82beec4b9e4078
--- /dev/null
+++ b/app/components/component-chat/src/Messages/style.js
@@ -0,0 +1,140 @@
+import styled from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+import { HorizontalRule } from '../../../../globals'
+
+export const Timestamp = styled(HorizontalRule)`
+  margin: 24px 0;
+  text-align: center;
+  user-select: none;
+
+  hr {
+    border-color: ${th('colorBorder')};
+  }
+`
+
+export const UnseenRobotext = styled(Timestamp)`
+  hr {
+    border-color: ${th('colorWarning')};
+    opacity: 0.1;
+  }
+`
+
+export const Time = styled.span`
+  text-align: center;
+  color: ${th('colorText')};
+  font-size: 14px;
+  font-weight: 500;
+  margin: 0 24px;
+`
+
+export const MessagesGroup = styled.div`
+  padding-bottom: 8px;
+  flex-direction: column;
+  width: 100%;
+  max-width: 100%;
+  -webkit-box-pack: end;
+  justify-content: flex-end;
+  flex: 1 0 auto;
+  background: rgb(255, 255, 255);
+  overflow: hidden auto;
+  grid-area: read;
+`
+
+export const MessageGroupContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  position: relative;
+  margin-top: 8px;
+  flex: 0 0 auto;
+`
+
+export const Message = styled.div`
+  display: grid;
+  grid-template-columns: 72px minmax(0px, 1fr);
+  align-self: stretch;
+  position: relative;
+  padding-right: 16px;
+  background: transparent;
+`
+
+export const GutterContainer = styled.div`
+  display: flex;
+  width: 72px;
+  min-width: 72px;
+  max-width: 72px;
+  padding-left: 16px;
+`
+
+export const InnerMessageContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  flex: 1 1 auto;
+  padding: 4px 0px;
+`
+
+export const Byline = styled.span`
+  display: flex;
+  font-size: 14px;
+  line-height: 16px;
+  font-weight: 500;
+  margin-bottom: 4px;
+  user-select: none;
+  color: rgb(36, 41, 46);
+  max-width: 100%;
+  position: relative;
+  flex-wrap: wrap;
+  -webkit-box-align: center;
+  align-items: center;
+`
+
+export const Bubble = styled.div`
+  vertical-align: middle;
+  white-space: pre-line;
+  overflow-wrap: break-word;
+  word-break: break-word;
+  align-self: flex-start;
+  clear: both;
+  font-size: 16px;
+  line-height: ${th('lineHeightBase')};
+  color: rgb(36, 41, 46);
+  font-weight: 400;
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  border-radius: 16px;
+  flex: 1 1 auto;
+
+  // Classic tags used in Markdown renders (MessageRenderer)
+  pre {
+    font-family: ${th('fontCode')};
+  }
+
+  strong {
+    font-weight: bold;
+  }
+
+  em {
+    font-style: italic;
+  }
+`
+
+export const EmbedContainer = styled.div`
+  position: relative;
+  width: 100%;
+  margin-bottom: 32px;
+  display: flex;
+  justify-content: center;
+`
+
+export const AspectRatio = styled(EmbedContainer)`
+  padding-bottom: ${props => (props.ratio ? props.ratio : '0')};
+`
+
+export const EmbedComponent = styled.iframe`
+  position: absolute;
+  height: 100%;
+  width: 100%;
+`
diff --git a/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx b/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a6b54538dff989d13cb7f6a4be090af7dc099c77
--- /dev/null
+++ b/app/components/component-chat/src/SuperChatInput/SuperChatInput.jsx
@@ -0,0 +1,419 @@
+// @flow
+import * as React from 'react'
+import { Button } from '@pubsweet/ui'
+import { th } from '@pubsweet/ui-toolkit'
+import styled from 'styled-components'
+import { useMutation } from '@apollo/react-hooks'
+
+// import compose from 'recompose/compose';
+// import { connect } from 'react-redux';
+import Icon from '../Messages/Icon'
+// import { addToastWithTimeout } from 'src/actions/toasts';
+// import { openModal } from 'src/actions/modals';
+// import { replyToMessage } from 'src/actions/message';
+import useCurrentUser from '../../../../hooks/useCurrentUser'
+import {
+  Form,
+  ChatInputContainer,
+  ChatInputWrapper,
+  Input,
+  InputWrapper,
+  PhotoSizeError,
+  PreviewWrapper,
+  RemovePreviewButton,
+} from './style'
+
+import { CREATE_MESSAGE, GET_MESSAGE_BY_ID } from '../../../../queries'
+
+// import sendDirectMessage from 'shared/graphql/mutations/message/sendDirectMessage'
+// import { getMessageById } from 'shared/graphql/queries/message/getMessage'
+
+// import MediaUploader from './components/mediaUploader'
+// import { QuotedMessage as QuotedMessageComponent } from '../message/view'
+
+// import type { Dispatch } from 'redux';
+// import { MarkdownHint } from 'src/components/markdownHint'
+import { useAppScroller } from '../../../../hooks/useAppScroller'
+import { MEDIA_BREAK } from '../../../layout'
+
+const MarkdownHint = styled.div`
+  line-height: 1;
+  min-height: 1em;
+  padding-left: 16px;
+  font-size: 12px;
+  color: ${th('colorTextPlaceholder')};
+`
+
+// const QuotedMessage = connect()(
+//   getMessageById(props => {
+//     if (props.data && props.data.message) {
+//       return <QuotedMessageComponent message={props.data.message} />
+//     }
+
+//     // if the query is done loading and no message was returned, clear the input
+//     if (props.data && props.data.networkStatus === 7 && !props.data.message) {
+//       props.dispatch(
+//         addToastWithTimeout(
+//           'error',
+//           'The message you are replying to was deleted or could not be fetched.',
+//         ),
+//       )
+//       props.dispatch(
+//         replyToMessage({ threadId: props.threadId, messageId: null }),
+//       )
+//     }
+
+//     return null
+//   }),
+// )
+
+const QuotedMessage = styled.div``
+
+// type Props = {
+//   onRef: Function,
+//   dispatch: Dispatch<Object>,
+//   createThread: Function,
+//   // sendMessage: Function,
+//   // sendDirectMessage: Function,
+//   threadType: string,
+//   threadId: string,
+//   clear: Function,
+//   websocketConnection: string,
+//   networkOnline: boolean,
+//   refetchThread: Function,
+//   quotedMessage: { messageId: string, threadId: string },
+//   // used to pre-populate the @mention suggestions with participants and the author of the thread
+//   participants: Array<?Object>,
+//   onFocus: ?Function,
+//   onBlur: ?Function,
+// }
+
+export const cleanSuggestionUserObject = user => {
+  if (!user) return null
+  return {
+    ...user,
+    id: user.username,
+    display: user.username,
+    filterName: user.name.toLowerCase(),
+  }
+}
+
+const SuperChatInput = props => {
+  const currentUser = useCurrentUser()
+  const [sendChannelMessage] = useMutation(CREATE_MESSAGE)
+  const [sendDirectMessage] = useMutation(CREATE_MESSAGE)
+
+  const cacheKey = `last-content-${props.channelId}`
+  const [text, changeText] = React.useState('')
+  const [photoSizeError, setPhotoSizeError] = React.useState('')
+  const [inputRef, setInputRef] = React.useState(null)
+  const { scrollToBottom } = useAppScroller()
+
+  // On mount, set the text state to the cached value if one exists
+  // $FlowFixMe
+  React.useEffect(() => {
+    changeText(localStorage.getItem(cacheKey) || '')
+    // NOTE(@mxstbr): We ONLY want to run this if we switch between threads, never else!
+  }, [props.threadId])
+
+  // Cache the latest text everytime it changes
+  // $FlowFixMe
+  React.useEffect(() => {
+    localStorage.setItem(cacheKey, text)
+  }, [text])
+
+  // Focus chatInput when quoted message changes
+  // $FlowFixMe
+  React.useEffect(() => {
+    if (inputRef) inputRef.focus()
+  }, [props.quotedMessage && props.quotedMessage.messageId])
+
+  React.useEffect(() => {
+    // autofocus the chat input on desktop
+    if (inputRef && window && window.innerWidth > MEDIA_BREAK) inputRef.focus()
+  }, [inputRef])
+
+  const removeAttachments = () => {
+    removeQuotedMessage()
+    setMediaPreview(null)
+  }
+
+  const handleKeyPress = e => {
+    // We shouldn't do anything during composition of IME.
+    // `keyCode === 229` is a fallback for old browsers like IE.
+    if (e.isComposing || e.keyCode === 229) {
+      return
+    }
+    switch (e.key) {
+      // Submit on Enter unless Shift is pressed
+      case 'Enter': {
+        if (e.shiftKey) return
+        e.preventDefault()
+        submit()
+        return
+      }
+      // If backspace is pressed on the empty
+      case 'Backspace': {
+        if (text.length === 0) removeAttachments()
+      }
+      default:
+    }
+  }
+
+  const onChange = e => {
+    const text = e.target.value
+    changeText(text)
+  }
+
+  const sendMessage = ({ file, body }) =>
+    // user is creating a new directMessageThread, break the chain
+    // and initiate a new group creation with the message being sent
+    // in views/directMessages/containers/newThread.js
+    // if (props.threadId === 'newDirectMessageThread') {
+    //   return props.createThread({
+    //     messageType: file ? 'media' : 'text',
+    //     file,
+    //     messageBody: body,
+    //   })
+    // }
+
+    sendChannelMessage({
+      variables: { content: body, channelId: props.channelId },
+    })
+  // const method =
+  //   props.threadType === 'story' ? props.sendMessage : props.sendDirectMessage
+  // return method({
+  //   threadId: props.threadId,
+  //   messageType: file ? 'media' : 'text',
+  //   threadType: props.threadType,
+  //   parentId: props.quotedMessage,
+  //   content: {
+  //     body,
+  //   },
+  //   file,
+  // })
+
+  const submit = async e => {
+    if (e) e.preventDefault()
+
+    if (!props.networkOnline) {
+      console.error('No internet')
+      // return props.dispatch(
+      //   addToastWithTimeout(
+      //     'error',
+      //     'Not connected to the internet - check your internet connection or try again',
+      //   ),
+      // )
+    }
+
+    if (
+      props.websocketConnection !== 'connected' &&
+      props.websocketConnection !== 'reconnected'
+    ) {
+      console.error('No internet, reconnecting.')
+      // return props.dispatch(
+      //   addToastWithTimeout(
+      //     'error',
+      //     'Error connecting to the server - hang tight while we try to reconnect',
+      //   ),
+      // )
+    }
+
+    if (!currentUser) {
+      // user is trying to send a message without being signed in
+      console.error('Not logged in')
+      // return props.dispatch(openModal('LOGIN_MODAL', {}))
+    }
+
+    scrollToBottom()
+
+    if (mediaFile) {
+      setIsSendingMediaMessage(true)
+      scrollToBottom()
+      await sendMessage({
+        file: mediaFile,
+        body: '{"blocks":[],"entityMap":{}}',
+      })
+        .then(() => {
+          setIsSendingMediaMessage(false)
+          setMediaPreview(null)
+          setAttachedMediaFile(null)
+        })
+        .catch(err => {
+          setIsSendingMediaMessage(false)
+          props.dispatch(addToastWithTimeout('error', err.message))
+        })
+    }
+
+    if (text.length === 0) return
+
+    // workaround react-mentions bug by replacing @[username] with @username
+    // @see withspectrum/spectrum#4587
+    sendMessage({ body: text.replace(/@\[([a-z0-9_-]+)\]/g, '@$1') })
+    // .then(() => {
+    //   // If we're viewing a thread and the user sends a message as a non-member, we need to refetch the thread data
+    //   if (
+    //     props.threadType === 'story' &&
+    //     props.threadId &&
+    //     props.refetchThread
+    //   ) {
+    //     return props.refetchThread();
+    //   }
+    // })
+    // .catch(err => {
+    // props.dispatch(addToastWithTimeout('error', err.message));
+    // })
+
+    // Clear the chat input now that we're sending a message for sure
+    onChange({ target: { value: '' } })
+    removeQuotedMessage()
+    inputRef && inputRef.focus()
+  }
+
+  // $FlowFixMe
+  const [isSendingMediaMessage, setIsSendingMediaMessage] = React.useState(
+    false,
+  )
+  // $FlowFixMe
+  const [mediaPreview, setMediaPreview] = React.useState(null)
+  // $FlowFixMe
+  const [mediaFile, setAttachedMediaFile] = React.useState(null)
+
+  const previewMedia = blob => {
+    if (isSendingMediaMessage) return
+    setIsSendingMediaMessage(true)
+    setAttachedMediaFile(blob)
+    inputRef && inputRef.focus()
+
+    const reader = new FileReader()
+    reader.onload = () => {
+      setMediaPreview(reader.result.toString())
+      setIsSendingMediaMessage(false)
+    }
+
+    if (blob) {
+      reader.readAsDataURL(blob)
+    }
+  }
+
+  const removeQuotedMessage = () => {
+    if (props.quotedMessage)
+      props.dispatch(
+        replyToMessage({ threadId: props.threadId, messageId: null }),
+      )
+  }
+
+  const networkDisabled =
+    !props.networkOnline ||
+    (props.websocketConnection !== 'connected' &&
+      props.websocketConnection !== 'reconnected')
+
+  return (
+    <>
+      <ChatInputContainer>
+        {photoSizeError && (
+          <PhotoSizeError>
+            <p>{photoSizeError}</p>
+            <Icon
+              color="warn.default"
+              glyph="view-close"
+              onClick={() => setPhotoSizeError('')}
+              size={16}
+            />
+          </PhotoSizeError>
+        )}
+        <ChatInputWrapper>
+          {/* {props.currentUser && (
+            <MediaUploader
+              currentUser={props.currentUser}
+              isSendingMediaMessage={isSendingMediaMessage}
+              onError={err => setPhotoSizeError(err)}
+              onValidated={previewMedia}
+            />
+          )} */}
+          <Form onSubmit={submit}>
+            <InputWrapper
+              hasAttachment={!!props.quotedMessage || !!mediaPreview}
+              networkDisabled={networkDisabled}
+            >
+              {mediaPreview && (
+                <PreviewWrapper>
+                  <img alt="" src={mediaPreview} />
+                  <RemovePreviewButton onClick={() => setMediaPreview(null)}>
+                    <Icon glyph="view-close-small" size="16" />
+                  </RemovePreviewButton>
+                </PreviewWrapper>
+              )}
+              {props.quotedMessage && (
+                <PreviewWrapper data-cy="staged-quoted-message">
+                  <QuotedMessage
+                    id={props.quotedMessage}
+                    threadId={props.threadId}
+                  />
+                  <RemovePreviewButton
+                    data-cy="remove-staged-quoted-message"
+                    onClick={removeQuotedMessage}
+                  >
+                    <Icon glyph="view-close-small" size="16" />
+                  </RemovePreviewButton>
+                </PreviewWrapper>
+              )}
+              <Input
+                autoFocus={false}
+                hasAttachment={!!props.quotedMessage || !!mediaPreview}
+                inputRef={node => {
+                  if (props.onRef) props.onRef(node)
+                  setInputRef(node)
+                }}
+                networkDisabled={networkDisabled}
+                // onBlur={props.onBlur}
+                onChange={onChange}
+                // onFocus={props.onFocus}
+                onKeyDown={handleKeyPress}
+                placeholder="Your message here..."
+                // staticSuggestions={props.participants}
+                value={text}
+              />
+            </InputWrapper>
+            <Button
+              data-cy="chat-input-send-button"
+              onClick={submit}
+              primary
+              style={{ flex: 'none', marginLeft: '8px' }}
+            >
+              Send
+            </Button>
+          </Form>
+        </ChatInputWrapper>
+      </ChatInputContainer>
+      <MarkdownHint dataCy="markdownHint">
+        {text.length > 0 && (
+          <span>
+            **<strong>bold</strong>**, *<em>italic</em>*, `code`
+          </span>
+        )}
+      </MarkdownHint>
+    </>
+  )
+}
+
+SuperChatInput.defaultProps = {
+  networkOnline: true,
+  websocketConnection: 'connected',
+}
+
+// const map = (state, ownProps) => ({
+//   websocketConnection: state.connectionStatus.websocketConnection,
+//   networkOnline: state.connectionStatus.networkOnline,
+//   quotedMessage: state.message.quotedMessage[ownProps.threadId] || null,
+// })
+
+// export default compose(
+//   withCurrentUser,
+//   sendMessage,
+//   sendDirectMessage,
+//   // $FlowIssue
+//   connect(map)
+// )(ChatInput)
+
+export default SuperChatInput
diff --git a/app/components/component-chat/src/SuperChatInput/style.js b/app/components/component-chat/src/SuperChatInput/style.js
new file mode 100644
index 0000000000000000000000000000000000000000..adfbbed02cc1101971bbb3c99ee60b5d74809eb1
--- /dev/null
+++ b/app/components/component-chat/src/SuperChatInput/style.js
@@ -0,0 +1,343 @@
+import styled, { css } from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+import MentionsInput from '../MentionsInput/MentionsInput'
+import { MEDIA_BREAK } from '../../../layout'
+import { zIndex } from '../../../../globals'
+
+const theme = {
+  text: {
+    alt: '#000',
+    placeholder: '#000',
+    reverse: '#000',
+  },
+  bg: {
+    border: '#000',
+  },
+  warn: {
+    alt: '#000',
+  },
+  brand: {
+    default: '#000',
+  },
+  special: {
+    default: '#000',
+    wash: '#000',
+  },
+}
+
+export const hexa = (hex, alpha) => {
+  const r = parseInt(hex.slice(1, 3), 16)
+  const g = parseInt(hex.slice(3, 5), 16)
+  const b = parseInt(hex.slice(5, 7), 16)
+
+  if (alpha >= 0) {
+    return `rgba(${r}, ${g}, ${b}, ${alpha})`
+  }
+  return `rgb(${r}, ${g}, ${b})`
+}
+
+export const SvgWrapper = styled.div`
+  display: inline-block;
+  flex: 0 0 ${props => (props.size ? `${props.size}px` : '32px')};
+  width: ${props => (props.size ? `${props.size}px` : '32px')};
+  height: ${props => (props.size ? `${props.size}px` : '32px')};
+  min-width: ${props => (props.size ? `${props.size}px` : '32px')};
+  min-height: ${props => (props.size ? `${props.size}px` : '32px')};
+  position: relative;
+  color: inherit;
+  ${props =>
+    props.count &&
+    css`
+      background-color: transparent;
+      &:after {
+        content: ${props.count ? `'${props.count}'` : `''`};
+        position: absolute;
+        left: calc(100% - 12px);
+        top: -2px;
+        font-size: 14px;
+        font-weight: 600;
+        background: ${theme.bg.default};
+        color: ${({ theme }) =>
+          process.env.NODE_ENV === 'production'
+            ? theme.text.default
+            : theme.warn.alt};
+        border-radius: 8px;
+        padding: 2px 4px;
+        border: 2px solid
+          ${({ theme }) =>
+            process.env.NODE_ENV === 'production'
+              ? theme.text.default
+              : theme.warn.alt};
+      }
+    `};
+`
+
+export const QuoteWrapper = styled.div`
+  border-left: 4px solid ${theme.bg.border};
+  color: ${theme.text.alt};
+  padding: 4px 12px 4px 16px;
+  max-height: ${props => (props.expanded ? 'none' : '7em')};
+  margin-top: 4px;
+  margin-bottom: 8px;
+  overflow-y: hidden;
+  cursor: pointer;
+  position: relative;
+  ${SvgWrapper} {
+    margin-left: -3px;
+    margin-right: 2px;
+  }
+`
+
+export const monoStack = css`
+  font-family: 'Input Mono', 'Menlo', 'Inconsolata', 'Roboto Mono', monospace;
+`
+
+export const FlexRow = styled.div`
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-start;
+  align-items: center;
+`
+
+export const ChatInputContainer = styled(FlexRow)`
+  grid-area: write;
+  flex: none;
+  display: flex;
+  flex-direction: column;
+  z-index: inherit;
+  position: relative;
+  width: 100%;
+  margin: 0;
+  a {
+    text-decoration: underline;
+  }
+`
+
+export const ChatInputWrapper = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: flex-end;
+  width: 100%;
+  margin-bottom: ${th('gridUnit')};
+  padding: 8px 12px 0 12px;
+  background-color: ${th('colorBackground')};
+  border-top: 1px solid ${th('colorBorder')};
+  box-shadow: -1px 0 0 ${th('colorBorder')}, 1px 0 0 ${th('colorBorder')};
+  @media (max-width: ${MEDIA_BREAK}px) {
+    bottom: ${props => (props.focus ? '0' : 'auto')};
+    position: relative;
+    z-index: ${zIndex.mobileInput};
+    padding: 8px;
+  }
+`
+
+export const Form = styled.form`
+  flex: auto;
+  display: flex;
+  min-width: 1px;
+  max-width: 100%;
+  align-items: center;
+  margin-left: 4px;
+  border-radius: 24px;
+  background-color: transparent;
+  position: relative;
+`
+
+export const InputWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  flex: auto;
+  padding: ${props => (props.hasAttachment ? '16px' : '9px 16px 8px 16px')};
+  transition: padding 0.2s ease-in-out;
+  min-height: 40px;
+  max-width: calc(100% - 32px);
+  border-radius: 24px;
+  border: 1px solid
+    ${props => (props.networkDisabled ? th('colorWarning') : th('colorBorder'))};
+  transition: border 0.3s ease-out;
+  color: ${props =>
+    props.networkDisabled
+      ? th('colorText') // props.theme.special.default
+      : th('colorSecondary')}; // props.theme.text.secondary};
+  background: ${props =>
+    props.networkDisabled
+      ? hexa(props.theme.special.default, 0.1)
+      : th('colorBackground')};
+  &:hover,
+  &:focus {
+    border-color: ${props =>
+      props.networkDisabled
+        ? th('borderColor') // props.theme.special.default
+        : th('colorWarning')}; // props.theme.text.alt
+    transition: border-color 0.2s ease-in;
+  }
+  @media (max-width: ${MEDIA_BREAK}px) {
+    padding-left: 16px;
+  }
+`
+
+export const Input = styled(MentionsInput).attrs(props => ({
+  dataCy: props.dataCy || 'chat-input',
+  spellCheck: true,
+  autoCapitalize: 'sentences',
+  autoComplete: 'on',
+  autoCorrect: 'on',
+}))`
+  font-size: 16px; /* has to be 16px to avoid zoom on iOS */
+  font-weight: 400;
+  line-height: 1.4;
+  background: ${props =>
+    props.networkDisabled ? 'none' : th('colorBackground')};
+  @media (max-width: ${MEDIA_BREAK}px) {
+    font-size: 16px;
+  }
+  div,
+  textarea {
+    line-height: 1.4 !important;
+    word-break: break-word;
+  }
+  &::placeholder {
+    color: ${props =>
+      props.networkDisabled
+        ? hexa(th('colorWarning'), 0.8)
+        : th('colorSecondary')}; // props.theme.text.placeholder};
+  }
+  &::-webkit-input-placeholder {
+    color: ${props =>
+      props.networkDisabled
+        ? hexa(th('colorWarning'), 0.8)
+        : th('colorSecondary')}; // props.theme.text.placeholder};
+  }
+  &:-moz-placeholder {
+    color: ${props =>
+      props.networkDisabled
+        ? hexa(th('colorWarning'), 0.8)
+        : th('colorSecondary')}; // props.theme.text.placeholder};
+  }
+  &:-ms-input-placeholder {
+    color: ${props =>
+      props.networkDisabled
+        ? hexa(th('colorWarning'), 0.8)
+        : th('colorSecondary')}; // props.theme.text.placeholder};
+  }
+  pre {
+    ${monoStack};
+    font-size: 15px;
+    font-weight: 500;
+    background-color: ${theme.bg.wash};
+    border: 1px solid ${th('colorBorder')};
+    border-radius: 2px;
+    padding: 4px;
+    margin-right: 16px;
+  }
+  blockquote {
+    line-height: 1.5;
+    border-left: 4px solid ${th('colorBorder')};
+    color: ${theme.text.alt};
+    padding: 4px 12px 4px 16px;
+  }
+  ${props =>
+    props.hasAttachment &&
+    css`
+      margin-top: 16px;
+      ${'' /* > div:last-of-type {
+        margin-right: 32px;
+      } */};
+    `};
+`
+
+export const MediaInput = styled.input`
+  width: 0.1px;
+  height: 0.1px;
+  opacity: 0;
+  overflow: hidden;
+  position: absolute;
+`
+
+export const MediaLabel = styled.label`
+  border: none;
+  outline: 0;
+  display: inline-block;
+  background: transparent;
+  transition: color 0.3s ease-out;
+  border-radius: 4px;
+  padding: 4px;
+  position: relative;
+  top: 2px;
+  color: ${theme.text.alt};
+  &:hover {
+    cursor: pointer;
+    color: ${theme.brand.default};
+  }
+`
+
+export const PhotoSizeError = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  align-content: center;
+  padding: 8px 16px;
+  width: 100%;
+  background: ${theme.special.wash};
+  border-top: 1px solid ${theme.special.border};
+  &:hover {
+    cursor: pointer;
+    p {
+      color: ${theme.brand.default};
+    }
+  }
+  p {
+    font-size: 14px;
+    line-height: 1.4;
+    color: ${theme.special.default};
+    max-width: calc(100% - 48px);
+  }
+  div {
+    align-self: center;
+  }
+`
+
+export const RemovePreviewButton = styled.button`
+  position: absolute;
+  top: 0;
+  right: 0;
+  vertical-align: top;
+  background-color: ${theme.text.placeholder};
+  color: ${theme.text.reverse};
+  border: none;
+  border-radius: 100%;
+  outline: none;
+  padding: 4px;
+  max-height: 24px;
+  max-width: 24px;
+  cursor: pointer;
+  z-index: 1;
+  &:hover {
+    background-color: ${theme.warn.alt};
+  }
+`
+
+export const PreviewWrapper = styled.div`
+  position: relative;
+  padding: 0;
+  padding-bottom: 8px;
+  border-bottom: 1px solid ${th('colorBorder')};
+  ${QuoteWrapper} {
+    margin: 0;
+    margin-top: -6px;
+    margin-left: -12px;
+    border-left: 0;
+  }
+  & + & {
+    padding-top: 16px;
+    ${RemovePreviewButton} {
+      top: 16px;
+    }
+  }
+  & > img {
+    border-radius: 8px;
+    max-width: 37%;
+  }
+`
diff --git a/app/components/component-chat/src/index.js b/app/components/component-chat/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..7bd1468d1613c96acdf6a350419567a0b4959acd
--- /dev/null
+++ b/app/components/component-chat/src/index.js
@@ -0,0 +1,39 @@
+import React from 'react'
+import styled from 'styled-components'
+// import { th } from '@pubsweet/ui-toolkit'
+// import { useQuery } from '@apollo/react-hooks'
+// import gql from 'graphql-tag'
+// import { useParams } from 'react-router-dom'
+import Messages from './Messages/Messages'
+import ChatInput from './SuperChatInput/SuperChatInput'
+
+const MessageContainer = styled.section`
+  display: grid;
+  min-width: 100%;
+  // flex-direction: column;
+  background: rgb(255, 255, 255);
+  // height: calc(100vh - 64px);
+  // grid-template-rows: calc(100% - 40px);
+  grid-template-areas:
+    'read'
+    'write';
+`
+
+// const GET_CHANNEL_BY_DOI = gql`
+//   query findByDOI($doi: String) {
+//     findByDOI(doi: $doi) {
+//       id
+//     }
+//   }
+// `
+
+const Container = ({ channelId }) => {
+  return (
+    <MessageContainer>
+      <Messages channelId={channelId} />
+      <ChatInput channelId={channelId} />
+    </MessageContainer>
+  )
+}
+
+export default Container
diff --git a/app/components/component-profile/src/ChangeUsername.jsx b/app/components/component-profile/src/ChangeUsername.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ea707cca30d53ca3a4b0f81e695661779c9a4397
--- /dev/null
+++ b/app/components/component-profile/src/ChangeUsername.jsx
@@ -0,0 +1,43 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import gql from 'graphql-tag'
+import { useMutation } from '@apollo/react-hooks'
+import { TextField, Button } from '@pubsweet/ui'
+import { th } from '@pubsweet/ui-toolkit'
+import styled from 'styled-components'
+
+const UPDATE_CURRENT_USERNAME = gql`
+  mutation updateCurrentUsername($username: String) {
+    updateCurrentUsername(username: $username) {
+      id
+      username
+    }
+  }
+`
+
+const InlineTextField = styled(TextField)`
+  display: inline;
+  width: calc(${th('gridUnit')} * 24);
+`
+const ChangeUsername = ({ user }) => {
+  const [updateCurrentUsername] = useMutation(UPDATE_CURRENT_USERNAME)
+  const [username, setUsername] = useState(user.username)
+
+  const updateUsername = async updatedUsername => {
+    await updateCurrentUsername({ variables: { username: updatedUsername } })
+  }
+  return (
+    <>
+      <InlineTextField
+        onChange={e => setUsername(e.target.value)}
+        value={username}
+      />
+      <Button onClick={() => updateUsername(username)}>Change</Button>
+    </>
+  )
+}
+
+ChangeUsername.propTypes = {
+  user: PropTypes.shape({ username: PropTypes.string.isRequired }).isRequired,
+}
+export default ChangeUsername
diff --git a/app/components/component-profile/src/FormGrid.jsx b/app/components/component-profile/src/FormGrid.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9144c8135943ecd708ab0d52327b1eeb93e1837
--- /dev/null
+++ b/app/components/component-profile/src/FormGrid.jsx
@@ -0,0 +1,41 @@
+import { th } from '@pubsweet/ui-toolkit'
+import styled from 'styled-components'
+
+export const FormGrid = styled.div`
+  margin-top: calc(${th('gridUnit')} * 2);
+  margin-left: calc(${th('gridUnit')} * 3);
+  margin-right: calc(${th('gridUnit')} * 3);
+  display: grid;
+  grid-template-rows: repeat(
+    ${props => props.rows || 4},
+    calc(${th('gridUnit')} * 6)
+  );
+  grid-gap: calc(${th('gridUnit')} * 4);
+`
+
+export const FormRow = styled.div`
+  align-items: start;
+  grid-gap: 16px;
+  display: grid;
+  border-bottom: 1px solid ${th('colorBorder')};
+  margin-bottom: calc(${th('gridUnit')} * -2);
+  grid-template-columns: calc(${th('gridUnit')} * 20) calc(
+      ${th('gridUnit')} * 60
+    );
+
+  label {
+    grid-column: 1 / 2;
+    line-height: calc(${th('gridUnit')} * 6);
+  }
+
+  div {
+    grid-column: 2 / 3;
+    line-height: calc(${th('gridUnit')} * 6);
+    display: flex;
+  }
+
+  button {
+    margin-left: calc(${th('gridUnit')} * 2);
+  }
+`
+
diff --git a/app/components/component-profile/src/PageWithHeader.jsx b/app/components/component-profile/src/PageWithHeader.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..28929e72dceb1885f0a18229fe1f63462e69caef
--- /dev/null
+++ b/app/components/component-profile/src/PageWithHeader.jsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import { th } from '@pubsweet/ui-toolkit'
+import styled from 'styled-components'
+
+const Settings = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-self: stretch;
+  flex: 1 1 0%;
+`
+
+const StyledHeader = styled.div`
+  display: flex;
+  padding-top: 32px;
+  padding-bottom: 32px;
+  border-bottom: 1px solid ${th('colorBorder')};
+  background: ${th('colorBackground')};
+  width: 100%;
+  align-items: center;
+`
+
+export const HeaderText = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+`
+
+export const Heading = styled.h1`
+  margin-left: calc(${th('gridUnit')} * 3);
+  font-size: 32px;
+  color: ${th('colorText')};
+  font-weight: 800;
+`
+
+export const Subheading = styled.h3`
+  margin-left: 16px;
+  font-size: 16px;
+  color: ${th('colorFurniture')};
+  font-weight: 400;
+  line-height: 1.3;
+  &:hover {
+    color: ${th('colorPrimary')};
+  }
+`
+
+const PageWithHeader = ({ children, header }) => (
+  <Settings>
+    <StyledHeader>
+      <HeaderText>
+        <Heading>{header}</Heading>
+      </HeaderText>
+    </StyledHeader>
+    { children }
+  </Settings>
+)
+
+export default PageWithHeader
\ No newline at end of file
diff --git a/app/components/component-profile/src/Profile.jsx b/app/components/component-profile/src/Profile.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7078609b74d5385722188ac21cfb4bf7109dbc98
--- /dev/null
+++ b/app/components/component-profile/src/Profile.jsx
@@ -0,0 +1,115 @@
+import React, { useCallback } from 'react'
+import { Button } from '@pubsweet/ui'
+// import { th } from '@pubsweet/ui-toolkit'
+// import styled from 'styled-components'
+import gql from 'graphql-tag'
+import { useQuery } from '@apollo/react-hooks'
+import { useDropzone } from 'react-dropzone'
+
+import Spinner from '../../Spinner'
+import ChangeUsername from './ChangeUsername'
+import { BigProfileImage } from './ProfileImage'
+import PageWithHeader from './PageWithHeader'
+import { FormGrid, FormRow } from './FormGrid'
+
+const GET_CURRENT_USER = gql`
+  query currentUser {
+    currentUser {
+      id
+      profilePicture
+      username
+      defaultIdentity {
+        aff
+        name {
+          surname
+        }
+        type
+        ... on ExternalIdentity {
+          identifier
+        }
+        ... on LocalIdentity {
+          email
+        }
+      }
+    }
+  }
+`
+
+// eslint-disable-next-line react/prop-types
+const ProfileDropzone = ({ profilePicture, updateProfilePicture }) => {
+  const onDrop = useCallback(async acceptedFiles => {
+    const body = new FormData()
+    body.append('file', acceptedFiles[0])
+    let result = await fetch('/api/uploadProfile', {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${localStorage.getItem('token')}`,
+      },
+      body,
+    })
+    result = await result.text()
+    updateProfilePicture(result)
+  }, [])
+  const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
+
+  return (
+    <div {...getRootProps()}>
+      <input {...getInputProps()} />
+      {profilePicture ? (
+        <BigProfileImage src={profilePicture} />
+      ) : (
+        <BigProfileImage src="/static/profiles/placeholder.png" />
+      )}
+      {isDragActive ? <Button>Drop it here</Button> : <Button>Change</Button>}
+    </div>
+  )
+}
+
+const Profile = () => {
+  const { loading, error, data, client } = useQuery(GET_CURRENT_USER)
+
+  if (loading) return <Spinner />
+  if (error) return JSON.stringify(error)
+
+  // This is a bridge between the fetch results and the Apollo cache/state
+  const updateProfilePicture = profilePicture => {
+    const cacheData = client.readQuery({ query: GET_CURRENT_USER })
+    const updatedData = {
+      currentUser: {
+        ...cacheData.currentUser,
+        profilePicture,
+      },
+    }
+    client.writeData({ data: updatedData })
+  }
+
+  return (
+    <>
+      <PageWithHeader header="Your profile">
+        <FormGrid rows={3}>
+          <FormRow>
+            <label>Profile picture</label>
+            <div>
+              <ProfileDropzone
+                profilePicture={data.currentUser.profilePicture}
+                updateProfilePicture={updateProfilePicture}
+              />
+            </div>
+          </FormRow>
+          <FormRow>
+            <label>ORCID</label>{' '}
+            <div>{data.currentUser.defaultIdentity.identifier}</div>
+          </FormRow>
+          <FormRow>
+            <label htmlFor="2">Username</label>
+            <div>
+              <ChangeUsername user={data.currentUser} />
+            </div>
+          </FormRow>
+        </FormGrid>
+      </PageWithHeader>
+    </>
+  )
+}
+
+export default Profile
diff --git a/app/components/component-profile/src/ProfileImage.jsx b/app/components/component-profile/src/ProfileImage.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..fbaa86fdcc7eabe886eb3872e1aa8949c07868ea
--- /dev/null
+++ b/app/components/component-profile/src/ProfileImage.jsx
@@ -0,0 +1,17 @@
+import { th } from '@pubsweet/ui-toolkit'
+import styled from 'styled-components'
+
+export const BigProfileImage = styled.img`
+  width: calc(${th('gridUnit')} * 6);
+  height: calc(${th('gridUnit')} * 6);
+  object-fit: cover;
+  border-radius: 50%;
+`
+
+export const SmallProfileImage = styled.img`
+  width: calc(${th('gridUnit')} * 4);
+  height: calc(${th('gridUnit')} * 4);
+  object-fit: cover;
+  border-radius: 50%;
+`
+
diff --git a/app/components/component-profile/src/index.js b/app/components/component-profile/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..a09beba600af3781eae8ceff3b005052aa5fc878
--- /dev/null
+++ b/app/components/component-profile/src/index.js
@@ -0,0 +1,2 @@
+import Profile from './Profile'
+export { Profile }
diff --git a/app/components/component-xpub-dashboard/src/components/UploadManuscript.js b/app/components/component-xpub-dashboard/src/components/UploadManuscript.js
index d91bb1f463cc7966ed969f7bff341359a24aeb13..1adf57c26c89c93c6e72e60971ce678a28ec729e 100644
--- a/app/components/component-xpub-dashboard/src/components/UploadManuscript.js
+++ b/app/components/component-xpub-dashboard/src/components/UploadManuscript.js
@@ -196,26 +196,29 @@ const UploadManuscript = ({ acceptFiles, ...props }) => {
       disableUpload={converting ? 'disableUpload' : null}
       onDrop={uploadManuscript}
     >
-      <Root>
-        <Main>
-          {completed && <StatusCompleted />}
-          {error && <StatusError />}
-          {converting && <StatusConverting />}
-          {!converting && !error && !completed && <StatusIdle />}
-          {error ? (
-            <Error>{error.message}</Error>
-          ) : (
-            <Info>
-              {completed ? 'Submission created' : 'Submit Manuscript'}
-            </Info>
-          )}
-        </Main>
-        <SubInfo>
-          {converting &&
-            'Your manuscript is being converted into a directly editable version. This might take a few seconds.'}
-          {!converting && 'Accepted file types: pdf, epub, zip, docx, latex'}
-        </SubInfo>
-      </Root>
+      {({ getRootProps, getInputProps }) => (
+        <Root {...getRootProps()}>
+          <Main>
+            <input {...getInputProps()} />
+            {completed && <StatusCompleted />}
+            {error && <StatusError />}
+            {converting && <StatusConverting />}
+            {!converting && !error && !completed && <StatusIdle />}
+            {error ? (
+              <Error>{error.message}</Error>
+            ) : (
+              <Info>
+                {completed ? 'Submission created' : 'Submit Manuscript'}
+              </Info>
+            )}
+          </Main>
+          <SubInfo>
+            {converting &&
+              'Your manuscript is being converted into a directly editable version. This might take a few seconds.'}
+            {!converting && 'Accepted file types: pdf, epub, zip, docx, latex'}
+          </SubInfo>
+        </Root>
+      )}
     </StyledDropzone>
   )
 }
diff --git a/app/components/component-xpub-review/src/components/DecisionPage.js b/app/components/component-xpub-review/src/components/DecisionPage.js
index cd85962babb3c2017580214db0401fcab614d2f5..434893e77baba631ddfb5e8fa35ec6ad905d3bb8 100644
--- a/app/components/component-xpub-review/src/components/DecisionPage.js
+++ b/app/components/component-xpub-review/src/components/DecisionPage.js
@@ -122,6 +122,10 @@ const query = gql`
       manuscriptVersions {
         ${fragmentFields}
       }
+      channel {
+        id
+        name
+      }
     }
   }
 `
diff --git a/app/components/component-xpub-review/src/components/decision/DecisionLayout.js b/app/components/component-xpub-review/src/components/decision/DecisionLayout.js
index df806b5690e436a898ae0fe0fdfad87c0ddf41c2..e908ef93873df6c22437304de8a7e74fc5ebd210 100644
--- a/app/components/component-xpub-review/src/components/decision/DecisionLayout.js
+++ b/app/components/component-xpub-review/src/components/decision/DecisionLayout.js
@@ -11,12 +11,12 @@ import Decision from './Decision'
 // import EditorSection from './EditorSection'
 import { Columns, Manuscript, Chat } from '../atoms/Columns'
 import AdminSection from '../atoms/AdminSection'
-
 // const addEditor = (manuscript, label) => ({
 //   content: <EditorSection manuscript={manuscript} />,
 //   key: manuscript.id,
 //   label,
 // })
+import MessageContainer from '../../../../component-chat/src'
 
 const DecisionLayout = ({
   handleSubmit,
@@ -100,7 +100,9 @@ const DecisionLayout = ({
         />
       </Manuscript>
 
-      <Chat></Chat>
+      <Chat>
+        <MessageContainer channelId={manuscript.channel.id} />
+      </Chat>
     </Columns>
   )
 }
diff --git a/app/components/layout.js b/app/components/layout.js
new file mode 100644
index 0000000000000000000000000000000000000000..4e4bd695437658483c74b74d30eac1189b783f78
--- /dev/null
+++ b/app/components/layout.js
@@ -0,0 +1,205 @@
+import styled from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+export const NAVBAR_WIDTH = 72
+export const NAVBAR_EXPANDED_WIDTH = 256
+export const MIN_PRIMARY_COLUMN_WIDTH = 600
+export const MIN_SECONDARY_COLUMN_WIDTH = 320
+export const MAX_PRIMARY_COLUMN_WIDTH = 968
+export const MAX_SECONDARY_COLUMN_WIDTH = 400
+export const COL_GAP = 24
+export const TITLEBAR_HEIGHT = 62
+export const MIN_MAX_WIDTH =
+  MIN_PRIMARY_COLUMN_WIDTH + MIN_SECONDARY_COLUMN_WIDTH + COL_GAP
+export const MAX_WIDTH =
+  MAX_PRIMARY_COLUMN_WIDTH + MAX_SECONDARY_COLUMN_WIDTH + COL_GAP
+export const MIN_WIDTH_TO_EXPAND_NAVIGATION = MAX_WIDTH + 256
+export const SINGLE_COLUMN_WIDTH = MAX_WIDTH
+// add 144 (72 * 2) to account for the left side nav
+export const MEDIA_BREAK =
+  MIN_PRIMARY_COLUMN_WIDTH +
+  MIN_SECONDARY_COLUMN_WIDTH +
+  COL_GAP +
+  NAVBAR_WIDTH * 2
+
+/*
+  do not remove this className.
+  see `src/routes.js` for an explanation of what's going on here
+*/
+export const ViewGrid = styled.main.attrs({
+  id: 'main',
+  className: 'view-grid',
+})`
+  display: grid;
+  grid-area: main;
+  height: 100%;
+  max-height: 100vh;
+  overflow: hidden;
+  overflow-y: auto;
+  @media (max-width: ${MEDIA_BREAK}px) {
+    max-height: calc(100vh - ${TITLEBAR_HEIGHT}px);
+  }
+`
+
+/*
+┌──┬────────┬──┐
+│  │   xx   │  │
+│  │        │  │
+│  │   xx   │  │
+│  │        │  │
+│  │   xx   │  │
+└──┴────────┴──┘
+*/
+export const SingleColumnGrid = styled.div`
+  display: grid;
+  justify-self: center;
+  grid-template-columns: ${MIN_MAX_WIDTH}px;
+  grid-template-areas: 'primary';
+  background: ${th('colorBackground')};
+  overflow-y: auto;
+  @media (max-width: ${MEDIA_BREAK}px) {
+    width: 100%;
+    max-width: 100%;
+    grid-template-columns: 1fr;
+    border-left: 0;
+    border-right: 0;
+  }
+`
+
+/*
+┌──┬────┬─┬─┬──┐
+│  │ xx │ │x│  │
+│  │    │ │ │  │
+│  │ xx │ │x│  │
+│  │    │ │ │  │
+│  │ xx │ │x│  │
+└──┴────┴─┴─┴──┘
+*/
+export const PrimarySecondaryColumnGrid = styled.div`
+  display: grid;
+  justify-self: center;
+  grid-template-columns:
+    minmax(${MIN_PRIMARY_COLUMN_WIDTH}px, ${MAX_PRIMARY_COLUMN_WIDTH}px)
+    minmax(${MIN_SECONDARY_COLUMN_WIDTH}px, ${MAX_SECONDARY_COLUMN_WIDTH}px);
+  grid-template-rows: 100%;
+  grid-template-areas: 'primary secondary';
+  grid-gap: ${COL_GAP}px;
+  max-width: ${MAX_WIDTH}px;
+  @media (max-width: ${MEDIA_BREAK}px) {
+    grid-template-columns: 1fr;
+    grid-template-rows: min-content 1fr;
+    grid-gap: 0;
+    min-width: 100%;
+  }
+`
+
+/*
+┌──┬─┬─┬────┬──┐
+│  │x│ │ xx │  │
+│  │ │ │    │  │
+│  │x│ │ xx │  │
+│  │ │ │    │  │
+│  │x│ │ xx │  │
+└──┴─┴─┴────┴──┘
+*/
+export const SecondaryPrimaryColumnGrid = styled.div`
+  display: grid;
+  justify-self: center;
+  grid-template-columns:
+    minmax(${MIN_SECONDARY_COLUMN_WIDTH}px, ${MAX_SECONDARY_COLUMN_WIDTH}px)
+    minmax(${MIN_PRIMARY_COLUMN_WIDTH}px, ${MAX_PRIMARY_COLUMN_WIDTH}px);
+  grid-template-rows: 100%;
+  grid-template-areas: 'secondary primary';
+  grid-gap: ${COL_GAP}px;
+  max-width: ${MAX_WIDTH}px;
+  margin: 0 24px;
+  @media (max-width: ${MEDIA_BREAK}px) {
+    grid-template-columns: 1fr;
+    grid-template-rows: 1fr;
+    grid-gap: 0;
+    min-width: 100%;
+    max-width: 100%;
+  }
+`
+
+/*
+┌─────────────┐
+│             │
+│    ┌───┐    │
+│    │ x │    │
+│    └───┘    │
+│             │
+└─────────────┘
+*/
+export const CenteredGrid = styled.div`
+  display: grid;
+  justify-self: center;
+  grid-template-columns: ${MAX_WIDTH}px;
+  align-self: center;
+  max-width: ${MAX_PRIMARY_COLUMN_WIDTH}px;
+  grid-template-columns: minmax(
+    ${MIN_PRIMARY_COLUMN_WIDTH}px,
+    ${MAX_PRIMARY_COLUMN_WIDTH}px
+  );
+  @media (max-width: ${MEDIA_BREAK}px) {
+    align-self: flex-start;
+    width: 100%;
+    max-width: 100%;
+    grid-template-columns: 1fr;
+    height: calc(100vh - ${TITLEBAR_HEIGHT}px);
+  }
+`
+
+export const PrimaryColumn = styled.section`
+  border-left: 1px solid ${th('colorBorder')};
+  border-right: 1px solid ${th('colorBorder')};
+  border-bottom: 1px solid ${th('colorBorder')};
+  border-radius: 0 0 4px 4px;
+  height: 100%;
+  max-width: ${props =>
+    !props.fullWidth ? `${MAX_PRIMARY_COLUMN_WIDTH}px` : 'none'};
+  grid-area: primary;
+  display: grid;
+  grid-template-rows: 1fr;
+  @media (max-width: ${MEDIA_BREAK}px) {
+    border-left: 0;
+    border-right: 0;
+    border-bottom: 0;
+    grid-column-start: 1;
+    max-width: 100%;
+    height: calc(100vh - ${TITLEBAR_HEIGHT}px);
+  }
+`
+
+export const SecondaryColumn = styled.section`
+  height: 100vh;
+  overflow: hidden;
+  overflow-y: auto;
+  position: sticky;
+  top: 0;
+  padding-bottom: 48px;
+  grid-area: secondary;
+  &::-webkit-scrollbar {
+    width: 0px;
+    height: 0px;
+    background: transparent; /* make scrollbar transparent */
+  }
+  @media (max-width: ${MEDIA_BREAK}px) {
+    height: calc(100vh - ${TITLEBAR_HEIGHT}px);
+    display: none;
+  }
+`
+
+export const ChatInputWrapper = styled.div`
+  position: sticky;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  z-index: 3000;
+  @media (max-width: ${MEDIA_BREAK}px) {
+    width: 100vw;
+    position: fixed;
+    bottom: 0;
+    left: 0;
+  }
+`
diff --git a/app/fragmentTypes.json b/app/fragmentTypes.json
new file mode 100644
index 0000000000000000000000000000000000000000..60f2ad261ff182ac10dfc1233ca4fc615fbefe2a
--- /dev/null
+++ b/app/fragmentTypes.json
@@ -0,0 +1 @@
+{"__schema":{"types":[{"kind":"INTERFACE","name":"Identity","possibleTypes":[{"name":"LocalIdentity"},{"name":"ExternalIdentity"}]},{"kind":"INTERFACE","name":"Object","possibleTypes":[{"name":"Journal"},{"name":"Manuscript"},{"name":"ManuscriptVersion"},{"name":"File"},{"name":"Review"},{"name":"Note"}]}]}}
\ No newline at end of file
diff --git a/app/globals.js b/app/globals.js
new file mode 100644
index 0000000000000000000000000000000000000000..0135d12727193960a9d932afe202bfdffb303f6e
--- /dev/null
+++ b/app/globals.js
@@ -0,0 +1,84 @@
+import styled, { css } from 'styled-components'
+import { th } from '@pubsweet/ui-toolkit'
+
+export const hexa = (hex, alpha) => {
+  const r = parseInt(hex.slice(1, 3), 16)
+  const g = parseInt(hex.slice(3, 5), 16)
+  const b = parseInt(hex.slice(5, 7), 16)
+
+  if (alpha >= 0) {
+    return `rgba(${r}, ${g}, ${b}, ${alpha})`
+  }
+  return `rgb(${r}, ${g}, ${b})`
+}
+
+export const zIndex = new (function() {
+  // Write down a camel-cased element descriptor as the name (e.g. modal or chatInput).
+  // Define at a component level here, then use math to handle order at a local level.
+  // (e.g. const ModalInput = styled.input`z-index: zIndex.modal + 1`;)
+  // This uses constructor syntax because that allows self-referential math
+
+  this.base = 1 // z-index: auto content will go here or inherit z-index from a parent
+
+  this.background = this.base - 1 // content that should always be behind other things (e.g. textures/illos)
+  this.hidden = this.base - 2 // this content should be hidden completely (USE ADD'L MEANS OF HIDING)
+
+  this.card = this.base + 1 // all cards should default to one layer above the base content
+  this.loading = this.card + 1 // loading elements should never appear behind cards
+  this.avatar = this.card + 1 // avatars should never appear behind cards
+  this.form = this.card + 1 // form elements should never appear behind cards
+  this.search = this.form // search is a type of form and should appear at the same level
+  this.dmInput = this.form
+
+  this.composerToolbar = 2000 // composer toolbar - should sit in between most elements
+
+  this.chrome = 3000 // chrome should be visible in modal contexts
+  this.navBar = this.chrome // navBar is chrome and should appear at the same level
+  this.mobileInput = this.chrome + 1 // the chatInput on mobile should appear above the navBar
+  this.dropDown = this.chrome + 1 // dropDowns shouldn't appear behind the navBar
+
+  this.slider = window.innerWidth < 768 ? this.chrome + 1 : this.chrome // slider should appear significantly above the base to leave room for other elements
+  this.composer = 4000 // should cover all screen except toasts
+  this.chatInput = this.slider + 1 // the slider chatInput should always appear above the slider
+  this.flyout = this.chatInput + 3 // flyout may overlap with chatInput and should take precedence
+
+  this.fullscreen = 4000 // fullscreen elements should cover all screen content except toasts
+
+  this.modal = 5000 // modals should completely cover base content and slider as well
+  this.gallery = this.modal + 1 // gallery should never appear behind a modal
+
+  this.toast = 6000 // toasts should be visible in every context
+  this.tooltip = this.toast + 1 // tooltips should always be on top
+})()
+
+export const Truncate = () => css`
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+  min-width: 0;
+`
+
+export const FlexRow = styled.div`
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-start;
+  align-items: center;
+`
+
+export const HorizontalRule = styled(FlexRow)`
+  position: relative;
+  justify-content: center;
+  align-items: center;
+  align-self: stretch;
+  color: ${th('colorBorder')};
+
+  hr {
+    display: inline-block;
+    flex: 1 0 auto;
+    border-top: 1px solid ${th('colorBorder')};
+  }
+
+  div {
+    margin: 0 16px;
+  }
+`
diff --git a/app/hooks/useAppScroller.js b/app/hooks/useAppScroller.js
new file mode 100644
index 0000000000000000000000000000000000000000..ddc56cee1175e512fcbe3dc16eb3e36e3ed17b88
--- /dev/null
+++ b/app/hooks/useAppScroller.js
@@ -0,0 +1,26 @@
+import { useState, useEffect } from 'react'
+
+export const useAppScroller = () => {
+  const [ref, setRef] = useState(null)
+
+  useEffect(() => {
+    if (!ref) setRef(document.getElementById('main'))
+  })
+
+  const scrollToTop = () => {
+    const elem = ref || document.getElementById('main')
+    if (elem) return (elem.scrollTop = 0)
+  }
+
+  const scrollToBottom = () => {
+    const elem = ref || document.getElementById('main')
+    if (elem) return (elem.scrollTop = elem.scrollHeight - elem.clientHeight)
+  }
+
+  const scrollTo = pos => {
+    const elem = ref || document.getElementById('main')
+    if (elem) return (elem.scrollTop = pos)
+  }
+
+  return { scrollToTop, scrollTo, scrollToBottom, ref }
+}
diff --git a/app/queries/index.js b/app/queries/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..33aaf2f5a11989807c2a6aad0105054f657519d3
--- /dev/null
+++ b/app/queries/index.js
@@ -0,0 +1,70 @@
+import gql from 'graphql-tag'
+
+export const GET_CURRENT_USER = gql`
+  query currentUser {
+    currentUser {
+      id
+      profilePicture
+      username
+      defaultIdentity {
+        aff
+        name {
+          surname
+        }
+        type
+        ... on ExternalIdentity {
+          identifier
+        }
+        ... on LocalIdentity {
+          email
+        }
+      }
+    }
+  }
+`
+
+export const GET_USER = gql`
+  query user($id: ID, $username: String) {
+    user(id: $id, username: $username) {
+      id
+      username
+      profilePicture
+      online
+    }
+  }
+`
+
+export const CREATE_MESSAGE = gql`
+  mutation createMessage($content: String, $channelId: String) {
+    createMessage(content: $content, channelId: $channelId) {
+      content
+      user {
+        username
+      }
+    }
+  }
+`
+
+export const GET_MESSAGE_BY_ID = gql`
+  query messageById($messageId: ID) {
+    message(messageId: $messageId) {
+      id
+      content
+      user {
+        username
+        profilePicture
+      }
+    }
+  }
+`
+
+export const SEARCH_USERS = gql`
+  query searchUsers($teamId: ID, $query: String) {
+    searchUsers(teamId: $teamId, query: $query) {
+      id
+      username
+      profilePicture
+      online
+    }
+  }
+`
diff --git a/app/routes.js b/app/routes.js
index 86e18426d1754823851374c17aeee763d30484e6..04945ff3c1daf6d13d48f78cd9b46b6e6f495965 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -17,11 +17,14 @@ import TeamPage from './components/component-xpub-teams-manager/src/components/T
 import DecisionPage from './components/component-xpub-review/src/components/DecisionPage'
 import FormBuilderPage from './components/component-xpub-formbuilder/src/components/FormBuilderPage'
 
+import { Profile } from './components/component-profile/src'
+
 import App from './components/App'
 
 // const createReturnUrl = ({ pathname, search = '' }) => pathname + search
 // const loginUrl = location => `/login?next=${createReturnUrl(location)}`
 
+// TODO: Redirect if token expires
 const PrivateRoute = ({ component: Component, ...rest }) => (
   <Route
     {...rest}
@@ -77,6 +80,7 @@ export default (
         exact
         path="/journals/:journal/versions/:version/decisions/:decision"
       />
+      <PrivateRoute path="/profile" component={Profile} exact path="/profile" />
       {/* <PrivateRoute
         component={FindReviewersPage}
         exact
diff --git a/app/shared/time-formatting.js b/app/shared/time-formatting.js
new file mode 100644
index 0000000000000000000000000000000000000000..512f3c58b9d30ca8f3b0303f4d45a498866dfe55
--- /dev/null
+++ b/app/shared/time-formatting.js
@@ -0,0 +1,32 @@
+export const convertTimestampToDate = timestamp => {
+  const monthNames = [
+    'January',
+    'February',
+    'March',
+    'April',
+    'May',
+    'June',
+    'July',
+    'August',
+    'September',
+    'October',
+    'November',
+    'December',
+  ]
+  const date = new Date(timestamp)
+  const day = date.getDate()
+  const monthIndex = date.getMonth()
+  const month = monthNames[monthIndex]
+  const year = date.getFullYear()
+  const hours = date.getHours() || 0
+  let cleanHours
+  if (hours === 0) {
+    cleanHours = 12 // if timestamp is between midnight and 1am, show 12:XX am
+  } else {
+    cleanHours = hours > 12 ? hours - 12 : hours // else show proper am/pm -- todo: support 24hr time
+  }
+  let minutes = date.getMinutes()
+  minutes = minutes >= 10 ? minutes : `0${minutes.toString()}` // turns 4 minutes into 04 minutes
+  const ampm = hours >= 12 ? 'pm' : 'am' // todo: support 24hr time
+  return `${month} ${day}, ${year} at ${cleanHours}:${minutes}${ampm}`
+}
diff --git a/app/sortAndGroup.js b/app/sortAndGroup.js
new file mode 100644
index 0000000000000000000000000000000000000000..3ee32d7944fcb34059c9b99f71b60099f3a57741
--- /dev/null
+++ b/app/sortAndGroup.js
@@ -0,0 +1,113 @@
+/* eslint-disable import/prefer-default-export */
+// @flow
+// import sortByDate from '../sort-by-date';
+
+// type Output = {
+//   ...$Exact<MessageInfoType>,
+//   timestamp: string,
+// };
+
+export const sortAndGroupMessages = messages => {
+  if (messages.length === 0) return []
+  // messages = sortByDate(messages, 'timestamp', 'asc');
+  const masterArray = []
+  let newArray = []
+  let checkId
+
+  for (let i = 0; i < messages.length; i += 1) {
+    // on the first message, get the user id and set it to be checked against
+    const robo = [
+      {
+        id: messages[i].created,
+        user: {
+          id: 'robo',
+        },
+        created: messages[i].created,
+        content: messages[i].created,
+        type: 'timestamp',
+      },
+    ]
+
+    if (i === 0) {
+      checkId = messages[i].user.id
+      masterArray.push(robo)
+    }
+
+    const sameUser =
+      messages[i].user.id !== 'robo' && messages[i].user.id === checkId
+    const oldMessage = (current, previous) => {
+      // => boolean
+      /*
+        Rethink db returns string timestamps. We need to convert them
+        into a number format so that we can compare message timestamps
+        more easily. .getTime() will convert something like:
+
+        '2017-05-02T18:15:20.769Z'
+
+        into:
+
+        '1493748920.769'
+
+        We then determine if the current message being evaluated is older
+        than the previous evaulated message by a certain integer to determine
+        if we should render a timestamp in the UI and create a new bubbleGroup
+      */
+      const c = new Date(current.created).getTime()
+      const p = new Date(previous.created).getTime()
+      return c > p + 3600000 * 6 // six hours;
+    }
+
+    // if we are evaulating a bubble from the same user
+    if (sameUser) {
+      // if we are still on the first message
+      if (i === 0) {
+        // push the message to the array
+        newArray.push(messages[i])
+      } else {
+        // if we're on to the second message, we need to evaulate the timestamp
+        // if the second message is older than the first message by our variance
+        if (oldMessage(messages[i], messages[i - 1])) {
+          // push the batch of messages to master array
+          masterArray.push(newArray)
+          // insert a new robotext timestamp
+          masterArray.push(robo)
+          // reset the batch of new messages
+          newArray = []
+          // populate the new batch of messages with this next old message
+          newArray.push(messages[i])
+        } else {
+          // if the message isn't older than our preferred variance,
+          // we keep populating the same batch of messages
+          newArray.push(messages[i])
+        }
+      }
+      // and maintain the checkid
+      checkId = messages[i].user.id
+      // if the next message is from a new user
+    } else {
+      // we push the previous user's messages to the masterarray
+      masterArray.push(newArray)
+      // if the new users message is older than our preferred variance
+      if (i > 0 && oldMessage(messages[i], messages[i - 1])) {
+        // push a robo timestamp
+        masterArray.push(robo)
+        newArray = []
+        newArray.push(messages[i])
+      } else {
+        // clear the messages array from the previous user
+        newArray = []
+        // and start a new batch of messages from the currently evaluating user
+        newArray.push(messages[i])
+      }
+
+      // set a new checkid for the next user
+      checkId = messages[i].user.id
+    }
+  }
+
+  // when done, push the final batch of messages to masterArray
+  // masterArray.push(newArray);
+  // and return masterArray to the component
+  masterArray.push(newArray)
+  return masterArray
+}
diff --git a/app/storage/submit-backup2.json b/app/storage/submit-backup2.json
new file mode 100644
index 0000000000000000000000000000000000000000..79aa2056568cc9a44ef63a479ae51d74daa780ed
--- /dev/null
+++ b/app/storage/submit-backup2.json
@@ -0,0 +1,358 @@
+{
+  "name": "Research Object Submission Form",
+  "id": "submit",
+  "children": [
+    {
+      "title": "Name",
+      "id": "1531303631370",
+      "component": "TextField",
+      "name": "submission.name",
+      "placeholder": "Enter your name",
+      "validate": [
+        "required"
+      ],
+      "validateValue": {
+        "minChars": "10"
+      },
+      "order": "1"
+    },
+    {
+      "title": "Affiliation",
+      "id": "1531303727228",
+      "component": "TextField",
+      "name": "submission.affiliation",
+      "placeholder": "Enter your affiliation",
+      "validate": [
+        "required"
+      ],
+      "validateValue": {
+        "minChars": "10"
+      },
+      "order": "2"
+    },
+    {
+      "title": "Email and contact information",
+      "id": "1531726163478",
+      "component": "TextField",
+      "name": "submission.contact",
+      "order": "3",
+      "placeholder": "Enter your contact information"
+    },
+    {
+      "title": "Keywords",
+      "id": "1531303777701",
+      "component": "TextField",
+      "name": "submission.keywords",
+      "placeholder": "Enter keywords...",
+      "parse": "split",
+      "format": "join",
+      "validate": [
+        "required"
+      ],
+      "order": "20"
+    },
+    {
+      "title": "Type of Research Object",
+      "id": "1531303833528",
+      "component": "Menu",
+      "name": "submission.objectType",
+      "options": [
+        {
+          "value": "original-research",
+          "label": "Original Research Report"
+        },
+        {
+          "label": "Review",
+          "value": "review"
+        },
+        {
+          "value": "opinion",
+          "label": "Opinion/Commentary"
+        },
+        {
+          "value": "registered-report",
+          "label": "Registered Report"
+        }
+      ],
+      "validate": [
+        "required"
+      ],
+      "order": "7"
+    },
+    {
+      "title": "Suggested reviewers",
+      "id": "1531304848635",
+      "component": "TextField",
+      "placeholder": "Add reviewer names...",
+      "name": "submission.suggested",
+      "parse": "split",
+      "format": "join",
+      "order": "13"
+    },
+    {
+      "title": "Upload supplementary materials",
+      "id": "1531305332347",
+      "component": "SupplementaryFiles",
+      "description": "<p>Upload your files.</p>",
+      "name": "fileName",
+      "order": "19"
+    },
+    {
+      "title": "Cover letter",
+      "id": "1591172762874",
+      "component": "AbstractEditor",
+      "name": "submission.cover",
+      "description": "<p>Cover letter describing submission, relevant implications, and timely information to consider</p>",
+      "order": "4",
+      "placeholder": "Enter your cover letter"
+    },
+    {
+      "title": "Ethics statement",
+      "id": "1591173029656",
+      "component": "AbstractEditor",
+      "name": "submission.ethics",
+      "placeholder": "Enter your ethics statements",
+      "order": "6"
+    },
+    {
+      "title": "Data and Code availability statements",
+      "id": "1591173076622",
+      "component": "AbstractEditor",
+      "name": "submission.datacode",
+      "placeholder": "Enter your data and code availability statement",
+      "order": "5"
+    },
+    {
+      "title": "Links",
+      "id": "1591192678688",
+      "component": "TextField",
+      "name": "submission.links",
+      "placeholder": "Enter your links, separated by commas",
+      "order": "14",
+      "parse": "split",
+      "format": "join"
+    },
+    {
+      "title": "Did your study involve healthy subjects only or patients (note that patient studies may also involve healthy subjects)",
+      "id": "1591193412418",
+      "component": "Menu",
+      "name": "submission.subjects",
+      "options": [
+        {
+          "label": "Healthy subjects",
+          "value": "healthy_subjects"
+        },
+        {
+          "label": "Patients",
+          "value": "patients"
+        }
+      ]
+    },
+    {
+      "title": "If your research involved human subjects, was the research approved by the relevant Institutional Review Board or ethics panel?",
+      "id": "1591193588182",
+      "component": "Menu",
+      "name": "submission.irb",
+      "options": [
+        {
+          "label": "Yes",
+          "value": "yes"
+        },
+        {
+          "label": "No",
+          "value": "no"
+        },
+        {
+          "label": " Not applicable (My Research Object does not involve human subjects) ",
+          "value": "N/A"
+        }
+      ],
+      "description": "<p><i>NOTE: Any human subjects studies without IRB approval will be automatically rejected.</i></p>"
+    },
+    {
+      "title": "Was any animal research approved by the relevant IACUC or other animal research panel?",
+      "id": "1591343661290",
+      "component": "Menu",
+      "name": "submission.animal_research_approval",
+      "description": "<p><i>NOTE: Any animal studies without IACUC approval will be automatically rejected.</i></p>",
+      "options": [
+        {
+          "label": "Yes",
+          "value": "yes"
+        },
+        {
+          "label": "No",
+          "value": "no"
+        },
+        {
+          "label": " Not applicable (My Research Object does not involve animal subjects)",
+          "value": "N/A"
+        }
+      ],
+      "order": "21"
+    },
+    {
+      "title": "Please indicate which methods were used in your research:",
+      "id": "1591343849679",
+      "component": "CheckboxGroup",
+      "name": "submission.methods",
+      "options": [
+        {
+          "label": "Structural MRI",
+          "value": "Structural MRI"
+        },
+        {
+          "label": "Functional MRI",
+          "value": "Functional MRI"
+        },
+        {
+          "label": "Diffusion MRI",
+          "value": "Diffusion MRI"
+        },
+        {
+          "label": "EEG/ERP",
+          "value": "EEG/ERP"
+        },
+        {
+          "label": "Neurophysiology",
+          "value": "Neurophysiology"
+        },
+        {
+          "label": "PET",
+          "value": "PET"
+        },
+        {
+          "label": "MEG",
+          "value": "MEG"
+        },
+        {
+          "label": "Optical Imaging",
+          "value": "Optical Imaging"
+        },
+        {
+          "label": "Postmortem anatomy",
+          "value": "Postmortem anatomy"
+        },
+        {
+          "label": "TMS",
+          "value": "TMS"
+        },
+        {
+          "label": "Behavior",
+          "value": "Behavior"
+        },
+        {
+          "label": "Neuropsychological testing",
+          "value": "Neuropsychological testing"
+        },
+        {
+          "label": "Computational modeling",
+          "value": "Computational modeling"
+        }
+      ],
+      "order": "22"
+    },
+    {
+      "title": "If you used other research methods, please specify:",
+      "id": "1591344092275",
+      "component": "TextField",
+      "name": "submission.otherMethods",
+      "placeholder": "Enter any additional methods used, if applicable",
+      "order": "23"
+    },
+    {
+      "title": "For human MRI, what field strength scanner do you use?",
+      "id": "1591344209443",
+      "component": "Menu",
+      "name": "submission.humanMRI",
+      "order": "24",
+      "options": [
+        {
+          "label": "1T",
+          "value": "1T"
+        },
+        {
+          "label": "1.5T",
+          "value": "1.5T"
+        },
+        {
+          "label": "2T",
+          "value": "2T"
+        },
+        {
+          "label": "3T",
+          "value": "3T"
+        },
+        {
+          "label": "4T",
+          "value": "4T"
+        },
+        {
+          "label": "7T",
+          "value": "7T"
+        }
+      ]
+    },
+    {
+      "title": "If other, please specify:",
+      "id": "1591345370930",
+      "component": "TextField",
+      "name": "submission.humanMRIother",
+      "order": "24"
+    },
+    {
+      "title": "Which processing packages did you use for your study?",
+      "id": "1591345419676",
+      "component": "CheckboxGroup",
+      "name": "submission.packages",
+      "options": [
+        {
+          "label": "AFNI",
+          "value": "AFNI"
+        },
+        {
+          "label": "SPM",
+          "value": "SPM"
+        },
+        {
+          "label": "Brain Voyager",
+          "value": "Brain Voyager"
+        },
+        {
+          "label": "FSL",
+          "value": "FSL"
+        },
+        {
+          "label": "Analyze",
+          "value": "Analyze"
+        },
+        {
+          "label": "Free Surfer",
+          "value": "Free Surfer"
+        },
+        {
+          "label": "LONI Pipeline",
+          "value": "LONI Pipeline"
+        }
+      ],
+      "order": "25"
+    },
+    {
+      "title": "If you used any other processing packages, please list them here:",
+      "id": "1591345560536",
+      "component": "TextField",
+      "name": "submission.otherPackages"
+    },
+    {
+      "title": "Provide references using author date format:",
+      "id": "1591345620839",
+      "component": "AbstractEditor",
+      "name": "submission.references",
+      "order": "26"
+    }
+  ],
+  "description": "<p>Aperture is now accepting Research Object Submissions. Please fill out the form below to complete your submission.</p>",
+  "haspopup": "true",
+  "popuptitle": "By submitting the manuscript, you agree to the following statements.",
+  "popupdescription": "<p>The corresponding author confirms that all co-authors are included, and that everyone listed as a co-author agrees to that role and all the following requirements and acknowledgements.</p><p></p><p>The submission represents original work and that sources are given proper attribution. The journal employs CrossCheck to compare submissions against a large and growing database of published scholarly content. If in the judgment of a senior editor a submission is genuinely suspected of plagiarism, it will be returned to the author(s) with a request for explanation.)</p><p></p><p>The research was conducted in accordance with ethical principles.</p><p></p><p>There is a Data Accessibility Statement, containing information about the location of open data and materials, in the manuscript.</p><p></p><p>A conflict of interest statement is present in the manuscript, even if to state no conflicts of interest.</p>"
+}
\ No newline at end of file
diff --git a/config/components.json b/config/components.json
index eec9889fd0e3b251f7258f5437210cce473b5c4b..c7c4ad725a8e4d917fdaee164ab3a614e5cc001c 100644
--- a/config/components.json
+++ b/config/components.json
@@ -1,6 +1,6 @@
 [
   "@pubsweet/model-team",
-  "@pubsweet/model-user",
+  "./server/model-user",
   "./server/model-journal/src/",
   "./server/model-manuscript/src/",
   "./server/model-file/src/",
@@ -9,5 +9,8 @@
   "./server/formbuilder/src/",
   "./server/teamMember/src/",
   "@pubsweet/job-xsweet",
-  "@pubsweet/component-password-reset-server"
+  "@pubsweet/component-password-reset-server",
+  "./server/model-channel",
+  "./server/model-message",
+  "./server/profile-upload"
 ]
diff --git a/package.json b/package.json
index 12803b138513a4bedeb8f15810dbf0b81bfc5b45..cb0c7dddd7db21325ab1bdb97c52e09150de726c 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
     "graphql": "^14.0.2",
     "graphql-tools": "^4.0.0",
     "history": "^4.7.2",
+    "jimp": "^0.13.0",
     "joi": "^10.0.6",
     "loadable-components": "^0.3.0",
     "moment": "^2.18.1",
@@ -44,11 +45,15 @@
     "pubsweet-server": "13.7.2",
     "react": "^16.3.2",
     "react-dom": "^16.3.2",
-    "react-dropzone": "^4.1.2",
+    "react-dropzone": "^10.2.2",
     "react-html-parser": "^2.0.2",
+    "react-image": "^4.0.1",
+    "react-markdown": "^4.3.1",
+    "react-mentions": "^3.3.1",
     "react-moment": "^0.9.6",
     "react-router-dom": "^5.1.2",
     "react-select1": "npm:react-select@^1.0.0",
+    "react-visibility-sensor": "^5.1.1",
     "recompose": "^0.30.0",
     "styled-components": "^4.1.1",
     "supertest": "^3.0.0",
@@ -61,7 +66,9 @@
     "xpub-edit": "^2.6.10",
     "xpub-journal": "^0.1.0",
     "xpub-validators": "^0.0.28",
-    "xpub-with-context": "^0.2.0"
+    "xpub-with-context": "^0.2.0",
+    "y-protocols": "^1.0.0",
+    "yjs": "^13.2.0"
   },
   "devDependencies": {
     "@babel/core": "^7.0.0",
@@ -135,7 +142,7 @@
     "server": "pubsweet start:server",
     "client": "pubsweet start:client",
     "start:services": "docker-compose up postgres",
-    "start:server-and-client": "start-test server 3000 client",
+    "start:server-and-client": "start-test server 'http://localhost:3000/healthcheck' client",
     "test:all": "start-test start:server-and-client 4000 test",
     "test": "cypress run",
     "__cleanNodeModules": "find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \\;",
diff --git a/scripts/introspectionMatcher.js b/scripts/introspectionMatcher.js
new file mode 100644
index 0000000000000000000000000000000000000000..d95fed899eae7c250f1fb7a45fce4e32a60f4038
--- /dev/null
+++ b/scripts/introspectionMatcher.js
@@ -0,0 +1,45 @@
+/* eslint-disable no-param-reassign */
+/* eslint-disable no-console */
+/* eslint-disable no-underscore-dangle */
+const fetch = require('node-fetch')
+const fs = require('fs')
+
+fetch(`http://localhost:3000/graphql`, {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({
+    variables: {},
+    query: `
+      {
+        __schema {
+          types {
+            kind
+            name
+            possibleTypes {
+              name
+            }
+          }
+        }
+      }
+    `,
+  }),
+})
+  .then(result => result.json())
+  .then(result => {
+    // here we're filtering out any type information unrelated to unions or interfaces
+    const filteredData = result.data.__schema.types.filter(
+      type => type.possibleTypes !== null,
+    )
+    result.data.__schema.types = filteredData
+    fs.writeFile(
+      './app/fragmentTypes.json',
+      JSON.stringify(result.data),
+      err => {
+        if (err) {
+          console.error('Error writing fragmentTypes file', err)
+        } else {
+          console.log('Fragment types successfully extracted!')
+        }
+      },
+    )
+  })
diff --git a/server/app.js b/server/app.js
index 0affb3b27afdf8c6e89f2e6e81f16322e1edbd75..69912c4e7b66dff7217b4501c1b0654423bc8f39 100644
--- a/server/app.js
+++ b/server/app.js
@@ -22,7 +22,7 @@ const registerComponents = require('pubsweet-server/src/register-components') //
 
 // Wax Collab requirements
 const WebSocket = require('ws')
-const wsUtils = require('./server-util.js')
+const wsUtils = require('./wax-collab/server-util.js')
 const cookie = require('cookie')
 const EventEmitter = require('events')
 
@@ -96,6 +96,7 @@ const configureApp = app => {
 
   // Serve the index page for front end
   // app.use('/', index)
+  app.use('/healthcheck', (req, res) => res.send('All good!'))
 
   app.use((err, req, res, next) => {
     // development error handler, will print stacktrace
diff --git a/server/model-channel/src/channel.js b/server/model-channel/src/channel.js
index 410bad72a2f0406004c19ba888c7e97341ea67bb..47040abc05e29a4b392f7588353b9e1a5949415a 100644
--- a/server/model-channel/src/channel.js
+++ b/server/model-channel/src/channel.js
@@ -6,7 +6,13 @@ class Channel extends BaseModel {
   }
 
   static get relationMappings() {
-    const { ChannelMember, User, Message, Team } = require('@pubsweet/models')
+    const {
+      ChannelMember,
+      User,
+      Message,
+      Team,
+      Manuscript,
+    } = require('@pubsweet/models')
 
     return {
       messages: {
@@ -33,6 +39,14 @@ class Channel extends BaseModel {
           to: 'teams.id',
         },
       },
+      manuscript: {
+        relation: BaseModel.HasOneRelation,
+        modelClass: Manuscript,
+        join: {
+          from: 'channels.id',
+          to: 'manuscripts.channelId',
+        },
+      },
       users: {
         relation: BaseModel.ManyToManyRelation,
         modelClass: User,
diff --git a/server/model-channel/src/graphql/index.js b/server/model-channel/src/graphql/index.js
index 2fb80e192e1345d92b31a727670d47a6b9b1e456..736540b3bbae2eaa16da317bd04b34a4eeda4b1c 100644
--- a/server/model-channel/src/graphql/index.js
+++ b/server/model-channel/src/graphql/index.js
@@ -3,6 +3,10 @@ const fetch = require('node-fetch')
 
 const resolvers = {
   Query: {
+    manuscriptChannel: async (_, { manuscriptId }, context) => {
+      const manuscript = context.connectors.Manuscript.fetchOne(manuscriptId)
+      return Channel.find(manuscript.channelId)
+    },
     teamByName: async (_, { name }, context) => {
       const Team = context.connectors.Team.model
       return Team.query()
@@ -95,6 +99,7 @@ const typeDefs = `
     findByDOI(doi: String): Channel
     searchOnCrossref(searchTerm: String): [Work]
     channels: [Channel]
+    manuscriptChannel: Channel
   }
 
   extend type Mutation {
diff --git a/server/model-channel/src/migrations/1585323910-add-channels.sql b/server/model-channel/src/migrations/1585323910-add-channels.sql
index 774d4db2ab0a8e8e611f48d500d551d55c4ff88a..b05165a2ddea2509eca692d5cf6da34525964dc1 100644
--- a/server/model-channel/src/migrations/1585323910-add-channels.sql
+++ b/server/model-channel/src/migrations/1585323910-add-channels.sql
@@ -6,7 +6,7 @@ CREATE TABLE channels (
   updated TIMESTAMP WITH TIME ZONE,
   name TEXT,
   topic TEXT,
-  type TEXT,
+  type TEXT
 );
 
 CREATE TABLE channel_members (
diff --git a/server/model-manuscript/src/resolvers.js b/server/model-manuscript/src/graphql.js
similarity index 64%
rename from server/model-manuscript/src/resolvers.js
rename to server/model-manuscript/src/graphql.js
index 2d1e49a89a86f29c9ce3a624f50cdbd6bbb0fca1..70f97c24ecc3244ada6108b50c283576f97adf51 100644
--- a/server/model-manuscript/src/resolvers.js
+++ b/server/model-manuscript/src/graphql.js
@@ -29,12 +29,16 @@ const resolvers = {
         }),
         status: 'new',
         submission,
+        channel: {
+          user_id: ctx.user,
+        },
       }
 
       // eslint-disable-next-line
       const manuscript = await new ctx.connectors.Manuscript.model(
         emptyManuscript,
-      ).save()
+      ).saveGraph()
+
       manuscript.manuscriptVersions = []
       manuscript.files = []
       files.map(async file => {
@@ -169,10 +173,13 @@ const resolvers = {
         object_id: manuscript.id,
       })
 
+      // TODO: Do this with eager loading relations
       manuscript.teams = await manuscript.getTeams()
       manuscript.reviews = await manuscript.getReviews()
       manuscript.manuscriptVersions = await manuscript.getManuscriptVersions()
-
+      manuscript.channel = await ctx.connectors.Channel.model.find(
+        manuscript.channelId,
+      )
       return manuscript
     },
     async manuscripts(_, { where }, ctx) {
@@ -192,4 +199,152 @@ const resolvers = {
   },
 }
 
-module.exports = resolvers
+const typeDefs = `
+  extend type Query {
+    globalTeams: [Team]
+    manuscript(id: ID!): Manuscript!
+    manuscripts: [Manuscript]!
+  }
+
+  extend type Mutation {
+    createManuscript(input: ManuscriptInput): Manuscript!
+    updateManuscript(id: ID!, input: String): Manuscript!
+    submitManuscript(id: ID!, input: String): Manuscript!
+    deleteManuscript(id: ID!): ID!
+    reviewerResponse(currentUserId: ID, action: String, teamId: ID! ): Team
+    assignTeamEditor(id: ID!, input: String): [Team]
+  }
+
+  type Manuscript implements Object {
+    id: ID!
+    created: DateTime!
+    updated: DateTime
+    manuscriptVersions: [ManuscriptVersion]
+    files: [File]
+    teams: [Team]
+    reviews: [Review!]
+    status: String
+    decision: String
+    suggestions: Suggestions
+    authors: [Author]
+    meta: ManuscriptMeta
+    submission: String
+    channel: Channel
+  }
+
+  type ManuscriptVersion implements Object {
+    id: ID!
+    created: DateTime!
+    updated: DateTime
+    files: [File]
+    teams: [Team]
+    reviews: [Review]
+    status: String
+    formState: String
+    decision: String
+    suggestions: Suggestions
+    authors: [Author]
+    meta: ManuscriptMeta
+    submission: String
+  }
+
+  input ManuscriptInput {
+    files: [FileInput]
+    meta: ManuscriptMetaInput
+    submission: String
+  }
+
+  input ManuscriptMetaInput {
+    title: String
+    source: String
+  }
+
+  input FileInput {
+    filename: String
+    url: String
+    mimeType: String
+    size: Int
+  }
+
+  type Author {
+    firstName: String
+    lastName: String
+    email: String
+    affiliation: String
+  }
+
+  type Suggestion {
+    suggested: String
+    opposed: String
+  }
+
+  type Suggestions {
+    reviewers: Suggestion
+    editors: Suggestion
+  }
+
+  type ManuscriptMeta {
+    title: String!
+    source: String
+    articleType: String
+    declarations: Declarations
+    articleSections: [String]
+    articleIds: [ArticleId]
+    abstract: String
+    subjects: [String]
+    history: [MetaDate]
+    publicationDates: [MetaDate]
+    notes: [Note]
+    keywords: String
+  }
+
+  type ArticleId {
+    pubIdType: String
+    id: String
+  }
+
+  type MetaDate {
+    type: String
+    date: DateTime
+  }
+
+  type Declarations {
+    openData: String
+    openPeerReview: String
+    preregistered: String
+    previouslySubmitted: String
+    researchNexus: String
+    streamlinedReview: String
+  }
+
+  type Note implements Object {
+    id: ID!
+    created: DateTime!
+    updated: DateTime
+    notesType: String
+    content: String
+  }
+
+  # type reviewerStatus {
+  #   user: ID!
+  #   status: String
+  # }
+
+  # input reviewerStatusUpdate {
+  #   user: ID!
+  #   status: String
+  # }
+
+  # extend type Team {
+  #   status: [reviewerStatus]
+  # }
+
+  # extend input TeamInput {
+  #   status: [reviewerStatusUpdate]
+  # }
+`
+
+module.exports = {
+  typeDefs,
+  resolvers,
+}
diff --git a/server/model-manuscript/src/index.js b/server/model-manuscript/src/index.js
index ed1efafd179dae3d5e7aade8a9ba42a824527cd6..dcc35aff68c11c7c2ff759b7134d5b745cb841a2 100644
--- a/server/model-manuscript/src/index.js
+++ b/server/model-manuscript/src/index.js
@@ -1,10 +1,8 @@
-const resolvers = require('./resolvers')
-const typeDefs = require('./typeDefs')
+const graphql = require('./graphql')
 const model = require('./manuscript')
 
 module.exports = {
   model,
   modelName: 'Manuscript',
-  resolvers,
-  typeDefs,
+  ...graphql,
 }
diff --git a/server/model-manuscript/src/manuscript.js b/server/model-manuscript/src/manuscript.js
index dfba3ae900b46e57319b69c917af3e270a124529..38c13711638161c65163a9a2cb5771ddeaa0b2d9 100644
--- a/server/model-manuscript/src/manuscript.js
+++ b/server/model-manuscript/src/manuscript.js
@@ -171,6 +171,21 @@ class Manuscript extends BaseModel {
     return this
   }
 
+  static get relationMappings() {
+    const { Channel } = require('@pubsweet/models')
+
+    return {
+      channel: {
+        relation: BaseModel.BelongsToOneRelation,
+        modelClass: Channel,
+        join: {
+          from: 'manuscripts.channelId',
+          to: 'channels.id',
+        },
+      },
+    }
+  }
+
   static get schema() {
     return {
       properties: {
@@ -248,6 +263,8 @@ class Manuscript extends BaseModel {
             keywords: { type: ['string', 'null'] },
           },
         },
+        // TODO
+        channelId: { type: ['string', 'null'], format: 'uuid' },
         submission: {},
       },
     }
diff --git a/server/model-manuscript/src/migrations/1591879913-add-channel.sql b/server/model-manuscript/src/migrations/1591879913-add-channel.sql
new file mode 100644
index 0000000000000000000000000000000000000000..064bfee117da818c30a0a4c5e91cc601ade7168a
--- /dev/null
+++ b/server/model-manuscript/src/migrations/1591879913-add-channel.sql
@@ -0,0 +1 @@
+ALTER TABLE manuscripts ADD COLUMN channel_id UUID;
\ No newline at end of file
diff --git a/server/model-manuscript/src/typeDefs.js b/server/model-manuscript/src/typeDefs.js
deleted file mode 100644
index 878abbd5965b741d554fd229083a833d5b96b1a6..0000000000000000000000000000000000000000
--- a/server/model-manuscript/src/typeDefs.js
+++ /dev/null
@@ -1,145 +0,0 @@
-const typeDefs = `
-  extend type Query {
-    globalTeams: [Team]
-    manuscript(id: ID!): Manuscript!
-    manuscripts: [Manuscript]!
-  }
-
-  extend type Mutation {
-    createManuscript(input: ManuscriptInput): Manuscript!
-    updateManuscript(id: ID!, input: String): Manuscript!
-    submitManuscript(id: ID!, input: String): Manuscript!
-    deleteManuscript(id: ID!): ID!
-    reviewerResponse(currentUserId: ID, action: String, teamId: ID! ): Team
-    assignTeamEditor(id: ID!, input: String): [Team]
-  }
-
-  type Manuscript implements Object {
-    id: ID!
-    created: DateTime!
-    updated: DateTime
-    manuscriptVersions: [ManuscriptVersion]
-    files: [File]
-    teams: [Team]
-    reviews: [Review!]
-    status: String
-    decision: String
-    suggestions: Suggestions
-    authors: [Author]
-    meta: ManuscriptMeta
-    submission: String
-  }
-
-  type ManuscriptVersion implements Object {
-    id: ID!
-    created: DateTime!
-    updated: DateTime
-    files: [File]
-    teams: [Team]
-    reviews: [Review]
-    status: String
-    formState: String
-    decision: String
-    suggestions: Suggestions
-    authors: [Author]
-    meta: ManuscriptMeta
-    submission: String
-  }
-
-  input ManuscriptInput {
-    files: [FileInput]
-    meta: ManuscriptMetaInput
-    submission: String
-  }
-
-  input ManuscriptMetaInput {
-    title: String
-    source: String
-  }
-
-  input FileInput {
-    filename: String
-    url: String
-    mimeType: String
-    size: Int
-  }
-
-  type Author {
-    firstName: String
-    lastName: String
-    email: String
-    affiliation: String
-  }
-
-  type Suggestion {
-    suggested: String
-    opposed: String
-  }
-
-  type Suggestions {
-    reviewers: Suggestion
-    editors: Suggestion
-  }
-
-  type ManuscriptMeta {
-    title: String!
-    source: String
-    articleType: String
-    declarations: Declarations
-    articleSections: [String]
-    articleIds: [ArticleId]
-    abstract: String
-    subjects: [String]
-    history: [MetaDate]
-    publicationDates: [MetaDate]
-    notes: [Note]
-    keywords: String
-  }
-
-  type ArticleId {
-    pubIdType: String
-    id: String
-  }
-
-  type MetaDate {
-    type: String
-    date: DateTime
-  }
-
-  type Declarations {
-    openData: String
-    openPeerReview: String
-    preregistered: String
-    previouslySubmitted: String
-    researchNexus: String
-    streamlinedReview: String
-  }
-
-  type Note implements Object {
-    id: ID!
-    created: DateTime!
-    updated: DateTime
-    notesType: String
-    content: String
-  }
-
-  # type reviewerStatus {
-  #   user: ID!
-  #   status: String
-  # }
-
-  # input reviewerStatusUpdate {
-  #   user: ID!
-  #   status: String
-  # }
-
-  # extend type Team {
-  #   status: [reviewerStatus]
-  # }
-
-  # extend input TeamInput {
-  #   status: [reviewerStatusUpdate]
-  # }
-`
-
-module.exports = typeDefs
diff --git a/server/model-messages/package.json b/server/model-message/package.json
similarity index 100%
rename from server/model-messages/package.json
rename to server/model-message/package.json
diff --git a/server/model-messages/src/graphql/index.js b/server/model-message/src/graphql/index.js
similarity index 100%
rename from server/model-messages/src/graphql/index.js
rename to server/model-message/src/graphql/index.js
diff --git a/server/model-messages/src/index.js b/server/model-message/src/index.js
similarity index 100%
rename from server/model-messages/src/index.js
rename to server/model-message/src/index.js
diff --git a/server/model-messages/src/message.js b/server/model-message/src/message.js
similarity index 100%
rename from server/model-messages/src/message.js
rename to server/model-message/src/message.js
diff --git a/server/model-messages/src/migrations/1585344885-add-messages.sql b/server/model-message/src/migrations/1585344885-add-messages.sql
similarity index 94%
rename from server/model-messages/src/migrations/1585344885-add-messages.sql
rename to server/model-message/src/migrations/1585344885-add-messages.sql
index 81dc39f6e49903280bba407330adabea541b94d1..caad7a3ba769d1e8cf3f9988b9e675b75923317a 100644
--- a/server/model-messages/src/migrations/1585344885-add-messages.sql
+++ b/server/model-message/src/migrations/1585344885-add-messages.sql
@@ -4,5 +4,5 @@ CREATE TABLE messages (
   channel_id uuid NOT NULL REFERENCES channels(id),
   created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT current_timestamp,
   updated TIMESTAMP WITH TIME ZONE,
-  content TEXT,
+  content TEXT
 );
diff --git a/server/model-user/.npmignore b/server/model-user/.npmignore
new file mode 100644
index 0000000000000000000000000000000000000000..05ec4f7ce2931532ed329c2f904ce7c55b856e32
--- /dev/null
+++ b/server/model-user/.npmignore
@@ -0,0 +1,2 @@
+test/
+config/test.js
diff --git a/server/model-user/CHANGELOG.md b/server/model-user/CHANGELOG.md
new file mode 100644
index 0000000000000000000000000000000000000000..cd7329b1383caafdd13f2587a0ee01bd81f502df
--- /dev/null
+++ b/server/model-user/CHANGELOG.md
@@ -0,0 +1,354 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+
+## [5.1.12](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.11...@pubsweet/model-user@5.1.12) (2020-03-16)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.11](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.10...@pubsweet/model-user@5.1.11) (2020-03-04)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.10](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.9...@pubsweet/model-user@5.1.10) (2020-02-28)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.9](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.8...@pubsweet/model-user@5.1.9) (2020-02-26)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.8](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.7...@pubsweet/model-user@5.1.8) (2020-01-29)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.7](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.6...@pubsweet/model-user@5.1.7) (2020-01-23)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.6](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.5...@pubsweet/model-user@5.1.6) (2019-12-11)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.5](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.4...@pubsweet/model-user@5.1.5) (2019-11-11)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.4](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.3...@pubsweet/model-user@5.1.4) (2019-09-11)
+
+
+### Bug Fixes
+
+* **models:** do not use hardcoded paths in relation mappings ([0cd9e3c](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/0cd9e3c))
+
+
+
+
+
+## [5.1.3](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.2...@pubsweet/model-user@5.1.3) (2019-09-04)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.2](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.1...@pubsweet/model-user@5.1.2) (2019-08-30)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.1.1](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.1.0...@pubsweet/model-user@5.1.1) (2019-08-08)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+# [5.1.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.0.5...@pubsweet/model-user@5.1.0) (2019-08-05)
+
+
+### Features
+
+* **loaders:** add dataloaders to context by default ([c4c2255](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/c4c2255))
+
+
+
+
+
+## [5.0.5](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.0.4...@pubsweet/model-user@5.0.5) (2019-07-12)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.0.4](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.0.3...@pubsweet/model-user@5.0.4) (2019-07-09)
+
+
+### Bug Fixes
+
+* **model-user:** make sure teams are returned with current user ([f1049d2](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/f1049d2))
+
+
+
+
+
+## [5.0.3](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.0.2...@pubsweet/model-user@5.0.3) (2019-07-03)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [5.0.2](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.0.1...@pubsweet/model-user@5.0.2) (2019-06-28)
+
+
+### Bug Fixes
+
+* **model-user:** make user.teams nullable ([e3fe2da](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/e3fe2da))
+
+
+
+
+
+## [5.0.1](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@5.0.0...@pubsweet/model-user@5.0.1) (2019-06-24)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+# [5.0.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.10...@pubsweet/model-user@5.0.0) (2019-06-21)
+
+
+### Features
+
+* **model-user:** move unique constraints verification into db ([38a941b](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/38a941b))
+
+
+### BREAKING CHANGES
+
+* **model-user:** Moves unique constraints from save()/isUniq() to database-native checks.
+
+
+
+
+
+## [4.0.10](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.9...@pubsweet/model-user@4.0.10) (2019-06-13)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.9](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.8...@pubsweet/model-user@4.0.9) (2019-06-12)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.8](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.7...@pubsweet/model-user@4.0.8) (2019-05-27)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.7](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.6...@pubsweet/model-user@4.0.7) (2019-04-25)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.6](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.5...@pubsweet/model-user@4.0.6) (2019-04-18)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.5](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.4...@pubsweet/model-user@4.0.5) (2019-04-09)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.4](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.3...@pubsweet/model-user@4.0.4) (2019-03-06)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.3](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.2...@pubsweet/model-user@4.0.3) (2019-03-05)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.2](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.1...@pubsweet/model-user@4.0.2) (2019-02-19)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [4.0.1](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@4.0.0...@pubsweet/model-user@4.0.1) (2019-02-19)
+
+
+### Bug Fixes
+
+* **model-user:** fix update user mutation's password hashing ([5c50fda](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/5c50fda))
+
+
+
+
+
+# [4.0.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@3.0.2...@pubsweet/model-user@4.0.0) (2019-02-01)
+
+
+### Bug Fixes
+
+* **model-user:** use correct team member reference ([9dfee12](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/9dfee12))
+
+
+### Features
+
+* add team relationship to user and test it ([a10e81c](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/a10e81c))
+* remove REST endpoints ([585881b](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/585881b))
+* **graphql:** add where option to connector calls where needed ([9ff779b](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/9ff779b))
+* **model-user:** improve eager loading in graphql ([2ae9640](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/2ae9640))
+
+
+### BREAKING CHANGES
+
+* This removes all previous /api endpoints, with the exception of file upload.
+
+
+
+
+
+## [3.0.2](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@3.0.1...@pubsweet/model-user@3.0.2) (2019-01-16)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+## [3.0.1](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@3.0.0...@pubsweet/model-user@3.0.1) (2019-01-14)
+
+**Note:** Version bump only for package @pubsweet/model-user
+
+
+
+
+
+# [3.0.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@2.0.0...@pubsweet/model-user@3.0.0) (2019-01-13)
+
+
+### Features
+
+* add [@pubsweet](https://gitlab.coko.foundation/pubsweet)/errors ([2969bf6](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/2969bf6))
+
+
+### BREAKING CHANGES
+
+* If you required errors deeply from pubsweet-server before, i.e.
+`pubsweet-server/src/errors`, this will no longer work, and you need to change your require to
+`@pubsweet/errors`.
+
+
+
+
+
+# [2.0.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/@pubsweet/model-user@1.0.1-alpha.0...@pubsweet/model-user@2.0.0) (2019-01-09)
+
+
+### Bug Fixes
+
+* **model-user:** change passwordResetTimestamp schema ([e0aafff](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/e0aafff))
+* **model-user:** passwordResetTimestamp can be null ([abfc095](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/abfc095))
+* **server:** additionally protect /api/users ([78ae476](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/78ae476))
+
+
+### Features
+
+* **base-model:** remove proxy for setting model properties ([e9ad1fa](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/e9ad1fa))
+* introduce [@pubsweet](https://gitlab.coko.foundation/pubsweet)/models package ([7c1a364](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/7c1a364))
+
+
+### BREAKING CHANGES
+
+* **server:** This adds additional authorization checks for the new user creation REST endpoint.
+Your authsome modes have to be updated.
+
+
+
+
+
+## 1.0.1-alpha.0 (2018-11-23)
+
+
+### Bug Fixes
+
+* **model-user:** omit passwordHash from JSON representation ([c33fbee](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/c33fbee))
+
+
+### Features
+
+* add standalone user model ([240beca](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/240beca))
diff --git a/server/model-user/README.md b/server/model-user/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a0ca6aae17290f397f69a6eb2f990e1420158e1a
--- /dev/null
+++ b/server/model-user/README.md
@@ -0,0 +1,27 @@
+Consists of two parts, the [User and the Identity model](https://gitlab.coko.foundation/pubsweet/pubsweet/tree/master/components/server/model-user/src). The User model contains a very basic set of user-related features (email, username, password hash, password reset token), and is complemented by the Identity model, which contains information about local (e.g. secondary email) or external identities (e.g. ORCID OAuth information).
+
+To use the User model, you have to [include it in the component list](/#/Components?id=section-how-do-you-use-components), and then require it in your code:
+
+```js static
+const { User, Identity } = require('@pubsweet/models')
+```
+
+You can then use the model as any other PubSweet model, e.g.
+
+```js static
+const user = {
+  username: input.username,
+  email: input.email,
+  password: input.password,
+}
+
+const identity = {
+  type: 'local',
+  aff: input.aff,
+  name: input.name,
+  isDefault: true,
+}
+user.defaultIdentity = identity
+
+const savedUser = await new User(user).saveGraph()
+```
diff --git a/server/model-user/config/test.js b/server/model-user/config/test.js
new file mode 100644
index 0000000000000000000000000000000000000000..e25f03f6b3ccc2dac4ab1b70a9308e57e260c932
--- /dev/null
+++ b/server/model-user/config/test.js
@@ -0,0 +1,25 @@
+const path = require('path')
+
+module.exports = {
+  'pubsweet-server': {
+    db: {
+      // temporary database name set by jest-environment-db
+      database: global.__testDbName || 'test',
+    },
+    pool: { min: 0, max: 10, idleTimeoutMillis: 1000 },
+    port: 4000,
+    secret: 'test',
+    uploads: 'uploads',
+  },
+  authsome: {
+    mode: path.resolve(__dirname, '..', 'test', 'helpers', 'authsome_mode'),
+    teams: {
+      teamTest: {
+        name: 'Contributors',
+      },
+    },
+  },
+  pubsweet: {
+    components: ['@pubsweet/model-user', '@pubsweet/model-team'],
+  },
+}
diff --git a/server/model-user/package.json b/server/model-user/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..f7f13d9dffe9ebca6b93691113d9351d66d64c48
--- /dev/null
+++ b/server/model-user/package.json
@@ -0,0 +1,27 @@
+{
+  "name": "@pubsweet/model-user-time0",
+  "version": "5.1.12",
+  "description": "A basic User model",
+  "main": "src/index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "author": "Adam Hyde",
+  "license": "MIT",
+  "dependencies": {
+    "@pubsweet/base-model": "^3.6.2",
+    "@pubsweet/errors": "^2.0.32",
+    "@pubsweet/logger": "^0.2.42",
+    "@pubsweet/models": "^0.3.7",
+    "bcrypt": "^3.0.6"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "peerDependencies": {
+    "config": ">=3.0.1",
+    "lodash": ">=4.17.11",
+    "pubsweet-server": ">=11.0.0"
+  },
+  "gitHead": "6b100b76f21785e5e50fca082a2743d3d0b1c88a"
+}
diff --git a/server/model-user/src/graphql.js b/server/model-user/src/graphql.js
new file mode 100644
index 0000000000000000000000000000000000000000..07d87ba934ffc1dc29ee70217ce5ca6b4811859b
--- /dev/null
+++ b/server/model-user/src/graphql.js
@@ -0,0 +1,221 @@
+const logger = require('@pubsweet/logger')
+const { AuthorizationError, ConflictError } = require('@pubsweet/errors')
+
+const eager = undefined
+
+const resolvers = {
+  Query: {
+    user(_, { id }, ctx) {
+      return ctx.connectors.User.fetchOne(id, ctx, { eager })
+    },
+    users(_, { where }, ctx) {
+      return ctx.connectors.User.fetchAll(where, ctx, { eager })
+    },
+    // Authentication
+    currentUser(_, vars, ctx) {
+      if (!ctx.user) return null
+      return ctx.connectors.User.model.find(ctx.user, { eager })
+    },
+    searchUsers(_, { teamId, query }, ctx) {
+      if (teamId) {
+        return ctx.connectors.User.model
+          .query()
+          .where({ teamId })
+          .where('username', 'ilike', `${query}%`)
+      }
+      return ctx.connectors.User.model
+        .query()
+        .where('username', 'ilike', `${query}%`)
+    },
+  },
+  Mutation: {
+    async createUser(_, { input }, ctx) {
+      const user = {
+        username: input.username,
+        email: input.email,
+        passwordHash: await ctx.connectors.User.model.hashPassword(
+          input.password,
+        ),
+      }
+
+      const identity = {
+        type: 'local',
+        aff: input.aff,
+        name: input.name,
+        isDefault: true,
+      }
+      user.defaultIdentity = identity
+
+      try {
+        const result = await ctx.connectors.User.create(user, ctx, {
+          eager: 'defaultIdentity',
+        })
+
+        return result
+      } catch (e) {
+        if (e.constraint) {
+          throw new ConflictError(
+            'User with this username or email already exists',
+          )
+        } else {
+          throw e
+        }
+      }
+    },
+    deleteUser(_, { id }, ctx) {
+      return ctx.connectors.User.delete(id, ctx)
+    },
+    async updateUser(_, { id, input }, ctx) {
+      if (input.password) {
+        input.passwordHash = await ctx.connectors.User.model.hashPassword(
+          input.password,
+        )
+        delete input.password
+      }
+
+      return ctx.connectors.User.update(id, input, ctx)
+    },
+    // Authentication
+    async loginUser(_, { input }, ctx) {
+      const authentication = require('pubsweet-server/src/authentication')
+
+      let isValid = false
+      let user
+      try {
+        user = await ctx.connectors.User.model.findByUsername(input.username)
+        isValid = await user.validPassword(input.password)
+      } catch (err) {
+        logger.debug(err)
+      }
+      if (!isValid) {
+        throw new AuthorizationError('Wrong username or password.')
+      }
+      return {
+        user,
+        token: authentication.token.create(user),
+      }
+    },
+    async updateCurrentUsername(_, { username }, ctx) {
+      const user = await ctx.connectors.User.model.find(ctx.user)
+      user.username = username
+      await user.save()
+      return user
+    },
+  },
+  User: {
+    async defaultIdentity(parent, args, ctx) {
+      const identity = await ctx.connectors.Identity.model
+        .query()
+        .where({ userId: parent.id, isDefault: true })
+        .first()
+      return identity
+    },
+    async identities(parent, args, ctx) {
+      const identities = await ctx.connectors.Identity.model
+        .query()
+        .where({ userId: parent.id })
+      return identities
+    },
+  },
+  LocalIdentity: {
+    __isTypeOf: (obj, context, info) => obj.type === 'local',
+    async email(obj, args, ctx, info) {
+      // Emails stored on user, but surfaced in local identity too
+      return (await ctx.loaders.User.load(obj.userId)).email
+    },
+  },
+  ExternalIdentity: {
+    __isTypeOf: (obj, context, info) => obj.type !== 'local',
+  },
+}
+
+const typeDefs = `
+  extend type Query {
+    user(id: ID, username: String): User
+    users: [User]
+    searchUsers(teamId: ID, query: String): [User]
+  }
+
+  extend type Mutation {
+    createUser(input: UserInput): User
+    deleteUser(id: ID): User
+    updateUser(id: ID, input: UserInput): User
+    updateCurrentUsername(username: String): User
+  }
+
+  type User {
+    id: ID!
+    created: DateTime!
+    updated: DateTime
+    username: String
+    email: String
+    admin: Boolean
+    identities: [Identity]
+    defaultIdentity: Identity
+    profilePicture: String
+    online: Boolean
+  }
+
+  type Name {
+    surname: String
+    givenNames: String
+    title: String
+  }
+
+  interface Identity {
+    name: Name
+    aff: String # JATS <aff>
+    email: String # JATS <aff>
+    type: String
+  }
+
+  # union Identity = Local | External
+
+  # local identity (not from ORCID, etc.)
+  type LocalIdentity implements Identity {
+    name: Name
+    email: String
+    aff: String
+    type: String
+  }
+
+  type ExternalIdentity implements Identity {
+    name: Name
+    identifier: String
+    email: String
+    aff: String
+    type: String
+  }
+
+  input UserInput {
+    username: String!
+    email: String!
+    password: String
+    rev: String
+  }
+
+  # Authentication
+
+  extend type Query {
+    # Get the currently authenticated user based on the JWT in the HTTP headers
+    currentUser: User
+  }
+
+  extend type Mutation {
+    # Authenticate a user using username and password
+    loginUser(input: LoginUserInput): LoginResult
+  }
+
+  # User details and bearer token
+  type LoginResult {
+    user: User!
+    token: String!
+  }
+
+  input LoginUserInput {
+    username: String!
+    password: String!
+  }
+`
+
+module.exports = { resolvers, typeDefs }
diff --git a/server/model-user/src/identity.js b/server/model-user/src/identity.js
new file mode 100644
index 0000000000000000000000000000000000000000..3fda068da74612f541cc71e9b5e686ad26aca0b5
--- /dev/null
+++ b/server/model-user/src/identity.js
@@ -0,0 +1,42 @@
+const BaseModel = require('@pubsweet/base-model')
+
+class Identity extends BaseModel {
+  static get tableName() {
+    return 'identities'
+  }
+
+  static get schema() {
+    return {
+      properties: {
+        type: { type: 'string' },
+        isDefault: { type: ['boolean', 'null'] },
+        aff: { type: ['string', 'null'] },
+        name: { type: ['string', 'null'] },
+        identifier: { type: ['string', 'null'] },
+        userId: { type: 'string', format: 'uuid' },
+        oauth: {
+          type: 'object',
+          properties: {
+            accessToken: { type: ['string'] },
+            refreshToken: { type: ['string'] },
+          },
+        },
+      },
+    }
+  }
+
+  static get relationMappings() {
+    return {
+      user: {
+        relation: BaseModel.BelongsToOneRelation,
+        modelClass: require('./user'),
+        join: {
+          from: 'identities.userId',
+          to: 'users.id',
+        },
+      },
+    }
+  }
+}
+
+module.exports = Identity
diff --git a/server/model-user/src/index.js b/server/model-user/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..758e1b1d186e8304d072f5a123ecb92b139eb894
--- /dev/null
+++ b/server/model-user/src/index.js
@@ -0,0 +1,8 @@
+module.exports = {
+  ...require('./graphql'),
+  modelName: 'User',
+  models: [
+    { modelName: 'User', model: require('./user') },
+    { modelName: 'Identity', model: require('./identity') },
+  ],
+}
diff --git a/server/model-user/src/migrations/1542276313-initial-user-migration.sql b/server/model-user/src/migrations/1542276313-initial-user-migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..7415b09166e43bb60b99cc8d654be47d4f10a43a
--- /dev/null
+++ b/server/model-user/src/migrations/1542276313-initial-user-migration.sql
@@ -0,0 +1,15 @@
+CREATE TABLE users (
+  id UUID PRIMARY KEY,
+  created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT current_timestamp,
+  updated TIMESTAMP WITH TIME ZONE,
+  admin BOOLEAN,
+  email TEXT,
+  username TEXT,
+  password_hash TEXT,
+  fragments JSONB,
+  collections JSONB,
+  teams JSONB,
+  password_reset_token TEXT,
+  password_reset_timestamp TIMESTAMP WITH TIME ZONE,
+  type TEXT NOT NULL
+);
\ No newline at end of file
diff --git a/server/model-user/src/migrations/1560771823-add-unique-constraints-to-users.sql b/server/model-user/src/migrations/1560771823-add-unique-constraints-to-users.sql
new file mode 100644
index 0000000000000000000000000000000000000000..af212389d177343f94666cdf47417c2a947c3839
--- /dev/null
+++ b/server/model-user/src/migrations/1560771823-add-unique-constraints-to-users.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users ADD UNIQUE (username);
+ALTER TABLE users ADD UNIQUE (email);
\ No newline at end of file
diff --git a/server/model-user/src/migrations/1580908536-add-identities.sql b/server/model-user/src/migrations/1580908536-add-identities.sql
new file mode 100644
index 0000000000000000000000000000000000000000..288cf0c38d845e553e7013850fe19574aa4e6da0
--- /dev/null
+++ b/server/model-user/src/migrations/1580908536-add-identities.sql
@@ -0,0 +1,17 @@
+CREATE TABLE identities (
+  id UUID PRIMARY KEY,
+  user_id uuid NOT NULL REFERENCES users,
+  created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT current_timestamp,
+  updated TIMESTAMP WITH TIME ZONE,
+  type TEXT NOT NULL, -- local, orcid
+  identifier TEXT, -- e.g. orcid ID
+  name TEXT,
+  aff TEXT,
+  oauth JSONB,
+  is_default BOOLEAN
+);
+
+CREATE UNIQUE INDEX is_default_idx ON identities (is_default, user_id) WHERE is_default IS true;
+
+
+
diff --git a/server/model-user/src/migrations/1581371297-migrate-users-to-identities.js b/server/model-user/src/migrations/1581371297-migrate-users-to-identities.js
new file mode 100644
index 0000000000000000000000000000000000000000..885ef57eeece0f2b5811c29a36c923e5b2df7dcc
--- /dev/null
+++ b/server/model-user/src/migrations/1581371297-migrate-users-to-identities.js
@@ -0,0 +1,24 @@
+/* eslint-disable no-restricted-syntax */
+/* eslint-disable no-await-in-loop */
+// eslint-disable-next-line import/no-extraneous-dependencies
+const logger = require('@pubsweet/logger')
+
+exports.up = async knex => {
+  const { User, Identity } = require('@pubsweet/models')
+  const users = await User.query().eager('defaultIdentity')
+
+  for (const user of users) {
+    // To make the migration idempotent
+    if (!user.defaultIdentity) {
+      try {
+        await new Identity({
+          type: 'local',
+          userId: user.id,
+          isDefault: true,
+        }).save()
+      } catch (e) {
+        logger.error(e)
+      }
+    }
+  }
+}
diff --git a/server/model-user/src/migrations/1582930582-drop-fragments-and-collections.js b/server/model-user/src/migrations/1582930582-drop-fragments-and-collections.js
new file mode 100644
index 0000000000000000000000000000000000000000..54a8afe60503c820f86ffbdf2b1948354e29df2f
--- /dev/null
+++ b/server/model-user/src/migrations/1582930582-drop-fragments-and-collections.js
@@ -0,0 +1,5 @@
+exports.up = async knex =>
+  knex.schema.table('users', table => {
+    table.dropColumn('fragments')
+    table.dropColumn('collections')
+  })
diff --git a/server/model-user/src/migrations/1585513226-add-profile-pic.sql b/server/model-user/src/migrations/1585513226-add-profile-pic.sql
new file mode 100644
index 0000000000000000000000000000000000000000..9d1c420160f08d4c6ce3ca40ab8d5002d5e29da5
--- /dev/null
+++ b/server/model-user/src/migrations/1585513226-add-profile-pic.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users
+ADD COLUMN profile_picture TEXT,
+ADD COLUMN online BOOLEAN;
\ No newline at end of file
diff --git a/server/model-user/src/user.js b/server/model-user/src/user.js
new file mode 100644
index 0000000000000000000000000000000000000000..468f9d470f6af4a510a59ec609a7cc467bc97136
--- /dev/null
+++ b/server/model-user/src/user.js
@@ -0,0 +1,147 @@
+const BaseModel = require('@pubsweet/base-model')
+const bcrypt = require('bcrypt')
+const pick = require('lodash/pick')
+const config = require('config')
+
+const BCRYPT_COST = config.util.getEnv('NODE_ENV') === 'test' ? 1 : 12
+
+class User extends BaseModel {
+  constructor(properties) {
+    super(properties)
+    this.type = 'user'
+  }
+
+  $formatJson(json) {
+    json = super.$formatJson(json)
+    delete json.passwordHash
+    return json
+  }
+
+  static get tableName() {
+    return 'users'
+  }
+
+  static get relationMappings() {
+    const { Team, TeamMember, Identity } = require('@pubsweet/models')
+
+    return {
+      identities: {
+        relation: BaseModel.HasManyRelation,
+        modelClass: Identity,
+        join: {
+          from: 'users.id',
+          to: 'identities.userId',
+        },
+      },
+      defaultIdentity: {
+        relation: BaseModel.HasOneRelation,
+        modelClass: Identity,
+        join: {
+          from: 'users.id',
+          to: 'identities.userId',
+        },
+        filter: builder => {
+          builder.where('isDefault', true)
+        },
+      },
+      teams: {
+        relation: BaseModel.ManyToManyRelation,
+        modelClass: Team,
+        join: {
+          from: 'users.id',
+          through: {
+            modelClass: TeamMember,
+            from: 'team_members.userId',
+            to: 'team_members.teamId',
+          },
+          to: 'teams.id',
+        },
+      },
+    }
+  }
+
+  static get schema() {
+    return {
+      properties: {
+        admin: { type: ['boolean', 'null'] },
+        email: { type: ['string', 'null'], format: 'email' },
+        username: { type: 'string', pattern: '^[a-zA-Z0-9]+' },
+        passwordHash: { type: ['string', 'null'] },
+        online: { type: ['boolean', 'null'] },
+        passwordResetToken: { type: ['string', 'null'] },
+        passwordResetTimestamp: {
+          type: ['string', 'object', 'null'],
+          format: 'date-time',
+        },
+        profilePicture: { type: ['string', 'null'] },
+      },
+    }
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  setOwners() {
+    // FIXME: this is overriden to be a no-op, because setOwners() is called by
+    // the API on create for all entity types and setting `owners` on a User is
+    // not allowed. This should instead be solved by having separate code paths
+    // in the API for different entity types.
+  }
+
+  async save() {
+    if (this.password) {
+      this.passwordHash = await User.hashPassword(this.password)
+      delete this.password
+    }
+
+    return super.save()
+  }
+
+  async validPassword(password) {
+    return password && this.passwordHash
+      ? bcrypt.compare(password, this.passwordHash)
+      : false
+  }
+
+  static hashPassword(password) {
+    return bcrypt.hash(password, BCRYPT_COST)
+  }
+
+  static findByEmail(email) {
+    return this.findByField('email', email).then(users => users[0])
+  }
+
+  static findByUsername(username) {
+    return this.findByField('username', username).then(users => users[0])
+  }
+
+  static async findOneWithIdentity(userId, identityType) {
+    const { Identity } = require('@pubsweet/models')
+    const user = (
+      await this.query()
+        .alias('u')
+        .leftJoin(
+          Identity.query()
+            .where('type', identityType)
+            .as('i'),
+          'u.id',
+          'i.userId',
+        )
+        .where('u.id', userId)
+    )[0]
+
+    return user
+  }
+
+  // For API display/JSON purposes only
+  static ownersWithUsername(object) {
+    return Promise.all(
+      object.owners.map(async ownerId => {
+        const owner = await this.find(ownerId)
+        return pick(owner, ['id', 'username'])
+      }),
+    )
+  }
+}
+
+User.type = 'user'
+
+module.exports = User
diff --git a/server/model-user/test/1581371297-migrate-users-to-identities_test.js b/server/model-user/test/1581371297-migrate-users-to-identities_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..9bf1285525962241664fd7fc94ffa6e7e3796d69
--- /dev/null
+++ b/server/model-user/test/1581371297-migrate-users-to-identities_test.js
@@ -0,0 +1,45 @@
+process.env.NODE_CONFIG = `{"pubsweet":{
+  "components":[
+    "@pubsweet/model-user",
+  ]
+}}`
+
+const { User } = require('@pubsweet/models')
+const { dbCleaner } = require('pubsweet-server/test')
+const migrate = require('@pubsweet/db-manager/src/commands/migrate')
+
+describe('Users to Identities migration', () => {
+  it('has successfuly created new default identities', async () => {
+    await dbCleaner({ to: '1580908536-add-identities.sql' })
+
+    const user1 = await new User({
+      email: 'some1@example.com',
+      username: 'user1',
+      password: 'test1',
+    }).save()
+
+    const user2 = await new User({
+      email: 'some2@example.com',
+      username: 'user2',
+      password: 'test2',
+    }).save()
+
+    // Do the migration
+    await migrate({ to: '1581371297-migrate-users-to-identities.js' })
+
+    const user1after = await User.find(user1.id, {
+      eager: '[identities,defaultIdentity]',
+    })
+    const user2after = await User.find(user2.id, {
+      eager: '[identities,defaultIdentity]',
+    })
+
+    expect(user1after.defaultIdentity).toBeTruthy()
+    expect(user1after.identities).toHaveLength(1)
+    expect(await user1after.validPassword('test1')).toBe(true)
+
+    expect(user2after.defaultIdentity).toBeTruthy()
+    expect(user2after.identities).toHaveLength(1)
+    expect(await user2after.validPassword('test2')).toBe(true)
+  })
+})
diff --git a/server/model-user/test/fixtures.js b/server/model-user/test/fixtures.js
new file mode 100644
index 0000000000000000000000000000000000000000..b6cc1725827d2e2c04251ddff088931055c12717
--- /dev/null
+++ b/server/model-user/test/fixtures.js
@@ -0,0 +1,36 @@
+module.exports = {
+  user: {
+    type: 'user',
+    username: 'testuser',
+    email: 'test@example.com',
+    password: 'test',
+  },
+
+  updatedUser: {
+    username: 'changeduser',
+    email: 'changed@example.com',
+    password: 'changed',
+  },
+
+  otherUser: {
+    type: 'user',
+    username: 'anotheruser',
+    email: 'another@example.com',
+    password: 'rubgy',
+  },
+
+  localIdentity: {
+    name: 'Someone',
+    aff: 'University of PubSweet',
+    type: 'local',
+  },
+
+  externalIdentity: {
+    type: 'external',
+    identifier: 'orcid',
+    oauth: {
+      accessToken: 'someAccessToken',
+      refreshToken: 'someRefreshToken',
+    },
+  },
+}
diff --git a/server/model-user/test/helpers/authsome_mode.js b/server/model-user/test/helpers/authsome_mode.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f156436b8ab761c0fc37453b3661b238cda1d5b
--- /dev/null
+++ b/server/model-user/test/helpers/authsome_mode.js
@@ -0,0 +1 @@
+module.exports = async (userId, operation, object, context) => true
diff --git a/server/model-user/test/identity_test.js b/server/model-user/test/identity_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..18d6aaf004e64146a045b09e42f2d7e052124fb7
--- /dev/null
+++ b/server/model-user/test/identity_test.js
@@ -0,0 +1,80 @@
+const { dbCleaner } = require('pubsweet-server/test')
+const fixtures = require('./fixtures')
+const Identity = require('../src/identity')
+const User = require('../src/user')
+
+describe('Identity', () => {
+  beforeEach(async () => {
+    await dbCleaner()
+  })
+
+  it('can create a user with a default local identity', async () => {
+    const user = await new User(fixtures.user).save()
+    const defaultIdentity = await new Identity({
+      ...fixtures.localIdentity,
+      userId: user.id,
+      isDefault: true,
+    }).save()
+
+    const savedUser = await User.find(user.id, { eager: 'defaultIdentity' })
+    expect(savedUser.defaultIdentity).toEqual(defaultIdentity)
+  })
+
+  it('can create a user with a local and a default oauth identity', async () => {
+    let user = await new User(fixtures.user).save()
+
+    const localIdentity = await new Identity({
+      ...fixtures.localIdentity,
+      userId: user.id,
+    }).save()
+
+    const externalIdentity = await new Identity({
+      ...fixtures.externalIdentity,
+      userId: user.id,
+      isDefault: true,
+    }).save()
+
+    user = await User.find(user.id, { eager: '[identities, defaultIdentity]' })
+
+    expect(user.identities).toContainEqual(localIdentity)
+    expect(user.identities).toContainEqual(externalIdentity)
+    expect(user.defaultIdentity).toEqual(externalIdentity)
+  })
+
+  it('user can not have more than one default identities', async () => {
+    const user = await new User(fixtures.user).save()
+
+    await new Identity({
+      ...fixtures.localIdentity,
+      userId: user.id,
+      isDefault: true,
+    }).save()
+
+    const externalIdentity = new Identity({
+      ...fixtures.externalIdentity,
+      userId: user.id,
+      isDefault: true,
+    }).save()
+
+    await expect(externalIdentity).rejects.toThrow('violates unique constraint')
+  })
+
+  it('can have multiple non-default identities (isDefault = false)', async () => {
+    const user = await new User(fixtures.user).save()
+
+    await new Identity({
+      ...fixtures.localIdentity,
+      userId: user.id,
+      isDefault: false,
+    }).save()
+
+    await new Identity({
+      ...fixtures.externalIdentity,
+      userId: user.id,
+      isDefault: false,
+    }).save()
+
+    const foundUser = await User.find(user.id, { eager: 'identities' })
+    expect(foundUser.identities).toHaveLength(2)
+  })
+})
diff --git a/server/model-user/test/index.js b/server/model-user/test/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b471deb5bba58deb1ee350994a8aa45bd3f62bf9
--- /dev/null
+++ b/server/model-user/test/index.js
@@ -0,0 +1,3 @@
+module.exports = {
+  fixtures: require('./fixtures'),
+}
diff --git a/server/model-user/test/jest-setup.js b/server/model-user/test/jest-setup.js
new file mode 100644
index 0000000000000000000000000000000000000000..7de2beed55c538a4a39fe8495b07dde62f6db5f9
--- /dev/null
+++ b/server/model-user/test/jest-setup.js
@@ -0,0 +1,3 @@
+const path = require('path')
+
+process.env.NODE_CONFIG_DIR = path.resolve(__dirname, '..', 'config')
diff --git a/server/model-user/test/user_graphql_test.js b/server/model-user/test/user_graphql_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..63400ad898333315af513350c41a136e709d4c67
--- /dev/null
+++ b/server/model-user/test/user_graphql_test.js
@@ -0,0 +1,133 @@
+process.env.NODE_CONFIG = `{"pubsweet":{
+  "components":[
+    "@pubsweet/model-user",
+    "@pubsweet/model-team"
+  ]
+}}`
+
+const User = require('../src/user')
+const { dbCleaner, api } = require('pubsweet-server/test')
+
+const { fixtures } = require('@pubsweet/model-user/test')
+const authentication = require('pubsweet-server/src/authentication')
+
+describe('User mutations', () => {
+  beforeEach(async () => {
+    await dbCleaner()
+  })
+
+  it('a user can sign up', async () => {
+    const { body } = await api.graphql.query(
+      `mutation($input: UserInput) {
+        createUser(input: $input) {
+          username
+          defaultIdentity {
+            ... on Local {
+              email
+            }
+          }
+        }
+      }`,
+      {
+        input: {
+          username: 'hi',
+          email: 'hi@example.com',
+          password: 'hello',
+        },
+      },
+    )
+
+    expect(body).toEqual({
+      data: {
+        createUser: {
+          username: 'hi',
+          defaultIdentity: {
+            email: 'hi@example.com',
+          },
+        },
+      },
+    })
+  })
+
+  it('errors when duplicate username or emails are used', async () => {
+    await api.graphql.query(
+      `mutation($input: UserInput) {
+        createUser(input: $input) {
+          username
+        }
+      }`,
+      {
+        input: {
+          username: 'hi',
+          email: 'hi@example.com',
+          password: 'hello',
+        },
+      },
+    )
+
+    const { body: body2 } = await api.graphql.query(
+      `mutation($input: UserInput) {
+        createUser(input: $input) {
+          username
+        }
+      }`,
+      {
+        input: {
+          username: 'hi',
+          email: 'hi@example.com',
+          password: 'hello',
+        },
+      },
+    )
+
+    expect(body2).toEqual({
+      data: {
+        createUser: null,
+      },
+      errors: [
+        {
+          extensions: {
+            code: 'INTERNAL_SERVER_ERROR',
+          },
+          message: 'User with this username or email already exists',
+          name: 'ConflictError',
+        },
+      ],
+    })
+  })
+
+  it('a user can update a password', async () => {
+    const user = await new User(fixtures.user).save()
+    const token = authentication.token.create(user)
+
+    const { body } = await api.graphql.query(
+      `mutation($id: ID, $input: UserInput) {
+        updateUser(id: $id, input: $input) {
+          username
+        }
+      }`,
+      {
+        id: user.id,
+        input: {
+          username: 'hi',
+          email: 'hi@example.com',
+          password: 'hello2',
+        },
+      },
+      token,
+    )
+
+    expect(body).toEqual({
+      data: {
+        updateUser: {
+          username: 'hi',
+        },
+      },
+    })
+
+    const oldHash = user.passwordHash
+    const newHash = await User.find(user.id).passwordHash
+
+    expect(oldHash).not.toEqual(newHash)
+  })
+})
diff --git a/server/model-user/test/user_test.js b/server/model-user/test/user_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..d09e9fbd73a7cd324e126feda881adde9e3a78d6
--- /dev/null
+++ b/server/model-user/test/user_test.js
@@ -0,0 +1,136 @@
+const { dbCleaner } = require('pubsweet-server/test')
+const fixtures = require('./fixtures')
+const User = require('../src/user')
+
+describe('User', () => {
+  beforeEach(async () => {
+    await dbCleaner()
+  })
+
+  it('validates passwords correctly after saving to db', async () => {
+    const user = new User(fixtures.user)
+    await user.save()
+
+    const savedUser = await User.findByUsername(user.username)
+    expect(typeof savedUser).toBe('object')
+
+    const shouldBeValid = await savedUser.validPassword(fixtures.user.password)
+    expect(shouldBeValid).toEqual(true)
+
+    const shouldBeInvalid = await savedUser.validPassword('wrongpassword')
+    expect(shouldBeInvalid).toEqual(false)
+  })
+
+  it('raises an error if trying to save a user with a non-unique username', async () => {
+    const user = new User(fixtures.user)
+    const otherUserFixture = fixtures.otherUser
+    otherUserFixture.username = fixtures.user.username
+    const duplicateUser = new User(otherUserFixture)
+
+    await user.save()
+    await expect(duplicateUser.save()).rejects.toThrow(
+      'violates unique constraint',
+    )
+
+    expect.hasAssertions()
+  })
+
+  it('raises an error if trying to save a user with a non-unique email', async () => {
+    const user = new User(fixtures.user)
+    const otherUserFixture = fixtures.otherUser
+    otherUserFixture.email = fixtures.user.email
+    const duplicateUser = new User(otherUserFixture)
+
+    await user.save()
+    await expect(duplicateUser.save()).rejects.toThrow(
+      'violates unique constraint',
+    )
+
+    expect.hasAssertions()
+  })
+
+  it('uses custom JSON serialization', async () => {
+    const user = new User(fixtures.user)
+    await user.save()
+
+    const savedUser = await User.findByUsername(user.username)
+    expect(savedUser).toHaveProperty('username', user.username)
+    expect(savedUser).toHaveProperty('passwordHash')
+
+    const stringifiedUser = JSON.parse(JSON.stringify(savedUser))
+    expect(stringifiedUser).toHaveProperty('username', user.username)
+    expect(stringifiedUser).not.toHaveProperty('passwordHash')
+  })
+
+  it('uses custom JSON serialization in an array', async () => {
+    const users = [
+      { username: 'user1', email: 'user-1@example.com', password: 'foo1' },
+      { username: 'user2', email: 'user-2@example.com', password: 'foo2' },
+      { username: 'user3', email: 'user-3@example.com', password: 'foo3' },
+    ]
+
+    await Promise.all(users.map(user => new User(user).save()))
+
+    const savedUsers = await User.all()
+
+    const savedUser = savedUsers[2]
+    expect(savedUser).toHaveProperty('username')
+    expect(savedUser).toHaveProperty('passwordHash')
+
+    const stringifiedUsers = JSON.parse(JSON.stringify(savedUsers))
+    const stringifiedUser = stringifiedUsers[2]
+
+    expect(stringifiedUser).toHaveProperty('username', savedUser.username)
+    expect(stringifiedUser).not.toHaveProperty('passwordHash')
+  })
+
+  it('finds a list of users', async () => {
+    const users = [
+      { username: 'user1', email: 'user-1@example.com', password: 'foo1' },
+      { username: 'user2', email: 'user-2@example.com', password: 'foo2' },
+      { username: 'user3', email: 'user-3@example.com', password: 'foo3' },
+    ]
+
+    await Promise.all(users.map(user => new User(user).save()))
+
+    const items = await User.findByField('email', 'user-1@example.com')
+
+    expect(items).toHaveLength(1)
+    expect(items[0]).toBeInstanceOf(User)
+  })
+
+  it('finds a single user by field', async () => {
+    const users = [
+      { username: 'user1', email: 'user-1@example.com', password: 'foo1' },
+      { username: 'user2', email: 'user-2@example.com', password: 'foo2' },
+      { username: 'user3', email: 'user-3@example.com', password: 'foo3' },
+    ]
+
+    await Promise.all(users.map(user => new User(user).save()))
+
+    const item = await User.findOneByField('email', 'user-1@example.com')
+
+    expect(item).toBeInstanceOf(User)
+
+    expect(item).toEqual(
+      expect.objectContaining({
+        username: 'user1',
+        email: 'user-1@example.com',
+      }),
+    )
+  })
+
+  it('fails password verification if passwordHash is not present', async () => {
+    const fixtureWithoutPassword = Object.assign({}, fixtures.user)
+    delete fixtureWithoutPassword.password
+
+    const user = await new User(fixtureWithoutPassword).save()
+
+    const validPassword1 = await user.validPassword(undefined)
+    expect(validPassword1).toEqual(false)
+    const validPassword2 = await user.validPassword(null)
+    expect(validPassword2).toEqual(false)
+    const validPassword3 = await user.validPassword('somethingfunky')
+    expect(validPassword3).toEqual(false)
+  })
+})
diff --git a/server/profile-upload/endpoint.js b/server/profile-upload/endpoint.js
new file mode 100644
index 0000000000000000000000000000000000000000..b128f84af299c0a8cd293acf74d838a505b7382b
--- /dev/null
+++ b/server/profile-upload/endpoint.js
@@ -0,0 +1,57 @@
+const express = require('express')
+const crypto = require('crypto')
+const multer = require('multer')
+const passport = require('passport')
+const path = require('path')
+const config = require('config')
+const jimp = require('jimp')
+
+const authBearer = passport.authenticate('bearer', { session: false })
+
+const storage = multer.diskStorage({
+  destination: config.get('pubsweet-server').profiles,
+  filename: (req, file, cb) => {
+    crypto.randomBytes(16, (err, raw) => {
+      if (err) {
+        cb(err)
+        return
+      }
+
+      cb(null, raw.toString('hex') + path.extname(file.originalname))
+    })
+  },
+})
+
+const upload = multer({
+  storage,
+  limits: { fileSize: 10000000, files: 1 },
+})
+
+module.exports = app => {
+  const { User } = require('@pubsweet/models')
+  app.post(
+    '/api/uploadProfile',
+    authBearer,
+    upload.single('file'),
+    async (req, res, next) => {
+      const user = await User.find(req.user)
+
+      const image = await jimp.read(req.file.path)
+      await image.cover(200, 200)
+
+      const profilePath = `profiles/${user.username}${path.extname(
+        req.file.path,
+      )}`
+      await image.writeAsync(profilePath)
+
+      user.profilePicture = `/static/${profilePath}`
+      await user.save()
+      return res.send(user.profilePicture)
+    },
+  )
+
+  app.use(
+    '/static/profiles',
+    express.static(path.join(__dirname, '..', '..', 'profiles')),
+  )
+}
diff --git a/server/profile-upload/index.js b/server/profile-upload/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f3c17be2e3140c7db32bc0f074bc0aa18cd65b3c
--- /dev/null
+++ b/server/profile-upload/index.js
@@ -0,0 +1,3 @@
+module.exports = {
+  server: () => app => require('./endpoint')(app),
+}
diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js
index 91d735c4147461ad0bab0c586cd871f566dcfb1e..105b6d8abc91a8920016d61d53a1d971b5f6d07f 100644
--- a/webpack/webpack.config.js
+++ b/webpack/webpack.config.js
@@ -36,6 +36,7 @@ module.exports = webpackEnv => {
         '/api': 'http://localhost:3000',
         '/graphql': 'http://localhost:3000',
         '/uploads': 'http://locahost:3000',
+        '/static/profiles': 'http://localhost:3000',
       },
       historyApiFallback: true,
     },
diff --git a/yarn.lock b/yarn.lock
index ea67b023585dd21513042f13c395b62426bb5deb..0fc66af76929441c7d4ab50d4c455d98179b78f5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1001,6 +1001,13 @@
     core-js-pure "^3.0.0"
     regenerator-runtime "^0.13.4"
 
+"@babel/runtime@7.4.5":
+  version "7.4.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.5.tgz#582bb531f5f9dc67d2fcb682979894f75e253f12"
+  integrity sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==
+  dependencies:
+    regenerator-runtime "^0.13.2"
+
 "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
   version "7.10.2"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839"
@@ -1312,6 +1319,296 @@
   dependencies:
     "@hapi/hoek" "^8.3.0"
 
+"@jimp/bmp@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.13.0.tgz#cca2301e56231e7dc20d5aba757fb237b94e2013"
+  integrity sha512-7i/XZLoK5JETBKO0VL7qjnr6WDVl1X8mmaUk6Lzq06/veMPC5IwUIZi1JRVAXPEwTf5uUegq0WFnmUS0lVYzFw==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    bmp-js "^0.1.0"
+
+"@jimp/core@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.13.0.tgz#6ec104d69cd7bf32bd5748f5d6106133e2ee3205"
+  integrity sha512-BMFEUm5HbRP4yCo4Q23CJFx/v6Yr3trw7rERmS1GKUEooDq9ktApZWWTvWq/vggKyysKX0nQ+KT+FaFD/75Q+Q==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    any-base "^1.1.0"
+    buffer "^5.2.0"
+    exif-parser "^0.1.12"
+    file-type "^9.0.0"
+    load-bmfont "^1.3.1"
+    mkdirp "^0.5.1"
+    phin "^2.9.1"
+    pixelmatch "^4.0.2"
+    tinycolor2 "^1.4.1"
+
+"@jimp/custom@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.13.0.tgz#bc7b2568e73eea1e1c15260b7283d7d099630881"
+  integrity sha512-Zir/CHoLRhQDGfPWueCIQbVjVUlayNIUch9fulq4M9V2S+ynHx9BqRn58z8wy+mk8jm1WlpRVhvZ8QUenbL0vg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/core" "^0.13.0"
+
+"@jimp/gif@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.13.0.tgz#09f208f616d83acc8f8e1f1f6df784bafea46062"
+  integrity sha512-7FO2Fa9FZluqGt1MM/L8s6P5UEedxrIQT2eBAxzB8Z82YTTSWQXw4bdrZWCwiQjBFZwKTIaULIfw6+TxA/Q2XA==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    gifwrap "^0.9.2"
+    omggif "^1.0.9"
+
+"@jimp/jpeg@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.13.0.tgz#e7609a932cb0b795c422b6ad25882277a9db029d"
+  integrity sha512-Fol/DxA1lnIzCsNx/CckIEoyWImQHiWPgFAWL5s7VIVaJrEFnnbRqfOxmvr8yWg8mh3hWLeXNcxqA82CKXgg+Q==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    jpeg-js "^0.4.0"
+
+"@jimp/plugin-blit@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.13.0.tgz#31d8744a2fe79abe0ffd1af3f385953fb9e3b4d5"
+  integrity sha512-roCShFZosJgRMLCLzuOT1pRZysQF/p3avQieZiu9pfg2F9X09f91OauU2Lf3/yOp0TZCWbheqbem9MPlhyED8w==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-blur@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.13.0.tgz#35c57830391da331fc28f847742c2ea822d8641f"
+  integrity sha512-LeBhQe72bRk2fe2AftcqcDaWgSu6vFD0fxiAYYMy3pHa8pnPAwnw2W3u4bV/gc5XJt6AJzoRyc7WVG2pE4A3gg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-circle@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-circle/-/plugin-circle-0.13.0.tgz#2df8783e7e648ed1135d9197a3927543c0e340de"
+  integrity sha512-INwIl8zgWnJYXxYkNhIjG8TXg2Q1nh008SDKyC+Pug4ce/XRJC8w/Gk6HS+U9Z2tIO2/zXv473k/JaiwvDMu1w==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-color@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.13.0.tgz#6f5683bb1e02bc1576867b5b79b7736a1249faec"
+  integrity sha512-e71UDivZdZGOhQRLjDo4a0BKgrH858HJ7zFk7/Yti58LwgeIGjYHhuYc+xQOdnBWPzGSD47TuFX5GXmf/x1nmg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    tinycolor2 "^1.4.1"
+
+"@jimp/plugin-contain@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.13.0.tgz#d87408f078d3f9438b5d04045cba5d1f185d8fc2"
+  integrity sha512-qPYS+ccMP4mEnf7BB0bcaszUTth8OxeRX0MdMvU6PDEI0nIvVVNwmuI6YtNqqs12PwuYxgPkq6AFenyLyoNP1g==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-cover@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.13.0.tgz#bf7e82df3857b374ece24cfb0f524c0cc6e03a6e"
+  integrity sha512-S2GkbXNgIb0afof/NLLq9IJDZPOcFtu1mc32ngt9S8HzXsNHgRAzONW7cg56bwQ6p0+sz/dS5tB4ctOW/pu/Dw==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-crop@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.13.0.tgz#2215923db11694796a40a294faf373454a0af3f8"
+  integrity sha512-Y1Ug3kOzsq72EjLiWQlwkHuvUvdSmFUDtxpyOXh3RxeWF7wmdjH8FvdhPj8hWvFLsDYFgWGaLI4Z6SXOr+N8nA==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-displace@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.13.0.tgz#ace748989133322cc1da94f7699ab393904fc306"
+  integrity sha512-c80VIUjIqQoavafthLpYRZdzANCxbOCHzqkFVbZ0kNKJnDDk6fW55mvVW4TJLDToDU81WMlttCmNV0oXcX93gQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-dither@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.13.0.tgz#c5a917955b9b5690e9cece2ee10e072ff9cfe82e"
+  integrity sha512-EUz/y/AaQ00TnaiVLVAXLz8n8Nx7S36lKi4VXPeYy5a5FyzBimxNiKxdITVe9zooN7+H4FP++/xGFGFMpfWtRg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-fisheye@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-fisheye/-/plugin-fisheye-0.13.0.tgz#d300564c30ae1513268680b5ced4de87d6719aee"
+  integrity sha512-O7h5pNTk2sYcTKxLvV6+zzUpLx8qzdNl6qiP9x1S0CKy64oZ9IwtK1eR1eLom0YA8tUR7rX5Ra4pB8bhq8Oyqw==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-flip@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.13.0.tgz#502c07fbefddbe045a1a8d691f3918651bb0f349"
+  integrity sha512-gWk+Q0LmCvupUuWRfoGyETmH/+lJKZuPCeA9K6UHJldq5Cdg/3MrlKUNS1HcPCDXjw+dWDGC8QnNslvMTaY5VQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-gaussian@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.13.0.tgz#9ae00f6080b3ed777eb837d94d9d0a071aa1246b"
+  integrity sha512-0ctRqbCcLdy8y9IrSIH2McWNPLnEwjoe8qxtqoi51zRsM3z3mwjiTC2q8AWeF0SdIdWwV+YV/eP0494AJqjTsg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-invert@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.13.0.tgz#f06ba0eed19225f45d1b87fcc84ed8a233e32d1e"
+  integrity sha512-k7TWx/la0MrTcT1dMtncV6I9IuGToRm9Q0ekzfb3k8bHzWRYX4SUtt/WrZ/I+/znD/fGorLtFI057v7mcLJc5A==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-mask@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.13.0.tgz#42492be551c7d1eeb52f13d369476301b51cea7e"
+  integrity sha512-U3OdsgtMNpbCYc1lzzu96zdSgyz7BK9eD8IoFHdw4Ma8gCuM8kp9gdBJlKnzOh8eyYvssdCMZOWz9Xbxc5xH9A==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-normalize@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.13.0.tgz#e1f4de7f8caef061c2dc093f3a8251d278b837e5"
+  integrity sha512-yKOgZSvOxSHNlh+U9NOQH4Drgca0Dwb7DQRk3vj67gvHQC96JafIpGwN+9V4fP89lA3rkItbw6xgN6C/28HEUQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-print@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.13.0.tgz#32a1e9c30dbf38564956eb326b138b729b6067f1"
+  integrity sha512-Tv7r/1t7z63oLeRuEWw9xbm0G5uuBE54986+BOu8OFaHBpV/BbVHrE7ouApA0mKVZqMZCVjhO6Ph8+uFzRjdOw==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    load-bmfont "^1.4.0"
+
+"@jimp/plugin-resize@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.13.0.tgz#17eb70bed34054b4b9633a213e1396431e8d682e"
+  integrity sha512-XOo0Skn7aq/aGxV9czFx6EaBUbAsAGCVbAS26fMM0AZ4YAWWUEleKTpHunEo92giIPhvlxeFFjQR2jQ9UcB3uQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-rotate@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.13.0.tgz#325e799055b748fe1948eb85e7ad6ec46d08bc5b"
+  integrity sha512-BaNeh655kF9Rz01ZV+Bkc8hLsHpNu3QnzigruVDjGt9Paoig0EBr+Dgyjje+7eTLu20kyuyxwPUAxLSOiPiraQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-scale@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.13.0.tgz#cf498f6fef97354c5a6f1e37d5c5ef940a963297"
+  integrity sha512-e/f7lvii+DmRMgYF+uBKQy437f+J66WbL0FcFEataCF/W9UkTIQGeXdECwJSPfqr81SxC5mGbSBbsdbMKChzAQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-shadow@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-shadow/-/plugin-shadow-0.13.0.tgz#dd35819800e7375f2a8eb13a02bf1b8829a3e47e"
+  integrity sha512-qObtH63dmfPLze5wE8XDRjDsBOUnAfEWib4YbjPXGBZVxeKD7+2oPGemsK56HqC/+rYzIynkbi4MUIV1Q0dGjA==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugin-threshold@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-threshold/-/plugin-threshold-0.13.0.tgz#d92febd59f72d44e3afd60d74e1232ab1645b3a4"
+  integrity sha512-ACF7jk0ogso+2RK+0EsvBupVfE3IMq39wGFQWgpnHR9Tj12mSO279f6i/H8bcj1ZXmHot22nwLOG0wO4AlAaRg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+
+"@jimp/plugins@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.13.0.tgz#d1f2aed6ce4b7de7e3d683daf723d887aa4a826f"
+  integrity sha512-onu8GnSnFjLFuFVFq8+aTYFIDfH8kwZuBHeGaDyScPFFn6QMKsPl4TeLzQ5vwIPvcpkADuFFfuAshE4peutjjA==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/plugin-blit" "^0.13.0"
+    "@jimp/plugin-blur" "^0.13.0"
+    "@jimp/plugin-circle" "^0.13.0"
+    "@jimp/plugin-color" "^0.13.0"
+    "@jimp/plugin-contain" "^0.13.0"
+    "@jimp/plugin-cover" "^0.13.0"
+    "@jimp/plugin-crop" "^0.13.0"
+    "@jimp/plugin-displace" "^0.13.0"
+    "@jimp/plugin-dither" "^0.13.0"
+    "@jimp/plugin-fisheye" "^0.13.0"
+    "@jimp/plugin-flip" "^0.13.0"
+    "@jimp/plugin-gaussian" "^0.13.0"
+    "@jimp/plugin-invert" "^0.13.0"
+    "@jimp/plugin-mask" "^0.13.0"
+    "@jimp/plugin-normalize" "^0.13.0"
+    "@jimp/plugin-print" "^0.13.0"
+    "@jimp/plugin-resize" "^0.13.0"
+    "@jimp/plugin-rotate" "^0.13.0"
+    "@jimp/plugin-scale" "^0.13.0"
+    "@jimp/plugin-shadow" "^0.13.0"
+    "@jimp/plugin-threshold" "^0.13.0"
+    timm "^1.6.1"
+
+"@jimp/png@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.13.0.tgz#f4d9b6f7843908b081dbace984f0d45e45f1ffda"
+  integrity sha512-9MVU0BLMQKJ6Kaiwjrq6dLDnDktZzeHtxz4qthRHaGOyHLx3RpxmbhaDuK9dDg6NASX3JuXznEhaOP4lqQODpQ==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/utils" "^0.13.0"
+    pngjs "^3.3.3"
+
+"@jimp/tiff@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.13.0.tgz#2795a35073fe296dc09ac2fa693fe5b3558386f6"
+  integrity sha512-8lLGgEmhVRRjzZfn/QgpM3+mijq5ORYqRHtLcqDgcQaUY/q/OU1CxLYX777pozyQ3KIq1O+jyyHZm2xu3RZkPA==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    utif "^2.0.1"
+
+"@jimp/types@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.13.0.tgz#e397bc848ee9a01e0fe8e266efcc045f23c36f79"
+  integrity sha512-qGq9qVHiRTgtIy061FSBr9l7OFrSiFLkKyQVnOBndEjwls2XLBKXkMmSD2U3oiHcNuf3ACsDSTIzK3KX/hDHvg==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/bmp" "^0.13.0"
+    "@jimp/gif" "^0.13.0"
+    "@jimp/jpeg" "^0.13.0"
+    "@jimp/png" "^0.13.0"
+    "@jimp/tiff" "^0.13.0"
+    timm "^1.6.1"
+
+"@jimp/utils@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.13.0.tgz#4cde8039a70a1c94baa33e4d8074e174ba895b74"
+  integrity sha512-zA4573jE4FIpBKiYpPGo66JOAGdv/FS/N9fW9GpkbwJeTu12fV+r4R1ARSyt8UEKdE4DMBatBmQC0U2FGZijOA==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    regenerator-runtime "^0.13.3"
+
 "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
@@ -2088,6 +2385,11 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1:
   dependencies:
     color-convert "^1.9.0"
 
+any-base@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe"
+  integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==
+
 any-observable@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b"
@@ -2630,12 +2932,10 @@ atob@^2.1.2:
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
-attr-accept@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52"
-  integrity sha512-iT40nudw8zmCweivz6j58g+RT33I4KbaIvRUhjNmDwO2WmsQUxFEZZYZ5w3vXe5x5MX9D7mfvA/XaLOZYFR9EQ==
-  dependencies:
-    core-js "^2.5.0"
+attr-accept@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.1.0.tgz#a231a854385d36ff7a99647bb77b33c8a5175aee"
+  integrity sha512-sLzVM3zCCmmDtDNhI0i96k6PUztkotSOXqE4kDGQt/6iDi5M+H0srjeF+QC6jN581l4X/Zq3Zu/tgcErEssavg==
 
 authsome@^0.1.0:
   version "0.1.0"
@@ -3284,6 +3584,11 @@ bluebird@3.7.2, bluebird@^3.5.1, bluebird@^3.5.2, bluebird@^3.5.4, bluebird@^3.5
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
   integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
 
+bmp-js@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233"
+  integrity sha1-4Fpj95amwf8l9Hcex62twUjAcjM=
+
 bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0:
   version "4.11.9"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
@@ -3504,6 +3809,11 @@ buffer-equal-constant-time@1.0.1:
   resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
   integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
 
+buffer-equal@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b"
+  integrity sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=
+
 buffer-from@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -3533,6 +3843,14 @@ buffer@^4.3.0:
     ieee754 "^1.1.4"
     isarray "^1.0.0"
 
+buffer@^5.2.0:
+  version "5.6.0"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786"
+  integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==
+  dependencies:
+    base64-js "^1.0.2"
+    ieee754 "^1.1.4"
+
 builtin-status-codes@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
@@ -5136,7 +5454,7 @@ dom-helpers@^5.0.1:
     "@babel/runtime" "^7.8.7"
     csstype "^2.6.7"
 
-dom-serializer@0:
+dom-serializer@0, dom-serializer@^0.2.1:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
   integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
@@ -5186,6 +5504,13 @@ domhandler@^2.3.0:
   dependencies:
     domelementtype "1"
 
+domhandler@^3.0, domhandler@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.0.0.tgz#51cd13efca31da95bbb0c5bee3a48300e333b3e9"
+  integrity sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw==
+  dependencies:
+    domelementtype "^2.0.1"
+
 domutils@1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
@@ -5202,6 +5527,15 @@ domutils@^1.5.1:
     dom-serializer "0"
     domelementtype "1"
 
+domutils@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.1.0.tgz#7ade3201af43703fde154952e3a868eb4b635f16"
+  integrity sha512-CD9M0Dm1iaHfQ1R/TI+z3/JWp/pgub0j4jIQKH89ARR4ATAV2nbaOQS5XxU9maJP5jHaPdDDQSEHuE2UmpUTKg==
+  dependencies:
+    dom-serializer "^0.2.1"
+    domelementtype "^2.0.1"
+    domhandler "^3.0.0"
+
 dont-sniff-mimetype@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz#c7d0427f8bcb095762751252af59d148b0a623b2"
@@ -5907,6 +6241,11 @@ executable@4.1.1:
   dependencies:
     pify "^2.2.0"
 
+exif-parser@^0.1.12:
+  version "0.1.12"
+  resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922"
+  integrity sha1-WKnS1ywCwfbwKg70qRZicrd2CSI=
+
 exit-hook@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
@@ -6220,6 +6559,18 @@ file-loader@^1.1.5:
     loader-utils "^1.0.2"
     schema-utils "^0.4.5"
 
+file-selector@^0.1.12:
+  version "0.1.12"
+  resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.12.tgz#fe726547be219a787a9dcc640575a04a032b1fd0"
+  integrity sha512-Kx7RTzxyQipHuiqyZGf+Nz4vY9R1XGxuQl/hLoJwq+J4avk/9wxxgZyHKtbyIPJmbD4A66DWGYfyykWNpcYutQ==
+  dependencies:
+    tslib "^1.9.0"
+
+file-type@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18"
+  integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw==
+
 file-uri-to-path@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -6726,6 +7077,14 @@ getpass@^0.1.1:
   dependencies:
     assert-plus "^1.0.0"
 
+gifwrap@^0.9.2:
+  version "0.9.2"
+  resolved "https://registry.yarnpkg.com/gifwrap/-/gifwrap-0.9.2.tgz#348e286e67d7cf57942172e1e6f05a71cee78489"
+  integrity sha512-fcIswrPaiCDAyO8xnWvHSZdWChjKXUanKKpAiWWJ/UTkEi/aYKn5+90e7DE820zbEaVR9CE2y4z9bzhQijZ0BA==
+  dependencies:
+    image-q "^1.1.1"
+    omggif "^1.0.10"
+
 glob-base@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
@@ -6819,6 +7178,14 @@ global@^4.3.0:
     min-document "^2.19.0"
     process "^0.11.10"
 
+global@~4.3.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f"
+  integrity sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=
+  dependencies:
+    min-document "^2.19.0"
+    process "~0.5.1"
+
 globals@^11.0.1, globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -7248,6 +7615,16 @@ html-tags@^2.0.0:
   resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b"
   integrity sha1-ELMKOGCF9Dzt41PMj6fLDe7qZos=
 
+html-to-react@^1.3.4:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/html-to-react/-/html-to-react-1.4.3.tgz#1430a1cb581ef29533892ec70a2fdc4554b17ffd"
+  integrity sha512-txe09A3vxW8yEZGJXJ1is5gGDfBEVACmZDSgwDyH5EsfRdOubBwBCg63ZThZP0xBn0UE4FyvMXZXmohusCxDcg==
+  dependencies:
+    domhandler "^3.0"
+    htmlparser2 "^4.1.0"
+    lodash.camelcase "^4.3.0"
+    ramda "^0.27"
+
 html-webpack-plugin@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz#b01abbd723acaaa7b37b6af4492ebda03d9dd37b"
@@ -7280,6 +7657,16 @@ htmlparser2@^3.3.0, htmlparser2@^3.9.0, htmlparser2@^3.9.1, htmlparser2@^3.9.2:
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+htmlparser2@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78"
+  integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==
+  dependencies:
+    domelementtype "^2.0.1"
+    domhandler "^3.0.0"
+    domutils "^2.0.0"
+    entities "^2.0.0"
+
 http-deceiver@^1.2.7:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
@@ -7435,6 +7822,11 @@ ignore@^3.3.3, ignore@^3.3.5, ignore@^3.3.6:
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
   integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
 
+image-q@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/image-q/-/image-q-1.1.1.tgz#fc84099664460b90ca862d9300b6bfbbbfbf8056"
+  integrity sha1-/IQJlmRGC5DKhi2TALa/u7+/gFY=
+
 import-fresh@^3.1.0:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
@@ -7571,7 +7963,7 @@ interpret@^1.2.0:
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.3.0.tgz#6f637617cf307760be422ab9f4d13cc8a35eca1a"
   integrity sha512-RDVhhDkycLoSQtE9o0vpK/vOccVDsCbWVzRxArGYnlQLcihPl2loFbPyiH7CM0m2/ijOJU3+PZbnBPaB6NJ1MA==
 
-invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4:
+invariant@^2.2.0, invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
   integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
@@ -7824,6 +8216,11 @@ is-fullwidth-code-point@^2.0.0:
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
   integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
+is-function@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08"
+  integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==
+
 is-generator-fn@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-1.0.0.tgz#969d49e1bb3329f6bb7f09089be26578b2ddd46a"
@@ -8149,6 +8546,11 @@ isomorphic-fetch@^2.1.1:
     node-fetch "^1.0.1"
     whatwg-fetch ">=0.10.0"
 
+isomorphic.js@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.1.3.tgz#b2956e14be3bc10efb5fe80e6b5bebc16e1deeca"
+  integrity sha512-pabBRLDwYefSsNS+qCazJ97o7P5xDTrNoxSYFTM09JlZTxPrOEPGKekwqUy3/Np4C4PHnVUXHYsZPOix0jELsA==
+
 isstream@0.1.x, isstream@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -8530,6 +8932,17 @@ jest-worker@^22.2.2, jest-worker@^22.4.3:
   dependencies:
     merge-stream "^1.0.1"
 
+jimp@^0.13.0:
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.13.0.tgz#d5497350adaebf5dd32e4b873871fe41297cccf4"
+  integrity sha512-N/iG8L7Qe+AcHhrgcL0m7PTP/14iybmSIuOqCDvuel9gcIKEzxbbGuPCJVMchwXzusc2E7h9UjO9LZDfXb/09w==
+  dependencies:
+    "@babel/runtime" "^7.7.2"
+    "@jimp/custom" "^0.13.0"
+    "@jimp/plugins" "^0.13.0"
+    "@jimp/types" "^0.13.0"
+    regenerator-runtime "^0.13.3"
+
 joi-browser@^10.0.6:
   version "10.6.1"
   resolved "https://registry.yarnpkg.com/joi-browser/-/joi-browser-10.6.1.tgz#1cfc1a244c9242327842c24354d8ead1c2fe3571"
@@ -8554,6 +8967,11 @@ joi@^14.3.0:
     isemail "3.x.x"
     topo "3.x.x"
 
+jpeg-js@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.0.tgz#39adab7245b6d11e918ba5d4b49263ff2fc6a2f9"
+  integrity sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==
+
 js-base64@^2.1.9:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.2.tgz#313b6274dda718f714d00b3330bbae6e38e90209"
@@ -8875,6 +9293,13 @@ levn@^0.3.0, levn@~0.3.0:
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+lib0@^0.2.27, lib0@^0.2.28:
+  version "0.2.28"
+  resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.28.tgz#bb0c9af79b260ebf3a7b359af622e1d8c5dc52fd"
+  integrity sha512-3gB5Ow5B/iL5jSEDgNIlkylX5loHrGeTajZXcCFEE8svVhYBVAn9Rt0uw+86bpbw64tFN8gkZ+BSivv8C0ZWdQ==
+  dependencies:
+    isomorphic.js "^0.1.3"
+
 liftoff@3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3"
@@ -9005,6 +9430,20 @@ listr@^0.12.0:
     stream-to-observable "^0.1.0"
     strip-ansi "^3.0.1"
 
+load-bmfont@^1.3.1, load-bmfont@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.0.tgz#75f17070b14a8c785fe7f5bee2e6fd4f98093b6b"
+  integrity sha512-kT63aTAlNhZARowaNYcY29Fn/QYkc52M3l6V1ifRcPewg2lvUZDAj7R6dXjOL9D0sict76op3T5+odumDSF81g==
+  dependencies:
+    buffer-equal "0.0.1"
+    mime "^1.3.4"
+    parse-bmfont-ascii "^1.0.3"
+    parse-bmfont-binary "^1.0.5"
+    parse-bmfont-xml "^1.1.4"
+    phin "^2.9.1"
+    xhr "^2.0.1"
+    xtend "^4.0.0"
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -9199,7 +9638,7 @@ lodash.uniq@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash@4.17.15, lodash@^4, lodash@^4.0.0, lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.3.0:
+lodash@4.17.15, lodash@^4, lodash@^4.0.0, lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.3.0, lodash@^4.5.1:
   version "4.17.15"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
   integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
@@ -9405,6 +9844,13 @@ md5@^2.2.1:
     crypt "~0.0.1"
     is-buffer "~1.1.1"
 
+mdast-add-list-metadata@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mdast-add-list-metadata/-/mdast-add-list-metadata-1.0.1.tgz#95e73640ce2fc1fa2dcb7ec443d09e2bfe7db4cf"
+  integrity sha512-fB/VP4MJ0LaRsog7hGPxgOrSL3gE/2uEdZyDuSEnKCv/8IkYHiDkIQSbChiJoHyxZZXZ9bzckyRk+vNxFzh8rA==
+  dependencies:
+    unist-util-visit-parents "1.1.2"
+
 mdast-util-compact@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"
@@ -9577,7 +10023,7 @@ mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
   dependencies:
     mime-db "1.44.0"
 
-mime@1.6.0, mime@^1.4.1:
+mime@1.6.0, mime@^1.3.4, mime@^1.4.1:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
   integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
@@ -10337,6 +10783,11 @@ obuf@^1.0.0, obuf@^1.1.2:
   resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
   integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
 
+omggif@^1.0.10, omggif@^1.0.9:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.10.tgz#ddaaf90d4a42f532e9e7cb3a95ecdd47f17c7b19"
+  integrity sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==
+
 on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -10583,7 +11034,7 @@ packet-reader@1.0.0:
   resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74"
   integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==
 
-pako@~1.0.5:
+pako@^1.0.5, pako@~1.0.5:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
   integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
@@ -10623,7 +11074,25 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5:
     pbkdf2 "^3.0.3"
     safe-buffer "^5.1.1"
 
-parse-entities@^1.0.2:
+parse-bmfont-ascii@^1.0.3:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz#11ac3c3ff58f7c2020ab22769079108d4dfa0285"
+  integrity sha1-Eaw8P/WPfCAgqyJ2kHkQjU36AoU=
+
+parse-bmfont-binary@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz#d038b476d3e9dd9db1e11a0b0e53a22792b69006"
+  integrity sha1-0Di0dtPp3Z2x4RoLDlOiJ5K2kAY=
+
+parse-bmfont-xml@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/parse-bmfont-xml/-/parse-bmfont-xml-1.1.4.tgz#015319797e3e12f9e739c4d513872cd2fa35f389"
+  integrity sha512-bjnliEOmGv3y1aMEfREMBJ9tfL3WR0i0CKPj61DnSLaoxWR3nLrsQrEbCId/8rF4NyRF0cCqisSVXyQYWM+mCQ==
+  dependencies:
+    xml-parse-from-string "^1.0.0"
+    xml2js "^0.4.5"
+
+parse-entities@^1.0.2, parse-entities@^1.1.0:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
   integrity sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg==
@@ -10654,6 +11123,11 @@ parse-glob@^3.0.4:
     is-extglob "^1.0.0"
     is-glob "^2.0.0"
 
+parse-headers@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.3.tgz#5e8e7512383d140ba02f0c7aa9f49b4399c92515"
+  integrity sha512-QhhZ+DCCit2Coi2vmAKbq5RGTRcQUOE2+REgv8vdyu7MnYx2eZztegqtTx99TZ86GTIwqiy3+4nQTWZ2tgmdCA==
+
 parse-json@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
@@ -10955,6 +11429,11 @@ pgpass@1.x:
   dependencies:
     split "^1.0.0"
 
+phin@^2.9.1:
+  version "2.9.3"
+  resolved "https://registry.yarnpkg.com/phin/-/phin-2.9.3.tgz#f9b6ac10a035636fb65dfc576aaaa17b8743125c"
+  integrity sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==
+
 picomatch@^2.0.4, picomatch@^2.2.1:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
@@ -10987,6 +11466,13 @@ pinkie@^2.0.0:
   resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
   integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
 
+pixelmatch@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854"
+  integrity sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ=
+  dependencies:
+    pngjs "^3.0.0"
+
 pkg-dir@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
@@ -11030,6 +11516,11 @@ pn@^1.1.0:
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
   integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
 
+pngjs@^3.0.0, pngjs@^3.3.3:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
+  integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
+
 portfinder@^1.0.26:
   version "1.0.26"
   resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
@@ -11484,6 +11975,11 @@ process@^0.11.10:
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
   integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
 
+process@~0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf"
+  integrity sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=
+
 progress@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@@ -11525,7 +12021,7 @@ prop-types-exact@^1.2.0:
     object.assign "^4.1.0"
     reflect.ownkeys "^0.2.0"
 
-prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
+prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
   version "15.7.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
   integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -12018,6 +12514,11 @@ ramda@0.26.1:
   resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06"
   integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==
 
+ramda@^0.27:
+  version "0.27.0"
+  resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.0.tgz#915dc29865c0800bf3f69b8fd6c279898b59de43"
+  integrity sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA==
+
 randexp@0.4.6:
   version "0.4.6"
   resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
@@ -12110,13 +12611,14 @@ react-dropdown@^1.6.2:
   dependencies:
     classnames "^2.2.3"
 
-react-dropzone@^4.1.2:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.3.0.tgz#facdd7db16509772633c9f5200621ac01aa6706f"
-  integrity sha512-ULfrLaTSsd8BDa9KVAGCueuq1AN3L14dtMsGGqtP0UwYyjG4Vhf158f/ITSHuSPYkZXbvfcIiOlZsH+e3QWm+Q==
+react-dropzone@^10.2.2:
+  version "10.2.2"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-10.2.2.tgz#67b4db7459589a42c3b891a82eaf9ade7650b815"
+  integrity sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA==
   dependencies:
-    attr-accept "^1.1.3"
-    prop-types "^15.5.7"
+    attr-accept "^2.0.0"
+    file-selector "^0.1.12"
+    prop-types "^15.7.2"
 
 react-emotion@^9.2.5:
   version "9.2.12"
@@ -12157,6 +12659,11 @@ react-html-parser@^2.0.2:
   dependencies:
     htmlparser2 "^3.9.0"
 
+react-image@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/react-image/-/react-image-4.0.1.tgz#e85a9c5ca11c84c59098dc0db3f5fabb05aacc50"
+  integrity sha512-B80I1UQU6XHJ5ZrQrmD6ADc5GrBlpXRsmcgBgwwJnfYflynkZ5NBrLBR+yt3zlDp6qlelxrBnF0ChpOwdgYiEA==
+
 react-input-autosize@^2.1.2, react-input-autosize@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.2.tgz#fcaa7020568ec206bc04be36f4eb68e647c4d8c2"
@@ -12183,6 +12690,31 @@ react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
   resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
   integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
 
+react-markdown@^4.3.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-4.3.1.tgz#39f0633b94a027445b86c9811142d05381300f2f"
+  integrity sha512-HQlWFTbDxTtNY6bjgp3C3uv1h2xcjCSi1zAEzfBW9OwJJvENSYiLXWNXN5hHLsoqai7RnZiiHzcnWdXk2Splzw==
+  dependencies:
+    html-to-react "^1.3.4"
+    mdast-add-list-metadata "1.0.1"
+    prop-types "^15.7.2"
+    react-is "^16.8.6"
+    remark-parse "^5.0.0"
+    unified "^6.1.5"
+    unist-util-visit "^1.3.0"
+    xtend "^4.0.1"
+
+react-mentions@^3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/react-mentions/-/react-mentions-3.3.1.tgz#b9e111443403de6a8c7e7f363d6d10939f0615f4"
+  integrity sha512-/UOZXTgK2rvuyjj8T0wVb4AsAjcKahwG6PtweSdbZAWbwmkaGkm49Yu725D6Xyw73h4qZhvzIeIY0K8ichfAHg==
+  dependencies:
+    "@babel/runtime" "7.4.5"
+    invariant "^2.2.4"
+    lodash "^4.5.1"
+    prop-types "^15.5.8"
+    substyle "^6.3.1"
+
 react-moment@^0.9.6:
   version "0.9.7"
   resolved "https://registry.yarnpkg.com/react-moment/-/react-moment-0.9.7.tgz#ca570466595b1aa4f7619e62da18b3bb2de8b6f3"
@@ -12301,6 +12833,13 @@ react-uid@^2.2.0:
   dependencies:
     tslib "^1.10.0"
 
+react-visibility-sensor@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/react-visibility-sensor/-/react-visibility-sensor-5.1.1.tgz#5238380960d3a0b2be0b7faddff38541e337f5a9"
+  integrity sha512-cTUHqIK+zDYpeK19rzW6zF9YfT4486TIgizZW53wEZ+/GPBbK7cNS0EHyJVyHYacwFEvvHLEKfgJndbemWhB/w==
+  dependencies:
+    prop-types "^15.7.2"
+
 react@^16.3.2, react@^16.8.6, react@^16.9.0:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
@@ -12506,7 +13045,7 @@ regenerator-runtime@^0.11.0:
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
   integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
 
-regenerator-runtime@^0.13.4:
+regenerator-runtime@^0.13.2, regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4:
   version "0.13.5"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"
   integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==
@@ -12597,6 +13136,27 @@ remark-parse@^4.0.0:
     vfile-location "^2.0.0"
     xtend "^4.0.1"
 
+remark-parse@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95"
+  integrity sha512-b3iXszZLH1TLoyUzrATcTQUZrwNl1rE70rVdSruJFlDaJ9z5aMkhrG43Pp68OgfHndL/ADz6V69Zow8cTQu+JA==
+  dependencies:
+    collapse-white-space "^1.0.2"
+    is-alphabetical "^1.0.0"
+    is-decimal "^1.0.0"
+    is-whitespace-character "^1.0.0"
+    is-word-character "^1.0.0"
+    markdown-escapes "^1.0.0"
+    parse-entities "^1.1.0"
+    repeat-string "^1.5.4"
+    state-toggle "^1.0.0"
+    trim "0.0.1"
+    trim-trailing-lines "^1.0.0"
+    unherit "^1.0.4"
+    unist-util-remove-position "^1.0.0"
+    vfile-location "^2.0.0"
+    xtend "^4.0.1"
+
 remark-stringify@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-4.0.0.tgz#4431884c0418f112da44991b4e356cfe37facd87"
@@ -12961,7 +13521,7 @@ sass-loader@^6.0.6:
     neo-async "^2.5.0"
     pify "^3.0.0"
 
-sax@^1.2.4, sax@~1.2.1:
+sax@>=0.6.0, sax@^1.2.4, sax@~1.2.1:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -13890,6 +14450,16 @@ subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.12, subscrip
     symbol-observable "^1.0.4"
     ws "^5.2.0"
 
+substyle@^6.3.1:
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/substyle/-/substyle-6.3.1.tgz#29801f06e016dda8df91b531a69338d6e096fa58"
+  integrity sha512-S25YRgVQB25cLXgGAJ7AXTYewkIB/9Fa1Y7jxkN48U4N6rbK9YhVEiF7vtPGLlJl8ebSOJ3N4t8cd6jL8MH7Uw==
+  dependencies:
+    hoist-non-react-statics "^3.1.0"
+    invariant "^2.2.0"
+    prop-types "^15.5.8"
+    warning "^2.1.0"
+
 sugarss@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-1.0.1.tgz#be826d9003e0f247735f92365dc3fd7f1bae9e44"
@@ -14116,6 +14686,11 @@ timers-browserify@^2.0.4:
   dependencies:
     setimmediate "^1.0.4"
 
+timm@^1.6.1:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/timm/-/timm-1.6.2.tgz#dfd8c6719f7ba1fcfc6295a32670a1c6d166c0bd"
+  integrity sha512-IH3DYDL1wMUwmIlVmMrmesw5lZD6N+ZOAFWEyLrtpoL9Bcrs9u7M/vyOnHzDD2SMs4irLkVjqxZbHrXStS/Nmw==
+
 tiny-invariant@^1.0.2:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
@@ -14126,6 +14701,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3:
   resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
   integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
 
+tinycolor2@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8"
+  integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=
+
 tmp-promise@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-2.1.1.tgz#eb97c038995af74efbfe8156f5e07fdd0c935539"
@@ -14429,7 +15009,7 @@ unicode-property-aliases-ecmascript@^1.0.4:
   resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
   integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
 
-unified@^6.0.0:
+unified@^6.0.0, unified@^6.1.5:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba"
   integrity sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==
@@ -14499,6 +15079,11 @@ unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1:
   resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6"
   integrity sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ==
 
+unist-util-visit-parents@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz#f6e3afee8bdbf961c0e6f028ea3c0480028c3d06"
+  integrity sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q==
+
 unist-util-visit-parents@^2.0.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz#25e43e55312166f3348cae6743588781d112c1e9"
@@ -14506,7 +15091,7 @@ unist-util-visit-parents@^2.0.0:
   dependencies:
     unist-util-is "^3.0.0"
 
-unist-util-visit@^1.1.0:
+unist-util-visit@^1.1.0, unist-util-visit@^1.3.0:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3"
   integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw==
@@ -14595,6 +15180,13 @@ use@^3.1.0:
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
   integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
 
+utif@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/utif/-/utif-2.0.1.tgz#9e1582d9bbd20011a6588548ed3266298e711759"
+  integrity sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==
+  dependencies:
+    pako "^1.0.5"
+
 util-deprecate@^1.0.1, util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -14788,6 +15380,13 @@ walker@~1.0.5:
   dependencies:
     makeerror "1.0.x"
 
+warning@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/warning/-/warning-2.1.0.tgz#21220d9c63afc77a8c92111e011af705ce0c6901"
+  integrity sha1-ISINnGOvx3qMkhEeARr3Bc4MaQE=
+  dependencies:
+    loose-envify "^1.0.0"
+
 warning@^4.0.1:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
@@ -15311,11 +15910,39 @@ x-xss-protection@1.3.0:
   resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.3.0.tgz#3e3a8dd638da80421b0e9fff11a2dbe168f6d52c"
   integrity sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg==
 
+xhr@^2.0.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.5.0.tgz#bed8d1676d5ca36108667692b74b316c496e49dd"
+  integrity sha512-4nlO/14t3BNUZRXIXfXe+3N6w3s1KoxcJUUURctd64BLRe67E4gRwp4PjywtDY72fXpZ1y6Ch0VZQRY/gMPzzQ==
+  dependencies:
+    global "~4.3.0"
+    is-function "^1.0.1"
+    parse-headers "^2.0.0"
+    xtend "^4.0.0"
+
 xml-name-validator@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
   integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
 
+xml-parse-from-string@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz#a9029e929d3dbcded169f3c6e28238d95a5d5a28"
+  integrity sha1-qQKekp09vN7RafPG4oI42VpdWig=
+
+xml2js@^0.4.5:
+  version "0.4.23"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
+  integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
+  dependencies:
+    sax ">=0.6.0"
+    xmlbuilder "~11.0.0"
+
+xmlbuilder@~11.0.0:
+  version "11.0.1"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
+  integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
+
 xpub-edit@^2.6.10:
   version "2.6.10"
   resolved "https://registry.yarnpkg.com/xpub-edit/-/xpub-edit-2.6.10.tgz#0f316a4389ea85eb19ca29d9fcf52ef293be9c63"
@@ -15371,6 +15998,13 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
   integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
 
+y-protocols@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.0.tgz#d06035f9c8824b1fdc652c6a06b3c9aaca9261f0"
+  integrity sha512-L/GB+ryTmrrE0ISLIsNmgXl8lmt+CF7wG9Gm6jQf1JQYKZuzX3+Tbz3b7ov/quXwguM5zcVlJ0zxG29SgQD5Ww==
+  dependencies:
+    lib0 "^0.2.28"
+
 y18n@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
@@ -15475,6 +16109,13 @@ yauzl@2.10.0, yauzl@^2.10.0:
     buffer-crc32 "~0.2.3"
     fd-slicer "~1.1.0"
 
+yjs@^13.2.0:
+  version "13.2.0"
+  resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.2.0.tgz#179997208bd835b84cbc9ffd1037b076299a5eed"
+  integrity sha512-0augWOespX5KC8de62GCR8WloZhAyBfEF3ZPDpjZlRs6yho7iFKqarpzxxJgmP8zA/pNJiV1EIpMezSxEdNdDw==
+  dependencies:
+    lib0 "^0.2.27"
+
 zen-observable-ts@^0.8.21:
   version "0.8.21"
   resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d"