diff --git a/api/mutations/message/addMessage.js b/api/mutations/message/addMessage.js index 0e3db80d39..e6e3c501c0 100644 --- a/api/mutations/message/addMessage.js +++ b/api/mutations/message/addMessage.js @@ -1,5 +1,6 @@ // @flow -import { markdownToDraft } from 'markdown-draft-js'; +import { stateFromMarkdown } from 'draft-js-import-markdown'; +import { convertToRaw } from 'draft-js'; import type { GraphQLContext } from '../../'; import UserError from '../../utils/UserError'; import { uploadImage } from '../../utils/file-storage'; @@ -87,7 +88,13 @@ export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { if (message.messageType === 'text') { message.content.body = JSON.stringify( - markdownToDraft(message.content.body) + convertToRaw( + stateFromMarkdown(message.content.body, { + parserOptions: { + breaks: true, + }, + }) + ) ); message.messageType = 'draftjs'; } diff --git a/api/mutations/message/editMessage.js b/api/mutations/message/editMessage.js index 5333869a02..44538e81ea 100644 --- a/api/mutations/message/editMessage.js +++ b/api/mutations/message/editMessage.js @@ -1,5 +1,7 @@ // @flow import type { GraphQLContext } from '../../'; +import { convertToRaw } from 'draft-js'; +import { stateFromMarkdown } from 'draft-js-import-markdown'; import UserError from '../../utils/UserError'; import { getMessage, @@ -18,6 +20,7 @@ import { trackQueue } from 'shared/bull/queues'; type Args = { input: { id: string, + messageType?: 'draftjs' | 'text' | 'media', content: { body: string, }, @@ -26,7 +29,7 @@ type Args = { export default requireAuth(async (_: any, args: Args, ctx: GraphQLContext) => { const { - input: { id, content }, + input: { id, content, messageType }, } = args; const { user, loaders } = ctx; @@ -43,8 +46,18 @@ export default requireAuth(async (_: any, args: Args, ctx: GraphQLContext) => { return new UserError('This message does not exist.'); } - if (content.body === message.content.body) { - return message; + let body = content.body; + if (messageType === 'text') { + body = JSON.stringify( + convertToRaw( + stateFromMarkdown(body, { + parserOptions: { + breaks: true, + }, + }) + ) + ); + messageType === 'draftjs'; } const eventFailed = @@ -52,6 +65,66 @@ export default requireAuth(async (_: any, args: Args, ctx: GraphQLContext) => { ? events.MESSAGE_EDITED_FAILED : events.DIRECT_MESSAGE_EDITED_FAILED; + if (messageType === 'draftjs') { + let parsed; + try { + parsed = JSON.parse(body); + } catch (err) { + trackQueue.add({ + userId: user.id, + event: eventFailed, + properties: { + reason: 'invalid draftjs data', + message, + }, + }); + + return new UserError( + 'Please provide serialized raw DraftJS content state as content.body' + ); + } + if (!parsed.blocks || !Array.isArray(parsed.blocks) || !parsed.entityMap) { + trackQueue.add({ + userId: user.id, + event: eventFailed, + properties: { + reason: 'invalid draftjs data', + message, + }, + }); + + return new UserError( + 'Please provide serialized raw DraftJS content state as content.body' + ); + } + if ( + parsed.blocks.some( + ({ type }) => + !type || + (type !== 'unstyled' && + type !== 'code-block' && + type !== 'blockquote') + ) + ) { + trackQueue.add({ + userId: user.id, + event: eventFailed, + properties: { + reason: 'invalid draftjs data', + message, + }, + }); + + return new UserError( + 'Invalid DraftJS block type specified. Supported block types: "unstyled", "code-block".' + ); + } + } + + if (body === message.content.body) { + return message; + } + if (message.senderId !== user.id) { trackQueue.add({ userId: user.id, @@ -65,5 +138,14 @@ export default requireAuth(async (_: any, args: Args, ctx: GraphQLContext) => { return new UserError('You can only edit your own messages.'); } - return editMessage(args.input, user.id); + return editMessage( + { + ...args.input, + content: { + body, + }, + messageType, + }, + user.id + ); }); diff --git a/api/mutations/thread/publishThread.js b/api/mutations/thread/publishThread.js index e505995edc..01df02f6c1 100644 --- a/api/mutations/thread/publishThread.js +++ b/api/mutations/thread/publishThread.js @@ -1,7 +1,7 @@ // @flow const debug = require('debug')('api:mutations:thread:publish-thread'); import stringSimilarity from 'string-similarity'; -import { markdownToDraft } from 'markdown-draft-js'; +import { stateFromMarkdown } from 'draft-js-import-markdown'; import type { GraphQLContext } from '../../'; import UserError from '../../utils/UserError'; import { uploadImage } from '../../utils/file-storage'; @@ -71,7 +71,11 @@ export default requireAuth( type = 'DRAFTJS'; if (thread.content.body) { thread.content.body = JSON.stringify( - markdownToDraft(thread.content.body) + stateFromMarkdown(thread.content.body, { + parserOptions: { + breaks: true, + }, + }) ); } } diff --git a/api/package.json b/api/package.json index 32d90563cf..746f7d5076 100644 --- a/api/package.json +++ b/api/package.json @@ -35,6 +35,7 @@ "draft-js-embed-plugin": "^1.2.0", "draft-js-focus-plugin": "2.0.0-rc2", "draft-js-image-plugin": "2.0.0-rc8", + "draft-js-import-markdown": "^1.2.3", "draft-js-linkify-plugin": "^2.0.0-beta1", "draft-js-markdown-plugin": "^1.4.4", "draft-js-plugins-editor": "^2.1.1", @@ -76,7 +77,6 @@ "lodash": "^4.17.11", "lodash.intersection": "^4.4.0", "longjohn": "^0.2.12", - "markdown-draft-js": "^0.6.3", "moment": "^2.23.0", "node-env-file": "^0.1.8", "node-localstorage": "^1.3.1", diff --git a/api/types/Message.js b/api/types/Message.js index a066c9dc22..f62aae7438 100644 --- a/api/types/Message.js +++ b/api/types/Message.js @@ -53,6 +53,7 @@ const Message = /* GraphQL */ ` input EditMessageInput { id: ID! + messageType: MessageTypes! content: MessageContentInput } diff --git a/api/yarn.lock b/api/yarn.lock index 1042f0c517..142bf57b4d 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -3495,6 +3495,22 @@ draft-js-image-plugin@2.0.0-rc8: prop-types "^15.5.8" union-class-names "^1.0.0" +draft-js-import-element@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/draft-js-import-element/-/draft-js-import-element-1.2.1.tgz#9a6a56d74690d48d35d8d089564e6d710b4926eb" + integrity sha512-T/eCDkaU8wrTCH6c+/2BE7Vx/11GABRNU/UBiHM4D903LNFar8UfjElehpiKVf+F4rxi8dfhvTgaWrpWDfX4MA== + dependencies: + draft-js-utils "^1.2.0" + synthetic-dom "^1.2.0" + +draft-js-import-markdown@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/draft-js-import-markdown/-/draft-js-import-markdown-1.2.3.tgz#71ffc8eee1530f0349c22273681fbcb3c659c0c0" + integrity sha512-NPcXwWSsIA+uwASzdJWLQM4y+xW1vTDtDdIDHCHfP76i9cx8zYpH75GW8Ezz8L9SW2qetNcFW056Hj2yxRZ+2g== + dependencies: + draft-js-import-element "^1.2.1" + synthetic-dom "^1.2.0" + draft-js-linkify-plugin@^2.0.0-beta1: version "2.0.1" resolved "https://registry.yarnpkg.com/draft-js-linkify-plugin/-/draft-js-linkify-plugin-2.0.1.tgz#28978b53640ce64c639cd2821a54c24de9f79c3f" @@ -3570,6 +3586,11 @@ draft-js-prism@ngs/draft-js-prism#6edb31c3805dd1de3fb897cc27fced6bac1bafbb: immutable "*" prismjs "^1.5.0" +draft-js-utils@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/draft-js-utils/-/draft-js-utils-1.2.0.tgz#f5cb23eb167325ffed3d79882fdc317721d2fd12" + integrity sha1-9csj6xZzJf/tPXmIL9wxdyHS/RI= + draft-js@0.x, draft-js@^0.10.4, draft-js@^0.10.5, draft-js@~0.10.0: version "0.10.5" resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742" @@ -6376,13 +6397,6 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -markdown-draft-js@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/markdown-draft-js/-/markdown-draft-js-0.6.3.tgz#5847cd3d07d7b7e3d4d87c5d7e5928c3a71bddd4" - integrity sha512-8kn53iDi9M+0jOeF5dXc2vy8tCkrcD/QlltOz9GErenWrJD+VJ5ZFRjjmPVk7TnE8f5R0DiGCDL/w8M2Lj7xQw== - dependencies: - remarkable "1.7.1" - math-random@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac" @@ -8137,7 +8151,7 @@ regjsparser@^0.6.0: dependencies: jsesc "~0.5.0" -remarkable@1.7.1, remarkable@^1.x: +remarkable@^1.x: version "1.7.1" resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-1.7.1.tgz#aaca4972100b66a642a63a1021ca4bac1be3bff6" integrity sha1-qspJchALZqZCpjoQIcpLrBvjv/Y= @@ -9024,6 +9038,11 @@ symbol-tree@^3.2.1: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY= +synthetic-dom@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/synthetic-dom/-/synthetic-dom-1.2.0.tgz#f3589aafe2b5e299f337bb32973a9be42dd5625e" + integrity sha1-81iar+K14pnzN7sylzqb5C3VYl4= + table@^4.0.2: version "4.0.3" resolved "https://registry.yarnpkg.com/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc" diff --git a/cypress/integration/messages_spec.js b/cypress/integration/messages_spec.js index 16388b340b..721389e5be 100644 --- a/cypress/integration/messages_spec.js +++ b/cypress/integration/messages_spec.js @@ -12,13 +12,13 @@ describe('/messages/new', () => { it('should allow to continue composing message incase of crash or reload', () => { const newMessage = 'Persist New Message'; - cy.get('[contenteditable="true"]').type(newMessage); - cy.get('[contenteditable="true"]').contains(newMessage); + cy.get('[data-cy="chat-input"]').type(newMessage); + cy.get('[data-cy="chat-input"]').contains(newMessage); cy.wait(2000); // Reload page(incase page closed or crashed ,reload should have same effect) cy.reload(); - cy.get('[contenteditable="true"]').contains(newMessage); + cy.get('[data-cy="chat-input"]').contains(newMessage); }); }); @@ -86,9 +86,9 @@ describe('/messages', () => { .click(); const newMessage = 'A new message!'; - cy.get('[contenteditable="true"]').type(newMessage); + cy.get('[data-cy="chat-input"]').type(newMessage); cy.get('[data-cy="chat-input-send-button"]').click(); - cy.get('[contenteditable="true"]').type(''); + cy.get('[data-cy="chat-input"]').clear(); cy.contains(newMessage); cy.get('[data-cy="unread-dm-list-item"]').should($p => { diff --git a/cypress/integration/thread/chat_input_spec.js b/cypress/integration/thread/chat_input_spec.js index 0fd0ece03d..41843c118b 100644 --- a/cypress/integration/thread/chat_input_spec.js +++ b/cypress/integration/thread/chat_input_spec.js @@ -73,25 +73,25 @@ describe('chat input', () => { it('should allow authed members to send messages', () => { const newMessage = 'A new message!'; cy.get('[data-cy="thread-view"]').should('be.visible'); - cy.get('[contenteditable="true"]').type(newMessage); + cy.get('[data-cy="chat-input"]').type(newMessage); // Wait for the messages to be loaded before sending new message cy.get('[data-cy="message-group"]').should('be.visible'); cy.get('[data-cy="chat-input-send-button"]').click(); // Clear the chat input and make sure the message was sent by matching the text - cy.get('[contenteditable="true"]').type(''); + cy.get('[data-cy="chat-input"]').clear(); cy.contains(newMessage); }); it('should allow chat input to be maintained', () => { const newMessage = 'Persist New Message'; cy.get('[data-cy="thread-view"]').should('be.visible'); - cy.get('[contenteditable="true"]').type(newMessage); - cy.get('[contenteditable="true"]').contains(newMessage); + cy.get('[data-cy="chat-input"]').type(newMessage); + cy.get('[data-cy="chat-input"]').contains(newMessage); cy.get('[data-cy="message-group"]').should('be.visible'); cy.wait(1000); // Reload page(incase page closed or crashed ,reload should have same effect) cy.reload(); - cy.get('[contenteditable="true"]').contains(newMessage); + cy.get('[data-cy="chat-input"]').contains(newMessage); }); }); diff --git a/cypress/integration/thread_spec.js b/cypress/integration/thread_spec.js index 262a8c2852..27456ffcd5 100644 --- a/cypress/integration/thread_spec.js +++ b/cypress/integration/thread_spec.js @@ -69,12 +69,12 @@ describe('Thread View', () => { it('should allow logged-in users to send public messages', () => { const newMessage = 'A new message!'; cy.get('[data-cy="thread-view"]').should('be.visible'); - cy.get('[contenteditable="true"]').type(newMessage); + cy.get('[data-cy="chat-input"]').type(newMessage); // Wait for the messages to be loaded before sending new message cy.get('[data-cy="message-group"]').should('be.visible'); cy.get('[data-cy="chat-input-send-button"]').click(); // Clear the chat input and make sure the message was sent by matching the text - cy.get('[contenteditable="true"]').type(''); + cy.get('[data-cy="chat-input"]').clear(); cy.contains(newMessage); }); }); @@ -101,10 +101,10 @@ describe('Thread View', () => { it('should allow logged-in users to send private messages if they have permission', () => { const newMessage = 'A new private message!'; cy.get('[data-cy="thread-view"]').should('be.visible'); - cy.get('[contenteditable="true"]').type(newMessage); + cy.get('[data-cy="chat-input"]').type(newMessage); cy.get('[data-cy="chat-input-send-button"]').click(); // Clear the chat input and make sure the message was sent by matching the text - cy.get('[contenteditable="true"]').type(''); + cy.get('[data-cy="chat-input"]').clear(); cy.contains(newMessage); }); }); @@ -430,7 +430,7 @@ describe('edit message signed in', () => { .click({ force: true }); cy.get('[data-cy="edit-message-input"]'); - cy.get('[contenteditable="true"]').type(' with edits'); + cy.get('[data-cy="editing-chat-input"]').type(' with edits'); cy.get('[data-cy="edit-message-save"]').click(); @@ -443,7 +443,7 @@ describe('edit message signed in', () => { }); }); -describe.only('/new/thread', () => { +describe('/new/thread', () => { beforeEach(() => { cy.auth(author.id).then(() => cy.visit('/new/thread')); }); diff --git a/flow-typed/npm/draft-js-export-markdown_vx.x.x.js b/flow-typed/npm/draft-js-export-markdown_vx.x.x.js new file mode 100644 index 0000000000..222c52e49e --- /dev/null +++ b/flow-typed/npm/draft-js-export-markdown_vx.x.x.js @@ -0,0 +1,39 @@ +// flow-typed signature: 62c300788f61ec7382ffcd7ba580e1e3 +// flow-typed version: <>/draft-js-export-markdown_vx.x.x/flow_v0.66.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'draft-js-export-markdown' + * + * Fill this stub out by replacing all the `any` types. + * + * Once filled out, we encourage you to share your work with the + * community by sending a pull request to: + * https://github.com/flowtype/flow-typed + */ + +declare module 'draft-js-export-markdown' { + declare module.exports: any; +} + +/** + * We include stubs for each file inside this npm package in case you need to + * require those files directly. Feel free to delete any files that aren't + * needed. + */ +declare module 'draft-js-export-markdown/lib/main' { + declare module.exports: any; +} + +declare module 'draft-js-export-markdown/lib/stateToMarkdown' { + declare module.exports: any; +} + +// Filename aliases +declare module 'draft-js-export-markdown/lib/main.js' { + declare module.exports: $Exports<'draft-js-export-markdown/lib/main'>; +} +declare module 'draft-js-export-markdown/lib/stateToMarkdown.js' { + declare module.exports: $Exports<'draft-js-export-markdown/lib/stateToMarkdown'>; +} diff --git a/package.json b/package.json index 96d46798a3..6d31c589ce 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "prettier": "^1.14.3", "raw-loader": "^0.5.1", "react-app-rewire-hot-loader": "^1.0.3", - "react-hot-loader": "^4.3.11", + "react-hot-loader": "^4.6.0", "react-scripts": "^1.1.5", "rimraf": "^2.6.1", "sw-precache-webpack-plugin": "^0.11.4", @@ -87,9 +87,10 @@ "draft-js-code-editor-plugin": "0.2.1", "draft-js-drag-n-drop-plugin": "^2.0.3", "draft-js-embed-plugin": "^1.2.0", + "draft-js-export-markdown": "^1.2.2", "draft-js-focus-plugin": "^2.2.0", "draft-js-image-plugin": "^2.0.6", - "draft-js-import-markdown": "^1.2.1", + "draft-js-import-markdown": "^1.2.3", "draft-js-linkify-plugin": "^2.0.0-beta1", "draft-js-markdown-plugin": "^3.0.5", "draft-js-plugins-editor": "^2.1.1", @@ -133,7 +134,6 @@ "linkify-it": "^2.0.3", "lodash": "^4.17.11", "lodash.intersection": "^4.4.0", - "markdown-draft-js": "^0.6.3", "moment": "^2.22.2", "node-env-file": "^0.1.8", "now-env": "^3.1.0", @@ -148,12 +148,12 @@ "query-string": "5.1.1", "raf": "^3.4.0", "raven": "^2.6.4", - "react": "^16.6.3", + "react": "^16.7.0-alpha.2", "react-apollo": "^2.3.2", "react-app-rewire-styled-components": "^3.0.0", "react-app-rewired": "^1.6.2", "react-clipboard.js": "^2.0.1", - "react-dom": "^16.6.3", + "react-dom": "^16.7.0-alpha.2", "react-flip-move": "^3.0.2", "react-helmet-async": "^0.1.0", "react-image": "^1.5.1", @@ -283,4 +283,4 @@ ] }, "pre-commit": "lint:staged" -} \ No newline at end of file +} diff --git a/shared/graphql/mutations/message/sendMessage.js b/shared/graphql/mutations/message/sendMessage.js index f31d8ae77b..809ad0a30d 100644 --- a/shared/graphql/mutations/message/sendMessage.js +++ b/shared/graphql/mutations/message/sendMessage.js @@ -2,6 +2,8 @@ import gql from 'graphql-tag'; import { graphql } from 'react-apollo'; import { btoa } from 'b2a'; +import { stateFromMarkdown } from 'draft-js-import-markdown'; +import { convertToRaw } from 'draft-js'; import messageInfoFragment from '../../fragments/message/messageInfo'; import type { MessageInfoType } from '../../fragments/message/messageInfo'; import { getThreadMessageConnectionQuery } from '../../queries/thread/getThreadMessageConnection'; @@ -41,7 +43,7 @@ const sendMessageOptions = { addMessage: { id: fakeId, timestamp: JSON.parse(JSON.stringify(new Date())), - messageType: message.messageType, + messageType: message.messageType === 'media' ? 'media' : 'draftjs', modifiedAt: '', author: { user: { @@ -65,6 +67,18 @@ const sendMessageOptions = { : null, content: { ...message.content, + body: + message.messageType === 'media' + ? message.content.body + : JSON.stringify( + convertToRaw( + stateFromMarkdown(message.content.body, { + parserOptions: { + breaks: true, + }, + }) + ) + ), __typename: 'MessageContent', }, reactions: { diff --git a/src/components/chatInput/components/mediaUploader.js b/src/components/chatInput/components/mediaUploader.js index 0f3e997a1f..570b992ce2 100644 --- a/src/components/chatInput/components/mediaUploader.js +++ b/src/components/chatInput/components/mediaUploader.js @@ -13,7 +13,6 @@ type Props = { onError: Function, currentUser: ?Object, isSendingMediaMessage: boolean, - inputFocused: boolean, }; class MediaUploader extends React.Component { diff --git a/src/components/chatInput/index.js b/src/components/chatInput/index.js index 3c5907142a..a57e25ba49 100644 --- a/src/components/chatInput/index.js +++ b/src/components/chatInput/index.js @@ -1,21 +1,8 @@ // @flow import * as React from 'react'; import compose from 'recompose/compose'; -import withState from 'recompose/withState'; -import withHandlers from 'recompose/withHandlers'; import { connect } from 'react-redux'; -import { KeyBindingUtil } from 'draft-js'; -import debounce from 'debounce'; import Icon from 'src/components/icons'; -import { - toJSON, - toState, - fromPlainText, - toPlainText, - isAndroid, -} from 'shared/draft-utils'; -import mentionsDecorator from 'shared/clients/draft-js/mentions-decorator/index.web.js'; -import linksDecorator from 'shared/clients/draft-js/links-decorator/index.web.js'; import { addToastWithTimeout } from 'src/actions/toasts'; import { openModal } from 'src/actions/modals'; import { replyToMessage } from 'src/actions/message'; @@ -24,6 +11,8 @@ import { Form, ChatInputContainer, ChatInputWrapper, + Input, + InputWrapper, SendButton, PhotoSizeError, MarkdownHint, @@ -31,7 +20,6 @@ import { PreviewWrapper, RemovePreviewButton, } from './style'; -import Input from './input'; import sendMessage from 'shared/graphql/mutations/message/sendMessage'; import sendDirectMessage from 'shared/graphql/mutations/message/sendDirectMessage'; import { getMessageById } from 'shared/graphql/queries/message/getMessage'; @@ -63,21 +51,10 @@ const QuotedMessage = connect()( }) ); -type State = { - isFocused: boolean, - photoSizeError: string, - isSendingMediaMessage: boolean, - mediaPreview: string, - mediaPreviewFile: ?Blob, - markdownHint: boolean, -}; - type Props = { onRef: Function, currentUser: Object, dispatch: Dispatch, - onChange: Function, - state: Object, createThread: Function, sendMessage: Function, sendDirectMessage: Function, @@ -85,8 +62,6 @@ type Props = { threadType: string, thread: string, clear: Function, - onBlur: Function, - onFocus: Function, websocketConnection: string, networkOnline: boolean, threadData?: Object, @@ -94,210 +69,104 @@ type Props = { quotedMessage: ?{ messageId: string, threadId: string }, }; -const LS_KEY = 'last-chat-input-content'; -const LS_KEY_EXPIRE = 'last-chat-input-content-expire'; -const LS_DM_KEY = 'last-chat-input-content-dm'; -const LS_DM_KEY_EXPIRE = 'last-chat-input-content-dm-expire'; - -const ONE_DAY = (): string => { - const time = new Date().getTime() + 60 * 60 * 24 * 1000; - return time.toString(); -}; - -// We persist the body and title to localStorage -// so in case the app crashes users don't loose content -const returnText = (type = '') => { - let storedContent; - let storedContentDM; - const currTime = new Date().getTime().toString(); - if (localStorage) { - try { - const expireTime = localStorage.getItem(LS_KEY_EXPIRE); +// $FlowFixMe +const ChatInput = (props: Props) => { + const cacheKey = `last-content-${props.thread}`; + // $FlowFixMe + const [text, changeText] = React.useState(''); + // $FlowFixMe + const [photoSizeError, setPhotoSizeError] = React.useState(''); + // $FlowFixMe + const [inputRef, setInputRef] = React.useState(null); + + // 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.thread] + ); + + // 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] + ); + + const removeAttachments = () => { + removeQuotedMessage(); + setMediaPreview(null); + }; - // if current time is greater than valid till of text then please expire text back to '' - if (expireTime && currTime > expireTime) { - localStorage.removeItem(LS_KEY); - localStorage.removeItem(LS_KEY_EXPIRE); - } else { - storedContent = toState(JSON.parse(localStorage.getItem(LS_KEY) || '')); + const handleKeyPress = e => { + switch (e.key) { + // Submit on Enter unless Shift is pressed + case 'Enter': { + if (e.shiftKey) return; + e.preventDefault(); + submit(); + return; } - } catch (err) { - localStorage.removeItem(LS_KEY); - localStorage.removeItem(LS_KEY_EXPIRE); - } - - try { - const expireTimeDM = localStorage.getItem(LS_DM_KEY_EXPIRE); - - // if current time is greater than valid till of text then please expire text back to '' - if (expireTimeDM && currTime > expireTimeDM) { - localStorage.removeItem(LS_DM_KEY); - localStorage.removeItem(LS_DM_KEY_EXPIRE); - } else { - storedContentDM = toState( - JSON.parse(localStorage.getItem(LS_DM_KEY) || '') - ); + // If backspace is pressed on the empty + case 'Backspace': { + if (text.length === 0) removeAttachments(); + return; } - } catch (err) { - localStorage.removeItem(LS_DM_KEY); - localStorage.removeItem(LS_DM_KEY_EXPIRE); } - } - - if (type === 'directMessageThread') { - return storedContentDM; - } else { - return storedContent; - } -}; - -const setText = (content, threadType = '') => { - if (threadType === 'directMessageThread') { - localStorage && - localStorage.setItem(LS_DM_KEY, JSON.stringify(toJSON(content))); - localStorage && localStorage.setItem(LS_DM_KEY_EXPIRE, ONE_DAY()); - } else { - localStorage && - localStorage.setItem(LS_KEY, JSON.stringify(toJSON(content))); - localStorage && localStorage.setItem(LS_KEY_EXPIRE, ONE_DAY()); - } -}; - -const forcePersist = (content, threadType = '') => { - setText(content, threadType); -}; -const persistContent = debounce((content, threadType = '') => { - setText(content, threadType); -}, 500); - -class ChatInput extends React.Component { - state = { - isFocused: false, - photoSizeError: '', - code: false, - isSendingMediaMessage: false, - mediaPreview: '', - mediaPreviewFile: null, - markdownHint: false, }; - editor: any; - - componentDidMount() { - document.addEventListener('keydown', this.handleKeyDown, true); - this.props.onRef(this); - } - - shouldComponentUpdate(next, nextState) { - const curr = this.props; - const currState = this.state; - // User changed - if (curr.currentUser !== next.currentUser) return true; - - if (curr.networkOnline !== next.networkOnline) return true; - if (curr.websocketConnection !== next.websocketConnection) return true; - - if (curr.quotedMessage !== next.quotedMessage) return true; - - // State changed - if (curr.state !== next.state) return true; - if (currState.isSendingMediaMessage !== nextState.isSendingMediaMessage) - return true; - if (currState.mediaPreview !== nextState.mediaPreview) return true; - if (currState.photoSizeError !== nextState.photoSizeError) return true; - - return false; - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyDown); - this.props.onRef(undefined); - } - - componentDidUpdate(prevProps) { - const curr = this.props; - if (curr.quotedMessage !== prevProps.quotedMessage) { - this.triggerFocus(); - } - } - - handleKeyDown = (event: any) => { - const key = event.keyCode || event.charCode; - // Detect esc key or backspace key (and empty message) to remove - // the previewed image and quoted message - if ( - key === ESC || - ((key === BACKSPACE || key === DELETE) && - !this.props.state.getCurrentContent().hasText()) - ) { - this.removePreviewWrapper(); - this.removeQuotedMessage(); - } - }; - - removeQuotedMessage = () => { - if (this.props.quotedMessage) - this.props.dispatch( - replyToMessage({ threadId: this.props.thread, messageId: null }) - ); + const onChange = e => { + const text = e.target.value; + changeText(text); }; - onChange = (state, ...rest) => { - const { onChange, threadType } = this.props; - this.toggleMarkdownHint(state); - persistContent(state, threadType); - onChange(state, ...rest); - }; + const sendMessage = ({ file, body }: { file?: any, body?: string }) => { + // 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.thread === 'newDirectMessageThread') { + return props.createThread({ + messageType: file ? 'media' : 'text', + file, + messageBody: body, + }); + } - toggleMarkdownHint = state => { - // eslint-disable-next-line - let hasText = false; - // NOTE(@mxstbr): This throws an error on focus, so we just ignore that - try { - hasText = state.getCurrentContent().hasText(); - } catch (err) {} - this.setState({ - markdownHint: state.getCurrentContent().hasText() ? true : false, + const method = + props.threadType === 'story' + ? props.sendMessage + : props.sendDirectMessage; + return method({ + threadId: props.thread, + messageType: file ? 'media' : 'text', + threadType: props.threadType, + parentId: props.quotedMessage, + content: { + body, + }, + file, }); }; - triggerFocus = () => { - // NOTE(@mxstbr): This needs to be delayed for a tick, otherwise the - // decorators that are passed to the editor are removed from the editor - // state - setTimeout(() => { - this.editor && this.editor.focus && this.editor.focus(); - }, 0); - }; - - submit = e => { + const submit = async e => { if (e) e.preventDefault(); - const { - state, - thread, - threadType, - createThread, - dispatch, - sendMessage, - sendDirectMessage, - clear, - forceScrollToBottom, - networkOnline, - websocketConnection, - currentUser, - threadData, - refetchThread, - quotedMessage, - } = this.props; - - const isSendingMessageAsNonMember = - threadType === 'story' && - threadData && - !threadData.channel.channelPermissions.isMember; - - if (!networkOnline) { - return dispatch( + if (!props.networkOnline) { + return props.dispatch( addToastWithTimeout( 'error', 'Not connected to the internet - check your internet connection or try again' @@ -306,10 +175,10 @@ class ChatInput extends React.Component { } if ( - websocketConnection !== 'connected' && - websocketConnection !== 'reconnected' + props.websocketConnection !== 'connected' && + props.websocketConnection !== 'reconnected' ) { - return dispatch( + return props.dispatch( addToastWithTimeout( 'error', 'Error connecting to the server - hang tight while we try to reconnect' @@ -317,424 +186,185 @@ class ChatInput extends React.Component { ); } - if (!currentUser) { + if (!props.currentUser) { // user is trying to send a message without being signed in - return dispatch(openModal('CHAT_INPUT_LOGIN_MODAL', {})); + return props.dispatch(openModal('CHAT_INPUT_LOGIN_MODAL', {})); } - // This doesn't exist if this is a new conversation - if (forceScrollToBottom) { - // if a user sends a message, force a scroll to bottom - forceScrollToBottom(); - } - - if (this.state.mediaPreview.length) { - this.sendMediaMessage(this.state.mediaPreviewFile); - } - - // If the input is empty don't do anything - if (!state.getCurrentContent().hasText()) return 'handled'; - // do one last persist before sending - forcePersist(state, threadType); - this.removeQuotedMessage(); - - // 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 (thread === 'newDirectMessageThread') { - createThread({ - messageBody: !isAndroid() - ? JSON.stringify(toJSON(state)) - : toPlainText(state), - messageType: !isAndroid() ? 'draftjs' : 'text', - }); - localStorage.removeItem(LS_DM_KEY); - localStorage.removeItem(LS_DM_KEY_EXPIRE); - - clear(); - return 'handled'; - } + // If a user sends a message, force a scroll to bottom. This doesn't exist if this is a new DM thread + if (props.forceScrollToBottom) props.forceScrollToBottom(); - // user is sending a message to an existing thread id - either a thread - // or direct message thread - if (threadType === 'directMessageThread') { - sendDirectMessage({ - threadId: thread, - messageType: !isAndroid() ? 'draftjs' : 'text', - threadType, - parentId: quotedMessage, - content: { - body: !isAndroid() - ? JSON.stringify(toJSON(state)) - : toPlainText(state), - }, + if (mediaFile) { + setIsSendingMediaMessage(true); + if (props.forceScrollToBottom) props.forceScrollToBottom(); + await sendMessage({ + file: mediaFile, + body: '{"blocks":[],"entityMap":{}}', }) .then(() => { - localStorage.removeItem(LS_DM_KEY); - localStorage.removeItem(LS_DM_KEY_EXPIRE); + setIsSendingMediaMessage(false); + setMediaPreview(null); + setAttachedMediaFile(null); }) .catch(err => { - dispatch(addToastWithTimeout('error', err.message)); - }); - } else { - sendMessage({ - threadId: thread, - messageType: !isAndroid() ? 'draftjs' : 'text', - threadType, - parentId: quotedMessage, - content: { - body: !isAndroid() - ? JSON.stringify(toJSON(state)) - : toPlainText(state), - }, - }) - .then(() => { - // if the user sends a message as a non member of the community or - // channel, we need to refetch the thread to update any join buttons - // and update all clientside caching of community + channel permissions - if (isSendingMessageAsNonMember) { - if (refetchThread) { - refetchThread(); - } - } - - localStorage.removeItem(LS_KEY); - localStorage.removeItem(LS_KEY_EXPIRE); - }) - .catch(err => { - dispatch(addToastWithTimeout('error', err.message)); - }); - } - - // refocus the input - setTimeout(() => { - clear(); - this.editor && this.editor.focus && this.editor.focus(); - }); - - return 'handled'; - }; - - handleReturn = e => { - // Always submit on CMD+Enter - if (KeyBindingUtil.hasCommandModifier(e)) { - return this.submit(e); - } - - // SHIFT+Enter should always add a new line - if (e.shiftKey) return 'not-handled'; - - const currentContent = this.props.state.getCurrentContent(); - const selection = this.props.state.getSelection(); - const key = selection.getStartKey(); - const blockMap = currentContent.getBlockMap(); - const block = blockMap.get(key); - - // If we're in a code block or starting one don't submit on enter - if ( - block.get('type') === 'code-block' || - block.get('text').indexOf('```') === 0 - ) { - return 'not-handled'; - } - - return this.submit(e); - }; - - removePreviewWrapper = () => { - this.setState({ - mediaPreview: '', - mediaPreviewFile: null, - }); - }; - - sendMediaMessage = (file: ?Blob) => { - if (file == null) { - return; - } - - this.removePreviewWrapper(); - - // eslint-disable-next-line - let reader = new FileReader(); - - const { - thread, - threadType, - createThread, - dispatch, - forceScrollToBottom, - sendDirectMessage, - sendMessage, - websocketConnection, - networkOnline, - quotedMessage, - } = this.props; - - if (!networkOnline) { - return dispatch( - addToastWithTimeout( - 'error', - 'Not connected to the internet - check your internet connection or try again' - ) - ); - } - - if ( - websocketConnection !== 'connected' && - websocketConnection !== 'reconnected' - ) { - return dispatch( - addToastWithTimeout( - 'error', - 'Error connecting to the server - hang tight while we try to reconnect' - ) - ); - } - - this.setState({ - isSendingMediaMessage: true, - }); - - reader.onloadend = () => { - if (forceScrollToBottom) { - forceScrollToBottom(); - } - - if (thread === 'newDirectMessageThread') { - return createThread({ - messageType: 'media', - file, + setIsSendingMediaMessage(false); + props.dispatch(addToastWithTimeout('error', err.message)); }); - } - - if (threadType === 'directMessageThread') { - sendDirectMessage({ - threadId: thread, - messageType: 'media', - threadType, - parentId: quotedMessage, - content: { - body: reader.result, - }, - file, - }) - .then(() => { - this.setState({ - isSendingMediaMessage: false, - }); - }) - .catch(err => { - this.setState({ - isSendingMediaMessage: false, - }); - dispatch(addToastWithTimeout('error', err.message)); - }); - } else { - sendMessage({ - threadId: thread, - messageType: 'media', - threadType, - parentId: quotedMessage, - content: { - body: reader.result, - }, - file, - }) - .then(() => { - this.setState({ - isSendingMediaMessage: false, - }); - }) - .catch(err => { - this.setState({ - isSendingMediaMessage: false, - }); - dispatch(addToastWithTimeout('error', err.message)); - }); - } - }; - - if (file) { - reader.readAsDataURL(file); } - }; - onFocus = () => { - /* - The new direct message thread component needs to know if the chat input is focused. That component passes down an onFocus prop, which should be called if it exists - */ - const { onFocus } = this.props; - if (onFocus) { - onFocus(); - } - - this.setState({ - isFocused: true, - }); - }; - - onBlur = () => { - /* - The new direct message thread component needs to know if the chat input is focused. That component passes down an onBlur prop, which should be called if it exists - */ - const { onBlur } = this.props; - if (onBlur) { - onBlur(); - } - - this.setState({ - isFocused: false, - }); - }; + if (text.length === 0) return; + + sendMessage({ body: text }) + .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.threadData && + !props.threadData.channel.channelPermissions.isMember && + props.refetchThread + ) { + return props.refetchThread(); + } + }) + .catch(err => { + props.dispatch(addToastWithTimeout('error', err.message)); + }); - clearError = () => { - this.setState({ photoSizeError: '' }); + // Clear the chat input now that we're sending a message for sure + onChange({ target: { value: '' } }); + removeQuotedMessage(); }; - setMediaMessageError = (error: string) => { - return this.setState({ - photoSizeError: error, - }); - }; + // $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(); - previewMedia = blob => { - if (this.state.isSendingMediaMessage) { - return; - } - this.setState({ - isSendingMediaMessage: true, - mediaPreviewFile: blob, - }); const reader = new FileReader(); - reader.onload = () => - this.setState({ - mediaPreview: reader.result.toString(), - isSendingMediaMessage: false, - }); + reader.onload = () => { + setMediaPreview(reader.result.toString()); + setIsSendingMediaMessage(false); + }; if (blob) { reader.readAsDataURL(blob); } }; - render() { - const { - state, - currentUser, - networkOnline, - websocketConnection, - quotedMessage, - thread, - } = this.props; - const { - isFocused, - photoSizeError, - isSendingMediaMessage, - mediaPreview, - markdownHint, - } = this.state; - const networkDisabled = - !networkOnline || - (websocketConnection !== 'connected' && - websocketConnection !== 'reconnected'); + const removeQuotedMessage = () => { + if (props.quotedMessage) + props.dispatch( + replyToMessage({ threadId: props.thread, messageId: null }) + ); + }; - return ( - - - {photoSizeError && ( - -

{photoSizeError}

- this.clearError()} - glyph="view-close" - size={16} - color={'warn.default'} - /> -
+ const networkDisabled = + !props.networkOnline || + (props.websocketConnection !== 'connected' && + props.websocketConnection !== 'reconnected'); + + return ( + + + {photoSizeError && ( + +

{photoSizeError}

+ setPhotoSizeError('')} + glyph="view-close" + size={16} + color={'warn.default'} + /> +
+ )} + + {props.currentUser && ( + setPhotoSizeError(err)} + /> )} - - {currentUser && ( - - )} -
+ + + {mediaPreview && ( + + + setMediaPreview(null)}> + + + + )} + {props.quotedMessage && ( + + + + + + + )} (this.editor = editor)} - editorKey="chat-input" - decorators={[mentionsDecorator, linksDecorator]} + hasAttachment={!!props.quotedMessage || !!mediaPreview} networkDisabled={networkDisabled} - hasAttachment={!!mediaPreview || !!quotedMessage} - > - {mediaPreview && ( - - - - - - - )} - {quotedMessage && ( - - - - - - - )} - - { + if (props.onRef) props.onRef(node); + setInputRef(node); + }} /> - -
-
- - *bold* - _italic_ - `code` - ```codeblock``` - -
- ); - } -} + + + + +
+ 0} data-cy="markdownHint"> + **bold** + *italic* + `code` + ```codeblock``` + +
+ ); +}; const map = (state, ownProps) => ({ websocketConnection: state.connectionStatus.websocketConnection, networkOnline: state.connectionStatus.networkOnline, quotedMessage: state.message.quotedMessage[ownProps.thread] || null, }); + export default compose( withCurrentUser, sendMessage, sendDirectMessage, // $FlowIssue - connect(map), - withState('state', 'changeState', props => { - return returnText(props.threadType) || fromPlainText(''); - }), - withHandlers({ - onChange: ({ changeState }) => state => changeState(state), - clear: ({ changeState }) => () => changeState(fromPlainText('')), - }) + connect(map) )(ChatInput); diff --git a/src/components/chatInput/input.js b/src/components/chatInput/input.js deleted file mode 100644 index f9433088e5..0000000000 --- a/src/components/chatInput/input.js +++ /dev/null @@ -1,124 +0,0 @@ -// @flow -import React from 'react'; -import DraftEditor from '../draft-js-plugins-editor'; -import createLinkifyPlugin from 'draft-js-linkify-plugin'; -import createCodeEditorPlugin from 'draft-js-code-editor-plugin'; -import createMarkdownPlugin from 'draft-js-markdown-plugin'; -import Prism from 'prismjs'; -import 'prismjs/components/prism-java'; -import 'prismjs/components/prism-scala'; -import 'prismjs/components/prism-go'; -import 'prismjs/components/prism-sql'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-c'; -import 'prismjs/components/prism-cpp'; -import 'prismjs/components/prism-kotlin'; -import 'prismjs/components/prism-perl'; -import 'prismjs/components/prism-ruby'; -import 'prismjs/components/prism-swift'; -import createPrismPlugin from 'draft-js-prism-plugin'; -import { customStyleMap } from 'src/components/rich-text-editor/style'; -import type { DraftEditorState } from 'draft-js/lib/EditorState'; - -import { InputWrapper } from './style'; - -type Props = { - editorState: DraftEditorState, - onChange: DraftEditorState => void, - placeholder: string, - className?: string, - focus?: boolean, - readOnly?: boolean, - editorRef?: any => void, - networkDisabled: boolean, - children?: React$Node, - hasAttachment?: boolean, - code?: boolean, -}; - -type State = { - plugins: Array, -}; - -/* - * NOTE(@mxstbr): DraftJS has huge troubles on Android, it's basically unusable - * We work around this by replacing the DraftJS editor with a plain text Input - * on Android, and then converting the plain text to DraftJS content State - * debounced every couple ms - */ -class Input extends React.Component { - editor: any; - - constructor(props: Props) { - super(props); - - this.state = { - plugins: [ - createPrismPlugin({ - prism: Prism, - }), - createMarkdownPlugin({ - features: { - inline: ['BOLD', 'ITALIC', 'CODE'], - block: ['CODE', 'blockquote'], - }, - renderLanguageSelect: () => null, - }), - createCodeEditorPlugin(), - createLinkifyPlugin({ - target: '_blank', - }), - ], - }; - } - - setRef = (editor: any) => { - const { editorRef } = this.props; - this.editor = editor; - if (editorRef && typeof editorRef === 'function') editorRef(editor); - }; - - render() { - const { - editorState, - onChange, - focus, - placeholder, - readOnly, - editorRef, - networkDisabled, - children, - hasAttachment, - code, - ...rest - } = this.props; - const { plugins } = this.state; - - return ( - - {children} - - - ); - } -} - -export default Input; diff --git a/src/components/chatInput/style.js b/src/components/chatInput/style.js index 9cfa5c1f28..8957bed550 100644 --- a/src/components/chatInput/style.js +++ b/src/components/chatInput/style.js @@ -1,6 +1,8 @@ // @flow +import React from 'react'; import theme from 'shared/theme'; import styled, { css } from 'styled-components'; +import Textarea from 'react-textarea-autosize'; import { IconButton } from '../buttons'; import { QuoteWrapper } from '../message/style'; import { @@ -10,7 +12,6 @@ import { zIndex, monoStack, } from 'src/components/globals'; -import { Wrapper as EditorWrapper } from '../rich-text-editor/style'; export const ChatInputContainer = styled(FlexRow)` flex: none; @@ -57,18 +58,15 @@ export const Form = styled.form` position: relative; `; -export const InputWrapper = styled(EditorWrapper)` +export const InputWrapper = styled.div` display: flex; flex-direction: column; align-items: stretch; flex: auto; - font-size: 15px; - font-weight: 500; - line-height: 20px; - min-height: 40px; - max-width: calc(100% - 32px); padding: ${props => (props.hasAttachment ? '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 => @@ -85,45 +83,69 @@ export const InputWrapper = styled(EditorWrapper)` ? hexa(props.theme.special.default, 0.1) : props.theme.bg.default}; + &:hover, + &:focus { + border-color: ${props => + props.networkDisabled + ? props.theme.special.default + : props.theme.text.alt}; + transition: border-color 0.2s ease-in; + } + @media (max-width: 768px) { - font-size: 16px; padding-left: 16px; - ${/* width: calc(100% - 72px); */ ''}; + } +`; + +export const Input = styled( + ({ hasAttachment, networkDisabled, dataCy, ...rest }) => ( +