diff --git a/api/mutations/files/index.js b/api/mutations/files/index.js new file mode 100644 index 0000000000..f2dec2e74f --- /dev/null +++ b/api/mutations/files/index.js @@ -0,0 +1,8 @@ +// @flow +import uploadImage from './uploadImage'; + +module.exports = { + Mutation: { + uploadImage, + }, +}; diff --git a/api/mutations/files/uploadImage.js b/api/mutations/files/uploadImage.js new file mode 100644 index 0000000000..fa0486952b --- /dev/null +++ b/api/mutations/files/uploadImage.js @@ -0,0 +1,23 @@ +// @flow +import { isAuthedResolver } from '../../utils/permissions'; +import { uploadImage } from '../../utils/file-storage'; +import type { EntityTypes } from 'shared/types'; +import type { GraphQLContext } from '../../'; +import type { FileUpload } from 'shared/types'; +import { signImageUrl } from 'shared/imgix'; + +type Args = { + input: { + image: FileUpload, + type: EntityTypes, + id?: string, + }, +}; + +export default isAuthedResolver( + async (_: void, { input }: Args, { loaders }: GraphQLContext) => { + const { image, type, id } = input; + const url = await uploadImage(image, type, id || 'draft'); + return await signImageUrl(url); + } +); diff --git a/api/mutations/thread/publishThread.js b/api/mutations/thread/publishThread.js index afd5a907ab..381e64487d 100644 --- a/api/mutations/thread/publishThread.js +++ b/api/mutations/thread/publishThread.js @@ -1,11 +1,17 @@ // @flow const debug = require('debug')('api:mutations:thread:publish-thread'); import stringSimilarity from 'string-similarity'; -import { convertToRaw } from 'draft-js'; +import { + convertToRaw, + convertFromRaw, + EditorState, + SelectionState, +} from 'draft-js'; import { stateFromMarkdown } from 'draft-js-import-markdown'; import type { GraphQLContext } from '../../'; import UserError from '../../utils/UserError'; import { uploadImage } from '../../utils/file-storage'; +import processThreadContent from 'shared/draft-utils/process-thread-content'; import { publishThread, editThread, @@ -68,22 +74,11 @@ export default requireAuth( ); } - if (type === 'TEXT') { - type = 'DRAFTJS'; - if (thread.content.body) { - thread.content.body = JSON.stringify( - convertToRaw( - stateFromMarkdown(thread.content.body, { - parserOptions: { - breaks: true, - }, - }) - ) - ); - } + if (thread.content.body) { + thread.content.body = processThreadContent(type, thread.content.body); } - thread.type = type; + thread.type = 'DRAFTJS'; const [ currentUserChannelPermissions, diff --git a/api/schema.js b/api/schema.js index 9ecc93730d..ec1ada63fb 100644 --- a/api/schema.js +++ b/api/schema.js @@ -56,6 +56,7 @@ const notificationMutations = require('./mutations/notification'); const userMutations = require('./mutations/user'); const metaMutations = require('./mutations/meta'); const communityMemberMutations = require('./mutations/communityMember'); +const fileMutations = require('./mutations/files'); const messageSubscriptions = require('./subscriptions/message'); const notificationSubscriptions = require('./subscriptions/notification'); @@ -125,6 +126,7 @@ const resolvers = merge( userMutations, metaMutations, communityMemberMutations, + fileMutations, // subscriptions messageSubscriptions, notificationSubscriptions, diff --git a/api/types/general.js b/api/types/general.js index 24a37ee63c..0caf9a1eab 100644 --- a/api/types/general.js +++ b/api/types/general.js @@ -76,6 +76,24 @@ const general = /* GraphQL */ ` createdAt: String status: String } + + enum EntityTypes { + communities + channels + users + threads + } + + input UploadImageInput { + image: Upload! + type: EntityTypes! + id: String + } + + extend type Mutation { + uploadImage(input: UploadImageInput!): String + @rateLimit(max: 20, window: "20m") + } `; module.exports = general; diff --git a/cypress/integration/channel/view/composer_spec.js b/cypress/integration/channel/view/composer_spec.js index f04a79633c..cf42bd8b6c 100644 --- a/cypress/integration/channel/view/composer_spec.js +++ b/cypress/integration/channel/view/composer_spec.js @@ -33,7 +33,7 @@ describe('renders composer for logged in members', () => { cy.get('[data-cy="thread-composer-placeholder"]').click(); - cy.get('[data-cy="thread-composer"]').should('be.visible'); + cy.get('[data-cy="rich-text-editor"]').should('be.visible'); }); }); diff --git a/cypress/integration/community/view/profile_spec.js b/cypress/integration/community/view/profile_spec.js index 46a474e61f..c1f14586ae 100644 --- a/cypress/integration/community/view/profile_spec.js +++ b/cypress/integration/community/view/profile_spec.js @@ -268,7 +268,8 @@ describe('private community signed in with permissions', () => { .filter(channel => !channel.isPrivate) .filter(channel => !channel.deletedAt) .forEach(channel => { - cy.contains(channel.name) + cy.get('[data-cy="channel-list"]') + .contains(channel.name) .scrollIntoView() .should('be.visible'); }); diff --git a/cypress/integration/thread/action_bar_spec.js b/cypress/integration/thread/action_bar_spec.js index 45a1481b2c..643b0bcb26 100644 --- a/cypress/integration/thread/action_bar_spec.js +++ b/cypress/integration/thread/action_bar_spec.js @@ -189,7 +189,7 @@ describe('action bar renders', () => { cy.get('[data-cy="thread-dropdown-edit"]').click(); cy.get('[data-cy="save-thread-edit-button"]').should('be.visible'); const title = 'Some new thread'; - cy.get('[data-cy="rich-text-editor"]').should('be.visible'); + cy.get('[data-cy="rich-text-editor"].markdown').should('be.visible'); cy.get('[data-cy="thread-editor-title-input"]') .clear() .type(title); @@ -201,7 +201,7 @@ describe('action bar renders', () => { cy.get('[data-cy="thread-dropdown-edit"]').click(); cy.get('[data-cy="save-thread-edit-button"]').should('be.visible'); const originalTitle = 'The first thread! πŸŽ‰'; - cy.get('[data-cy="rich-text-editor"]').should('be.visible'); + cy.get('[data-cy="rich-text-editor"].markdown').should('be.visible'); cy.get('[data-cy="thread-editor-title-input"]') .clear() .type(originalTitle); diff --git a/cypress/integration/thread_spec.js b/cypress/integration/thread_spec.js index 27456ffcd5..c9de67c3ef 100644 --- a/cypress/integration/thread_spec.js +++ b/cypress/integration/thread_spec.js @@ -456,7 +456,7 @@ describe('/new/thread', () => { cy.get('[data-cy="composer-channel-selector"]').should('be.visible'); // Type title and body cy.get('[data-cy="composer-title-input"]').type(title); - cy.get('[contenteditable="true"]').type(body); + cy.get('[data-cy="rich-text-editor"]').type(body); cy.get('[data-cy="composer-publish-button"]').click(); cy.location('pathname').should('contain', 'thread'); cy.get('[data-cy="thread-view"]'); @@ -472,12 +472,12 @@ describe('/new/thread', () => { cy.get('[data-cy="composer-channel-selector"]').should('be.visible'); // Type title and body cy.get('[data-cy="composer-title-input"]').type(title); - cy.get('[contenteditable="true"]').type(body); + cy.get('[data-cy="rich-text-editor"]').type(body); /////need time as our localstorage is not set cy.wait(1000); cy.reload(); cy.get('[data-cy="composer-title-input"]').contains(title); - cy.get('[contenteditable="true"]').contains(body); + cy.get('[data-cy="rich-text-editor"]').contains(body); }); }); diff --git a/flow-typed/npm/react-dropzone_vx.x.x.js b/flow-typed/npm/react-dropzone_vx.x.x.js new file mode 100644 index 0000000000..57b7ee6318 --- /dev/null +++ b/flow-typed/npm/react-dropzone_vx.x.x.js @@ -0,0 +1,116 @@ +// flow-typed signature: a5a6c5ef2495cde307215470bb3dd2b5 +// flow-typed version: <>/react-dropzone_v8.0.3/flow_v0.66.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'react-dropzone' + * + * 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 'react-dropzone' { + 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 'react-dropzone/commitlint.config' { + declare module.exports: any; +} + +declare module 'react-dropzone/dist/es/index' { + declare module.exports: any; +} + +declare module 'react-dropzone/dist/es/utils/index' { + declare module.exports: any; +} + +declare module 'react-dropzone/dist/es/utils/styles' { + declare module.exports: any; +} + +declare module 'react-dropzone/dist/index' { + declare module.exports: any; +} + +declare module 'react-dropzone/rollup.config' { + declare module.exports: any; +} + +declare module 'react-dropzone/src/index' { + declare module.exports: any; +} + +declare module 'react-dropzone/src/index.spec' { + declare module.exports: any; +} + +declare module 'react-dropzone/src/utils/index' { + declare module.exports: any; +} + +declare module 'react-dropzone/src/utils/index.spec' { + declare module.exports: any; +} + +declare module 'react-dropzone/src/utils/styles' { + declare module.exports: any; +} + +declare module 'react-dropzone/styleguide.config' { + declare module.exports: any; +} + +declare module 'react-dropzone/testSetup' { + declare module.exports: any; +} + +// Filename aliases +declare module 'react-dropzone/commitlint.config.js' { + declare module.exports: $Exports<'react-dropzone/commitlint.config'>; +} +declare module 'react-dropzone/dist/es/index.js' { + declare module.exports: $Exports<'react-dropzone/dist/es/index'>; +} +declare module 'react-dropzone/dist/es/utils/index.js' { + declare module.exports: $Exports<'react-dropzone/dist/es/utils/index'>; +} +declare module 'react-dropzone/dist/es/utils/styles.js' { + declare module.exports: $Exports<'react-dropzone/dist/es/utils/styles'>; +} +declare module 'react-dropzone/dist/index.js' { + declare module.exports: $Exports<'react-dropzone/dist/index'>; +} +declare module 'react-dropzone/rollup.config.js' { + declare module.exports: $Exports<'react-dropzone/rollup.config'>; +} +declare module 'react-dropzone/src/index.js' { + declare module.exports: $Exports<'react-dropzone/src/index'>; +} +declare module 'react-dropzone/src/index.spec.js' { + declare module.exports: $Exports<'react-dropzone/src/index.spec'>; +} +declare module 'react-dropzone/src/utils/index.js' { + declare module.exports: $Exports<'react-dropzone/src/utils/index'>; +} +declare module 'react-dropzone/src/utils/index.spec.js' { + declare module.exports: $Exports<'react-dropzone/src/utils/index.spec'>; +} +declare module 'react-dropzone/src/utils/styles.js' { + declare module.exports: $Exports<'react-dropzone/src/utils/styles'>; +} +declare module 'react-dropzone/styleguide.config.js' { + declare module.exports: $Exports<'react-dropzone/styleguide.config'>; +} +declare module 'react-dropzone/testSetup.js' { + declare module.exports: $Exports<'react-dropzone/testSetup'>; +} diff --git a/package.json b/package.json index 899f82c0d8..2f2b5684dd 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "draft-js-export-markdown": "^1.3.0", "draft-js-focus-plugin": "^2.2.0", "draft-js-image-plugin": "^2.0.6", - "draft-js-import-markdown": "^1.3.0", + "draft-js-import-markdown": "^1.3.1", "draft-js-linkify-plugin": "^2.0.0-beta1", "draft-js-markdown-plugin": "^3.0.5", "draft-js-plugins-editor": "^2.1.1", @@ -156,6 +156,7 @@ "react-app-rewired": "^1.6.2", "react-clipboard.js": "^2.0.1", "react-dom": "^16.7.0-alpha.2", + "react-dropzone": "^8.0.3", "react-flip-move": "^3.0.2", "react-helmet-async": "^0.1.0", "react-image": "^1.5.1", @@ -220,7 +221,7 @@ "start:api": "cross-env NODE_ENV=production node build-api/main.js", "dev:web": "cross-env NODE_PATH=./ react-app-rewired start", "dev:api": "cross-env FILE_STORAGE=local cross-env NODE_PATH=./ cross-env NODE_ENV=development cross-env DEBUG=build*,api*,shared:rethinkdb:db-query-cache,-api:resolvers cross-env DIR=api backpack", - "dev:api:s3": "cross-env FILE_STORAGE=s3 cross-env NODE_PATH=./ cross-env NODE_ENV=development cross-env DEBUG=build*,api*,shared:middlewares*,shared:rethinkdb:db-query-cache,-api:resolvers cross-env DIR=api backpack", + "dev:api:s3": "cross-env FILE_STORAGE=s3 cross-env NODE_PATH=./ cross-env NODE_ENV=development cross-env DEBUG=build*,api*,shared:rethinkdb:db-query-cache,-api:resolvers cross-env DIR=api backpack", "dev:athena": "cross-env NODE_PATH=./ cross-env NODE_ENV=development cross-env DEBUG=build*,athena*,shared:middlewares*,-athena:resolvers cross-env DIR=athena backpack", "dev:hermes": "cross-env NODE_PATH=./ cross-env NODE_ENV=development cross-env DEBUG=build*,hermes*,shared:middlewares*,-hermes:resolvers cross-env DIR=hermes backpack", "dev:chronos": "cross-env NODE_PATH=./ cross-env NODE_ENV=development cross-env DEBUG=build*,chronos*,shared:middlewares*,-chronos:resolvers cross-env DIR=chronos backpack", diff --git a/shared/clients/draft-js/links-decorator/core.js b/shared/clients/draft-js/links-decorator/core.js index 5e1253b2b2..8c43daa584 100644 --- a/shared/clients/draft-js/links-decorator/core.js +++ b/shared/clients/draft-js/links-decorator/core.js @@ -3,10 +3,13 @@ import React from 'react'; import linkStrategy from 'draft-js-linkify-plugin/lib/linkStrategy'; import normalizeUrl from '../../../normalize-url'; import type { ContentBlock } from 'draft-js/lib/ContentBlock'; +import type { ContentState } from 'draft-js/lib/ContentState'; import type { ComponentType, Node } from 'react'; type DecoratorComponentProps = { decoratedText: string, + contentState: ContentState, + entityKey?: string, children?: Node, }; @@ -21,15 +24,34 @@ const createLinksDecorator = ( ) => ({ strategy: ( contentBlock: ContentBlock, - callback: (...args?: Array) => any + callback: (...args?: Array) => any, + contentState: ContentState ) => { if (contentBlock.type === 'code-block') return; + if (contentBlock.findEntityRanges) { + contentBlock.findEntityRanges(char => { + const entityKey = char.getEntity(); + return ( + entityKey !== null && + contentState.getEntity(entityKey).getType() === 'LINK' + ); + }, callback); + } linkStrategy(contentBlock, callback); }, - component: ({ decoratedText, children }: DecoratorComponentProps) => ( + component: ({ + decoratedText, + children, + entityKey, + contentState, + }: DecoratorComponentProps) => ( { + const raw = convertToRaw(editorState.getCurrentContent()); + let newEditorState = editorState; + raw.blocks.forEach(block => { + if (block.type !== 'unstyled') return; + const embeds = getEmbedsFromText(block.text); + if (embeds.length > 0) { + embeds.forEach(embed => { + const selection = SelectionState.createEmpty(block.key); + newEditorState = addEmbedToEditorState(newEditorState, embed); + }); + } + }); + return newEditorState; +}; + +// Taken from https://github.com/vacenz/last-draft-js-plugins/blob/master/draft-js-embed-plugin/src/modifiers/addEmbed.js +// adapted to pass additional attrs onto the iframe +export const addEmbedToEditorState = ( + editorState: typeof EditorState, + attrs: AddEmbedAttrs +) => { + const urlType = 'embed'; + const entityKey = Entity.create(urlType, 'IMMUTABLE', { + src: (attrs && attrs.url) || null, + aspectRatio: attrs && attrs.aspectRatio, + width: attrs && attrs.width, + height: attrs && attrs.height, + }); + const newEditorState = AtomicBlockUtils.insertAtomicBlock( + editorState, + entityKey, + ' ' + ); + // insertAtomicBlock inserts _before_ the current block, which is not what we want, + // so we have to manually move the new atomic block one further down + const content = newEditorState.getCurrentContent(); + let newBlocks = []; + let moveNext; + content.getBlocksAsArray().forEach(block => { + if (!moveNext) { + newBlocks.push(block); + } else { + newBlocks = [ + ...newBlocks.slice(0, newBlocks.length - 2), + block, + ...newBlocks.slice(newBlocks.length - 2), + ]; + } + if (block.type === 'atomic' && block.getEntityAt(0) === entityKey) { + // move the automatically added empty block above the atomic block below the atomic block + newBlocks = [ + ...newBlocks.slice(0, newBlocks.length - 2), + newBlocks[newBlocks.length - 1], + newBlocks[newBlocks.length - 2], + ]; + moveNext = true; + } + }); + const newContent = ContentState.createFromBlockArray(newBlocks); + return EditorState.push(newEditorState, newContent, 'insert-characters'); +}; + +// Utility function to return the first capturing group of each unique match +// of a regex in a string +const match = (regex: RegExp, text: string) => { + const matches = text.match(regex); + if (!matches) return []; + return [...new Set(matches)].map(match => { + return regex.exec(match)[1]; + }); +}; + +export const getEmbedsFromText = (text: string): Array => { + let embeds = []; + + match(IFRAME_TAG, text).forEach(url => { + embeds.push({ url }); + }); + + match(FIGMA_URLS, text).forEach(url => { + embeds.push({ + url: `https://www.figma.com/embed?embed_host=spectrum&url=${url}`, + aspectRatio: '56.25%', // 16:9 aspect ratio + }); + }); + + match(YOUTUBE_URLS, text).forEach(id => { + embeds.push({ + url: `https://www.youtube.com/embed/${id}`, + aspectRatio: '56.25%', // 16:9 aspect ratio + }); + }); + + match(VIMEO_URLS, text).forEach(id => { + embeds.push({ + url: `https://player.vimeo.com/video/${id}`, + aspectRatio: '56.25%', // 16:9 aspect ratio + }); + }); + + match(FRAMER_URLS, text).forEach(url => { + embeds.push({ + url: `https://${url}`, + width: 600, + height: 800, + }); + }); + + match(CODEPEN_URLS, text).forEach(path => { + embeds.push({ + url: `https://codepen.io${path.replace(/(pen|full|details)/, 'embed')}`, + height: 300, + }); + }); + + match(CODESANDBOX_URLS, text).forEach(path => { + embeds.push({ + url: `https://codesandbox.io${path.replace('/s/', '/embed/')}`, + height: 500, + }); + }); + + return embeds; +}; diff --git a/shared/draft-utils.js b/shared/draft-utils/index.js similarity index 100% rename from shared/draft-utils.js rename to shared/draft-utils/index.js diff --git a/shared/draft-utils/process-thread-content.js b/shared/draft-utils/process-thread-content.js new file mode 100644 index 0000000000..647f50b38d --- /dev/null +++ b/shared/draft-utils/process-thread-content.js @@ -0,0 +1,53 @@ +// @flow +import { stateFromMarkdown } from 'draft-js-import-markdown'; +import { convertFromRaw, convertToRaw, EditorState } from 'draft-js'; +import { addEmbedsToEditorState } from './add-embeds-to-draft-js'; + +export default (type: 'TEXT' | 'DRAFTJS', body: string): string => { + let newBody = body; + if (type === 'TEXT') { + newBody = JSON.stringify( + convertToRaw( + stateFromMarkdown(newBody, { + customBlockFn: elem => { + if (elem.nodeName !== 'PRE') return; + + const code = elem.childNodes.find(node => node.nodeName === 'CODE'); + if (!code) return; + + const className = code.attributes.find( + ({ name }) => name === 'class' + ); + if (!className) return; + + const lang = className.value.replace('lang-', ''); + + return { + type: null, + data: { + language: lang, + }, + }; + }, + parserOptions: { + atomicImages: true, + breaks: true, + }, + }) + ) + ); + } + + // Add automatic embeds to body + try { + const editorState = EditorState.createWithContent( + convertFromRaw(JSON.parse(newBody)) + ); + const newEditorState = addEmbedsToEditorState(editorState); + return JSON.stringify(convertToRaw(newEditorState.getCurrentContent())); + // Ignore errors during automatic embed detection + } catch (err) { + console.error(err); + return newBody; + } +}; diff --git a/shared/draft-utils/test/add-embeds-to-draft-js.test.js b/shared/draft-utils/test/add-embeds-to-draft-js.test.js new file mode 100644 index 0000000000..ea23cf291d --- /dev/null +++ b/shared/draft-utils/test/add-embeds-to-draft-js.test.js @@ -0,0 +1,316 @@ +// @flow +import { getEmbedsFromText } from '../add-embeds-to-draft-js'; + +describe('sites', () => { + describe('