diff --git a/.circleci/config.yml b/.circleci/config.yml index bf042dea93..04a96486f0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,4 +1,5 @@ -# CircleCI configuration for Spectrum +# CircleCI configuration for Spectrum +version: 2.1 # Aliases aliases: @@ -63,7 +64,6 @@ js_defaults: &js_defaults docker: - image: circleci/node:8 -version: 2 jobs: # Set up environment and install required dependencies @@ -72,9 +72,9 @@ jobs: steps: - checkout - - restore-cache: *restore-yarn-cache + - restore_cache: *restore-yarn-cache - run: *yarn - - save-cache: *save-yarn-cache + - save_cache: *save-yarn-cache - persist_to_workspace: root: . @@ -112,15 +112,19 @@ jobs: # Start db and servers, then run e2e and unit tests - test_e2e: + test_integration: <<: *defaults docker: - image: circleci/node:8-browsers - image: redis:3.2.7 - image: cypress/base:6 - image: rethinkdb:2.3.5 - parallel: true - parallelism: 9 + parameters: + parallelism: + type: integer + default: 1 + description: Number of boxes to use to run this job + parallelism: <> environment: TERM: xterm steps: @@ -144,6 +148,14 @@ jobs: yarn run test:e2e fi + # This runs after the above and is only here to hack around missing support for varying jobs based on external vs internal PRs + # See https://github.com/withspectrum/spectrum/pull/4820 + test_e2e: + docker: + - image: cypress/base:10 + steps: + - run: echo "pass" + # Run eslint, flow etc. test_static_js: <<: *js_defaults @@ -158,8 +170,6 @@ jobs: command: yarn run lint workflows: - version: 2 - test: jobs: - checkout_environment @@ -172,6 +182,26 @@ workflows: - build_web: requires: - checkout_environment - - test_e2e: + # Run pull requests from internal contributors in parallel + - test_integration: + name: test_e2e_internal + requires: + - build_web + parallelism: 4 + filters: + branches: + ignore: /pull.*/ + # Run pull requests from external contributors on one machine + - test_integration: + name: test_e2e_external requires: - build_web + parallelism: 1 + filters: + branches: + only: /pull.*/ + # If either of the test_e2e_* jobs pass, this one passes so we can mark it as required on GitHub + - test_e2e: + requires: + - test_e2e_internal + - test_e2e_external diff --git a/api/mutations/directMessageThread/createDirectMessageThread.js b/api/mutations/directMessageThread/createDirectMessageThread.js index 4b39981dcd..ab05c7710a 100644 --- a/api/mutations/directMessageThread/createDirectMessageThread.js +++ b/api/mutations/directMessageThread/createDirectMessageThread.js @@ -17,8 +17,8 @@ import type { FileUpload } from 'shared/types'; import { events } from 'shared/analytics'; import { trackQueue } from 'shared/bull/queues'; import { isAuthedResolver as requireAuth } from '../../utils/permissions'; -import { messageTypeObj } from 'shared/draft-utils/process-message-content'; -import type { MessageType } from 'shared/draft-utils/process-message-content'; +import { messageTypeObj } from 'shared/draft-utils/message-types'; +import type { MessageType } from 'shared/draft-utils/message-types'; export type CreateDirectMessageThreadInput = { input: { diff --git a/api/mutations/message/addMessage.js b/api/mutations/message/addMessage.js index 453ccb32af..e0d6030c68 100644 --- a/api/mutations/message/addMessage.js +++ b/api/mutations/message/addMessage.js @@ -22,7 +22,7 @@ import { validateRawContentState } from '../../utils/validate-draft-js-input'; import processMessageContent, { messageTypeObj, } from 'shared/draft-utils/process-message-content'; -import type { MessageType } from 'shared/draft-utils/process-message-content'; +import type { MessageType } from 'shared/draft-utils/message-types'; type Input = { message: { @@ -34,6 +34,7 @@ type Input = { }, parentId?: string, file?: FileUpload, + bot?: boolean, }, }; diff --git a/api/mutations/message/editMessage.js b/api/mutations/message/editMessage.js index 9303a7d1a9..c7aa471493 100644 --- a/api/mutations/message/editMessage.js +++ b/api/mutations/message/editMessage.js @@ -19,7 +19,7 @@ import { validateRawContentState } from '../../utils/validate-draft-js-input'; import processMessageContent, { messageTypeObj, } from 'shared/draft-utils/process-message-content'; -import type { MessageType } from 'shared/draft-utils/process-message-content'; +import type { MessageType } from 'shared/draft-utils/message-types'; type Args = { input: { diff --git a/api/mutations/thread/publishThread.js b/api/mutations/thread/publishThread.js index 65f3c65b85..58d66a1aa0 100644 --- a/api/mutations/thread/publishThread.js +++ b/api/mutations/thread/publishThread.js @@ -348,22 +348,24 @@ export default requireAuth( await createParticipantInThread(dbThread.id, user.id); // Post a new message with a link to the new thread to the watercooler thread if one exists - // if (community.watercoolerId && !channel.isPrivate) { - // const identifier = user.username ? `@${user.username}` : user.name; - // await addMessage( - // { - // content: { - // body: `${identifier} just posted a new thread ๐Ÿ“ https://spectrum.chat/${ - // community.slug - // }/${channel.slug}/${slugg(dbThread.content.title)}~${dbThread.id}`, - // }, - // messageType: 'text', - // threadId: community.watercoolerId, - // threadType: 'story', - // }, - // 'sam' - // ); - // } + if (community.watercoolerId && !channel.isPrivate) { + await addMessage( + { + content: { + body: `${ + user.name + } just posted a new thread ๐Ÿ“ https://spectrum.chat/${ + community.slug + }/${channel.slug}/${slugg(dbThread.content.title)}~${dbThread.id}`, + }, + messageType: 'text', + threadId: community.watercoolerId, + threadType: 'story', + bot: true, + }, + user.id + ); + } if (!thread.filesToUpload || thread.filesToUpload.length === 0) { return dbThread; diff --git a/api/package.json b/api/package.json index 3d7a3e0019..5885242c6f 100644 --- a/api/package.json +++ b/api/package.json @@ -122,7 +122,7 @@ "sanitize-filename": "^1.6.1", "serialize-javascript": "^1.6.1", "session-rethinkdb": "^2.0.0", - "slate": "^0.44.10", + "slate": "^0.44.11", "slate-markdown": "0.1.0", "slugg": "^1.1.0", "string-replace-to-array": "^1.0.3", diff --git a/api/queries/thread/channel.js b/api/queries/thread/channel.js index 16d7013ce4..12fb461368 100644 --- a/api/queries/thread/channel.js +++ b/api/queries/thread/channel.js @@ -2,5 +2,16 @@ import type { GraphQLContext } from '../../'; import type { DBThread } from 'shared/types'; -export default ({ channelId }: DBThread, _: any, { loaders }: GraphQLContext) => - loaders.channel.load(channelId); +export default async (root: DBThread, _: any, ctx: GraphQLContext) => { + const { channelId, id } = root; + const { loaders } = ctx; + const channel = await loaders.channel.load(channelId); + if (!channel) { + console.error( + 'User queried thread of non-existent/deleted channel: ', + channelId + ); + console.error('Thread queried: ', id); + } + return channel; +}; diff --git a/api/queries/thread/community.js b/api/queries/thread/community.js index 03eda41434..758156671d 100644 --- a/api/queries/thread/community.js +++ b/api/queries/thread/community.js @@ -2,8 +2,16 @@ import type { GraphQLContext } from '../../'; import type { DBThread } from 'shared/types'; -export default ( - { communityId }: DBThread, - _: any, - { loaders }: GraphQLContext -) => loaders.community.load(communityId); +export default async (root: DBThread, _: any, ctx: GraphQLContext) => { + const { communityId, id } = root; + const { loaders } = ctx; + const community = await loaders.community.load(communityId); + if (!community) { + console.error( + 'User queried thread of non-existent/deleted community: ', + communityId + ); + console.error('Thread queried: ', id); + } + return community; +}; diff --git a/api/types/Message.js b/api/types/Message.js index db768f4fbf..341b450b6e 100644 --- a/api/types/Message.js +++ b/api/types/Message.js @@ -30,6 +30,7 @@ const Message = /* GraphQL */ ` messageType: MessageTypes! parent: Message modifiedAt: Date + bot: Boolean sender: User! @deprecated(reason: "Use Message.author field instead") } diff --git a/api/yarn.lock b/api/yarn.lock index 2403fcf5fb..dc4f09c981 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -8726,10 +8726,10 @@ slate-markdown@0.1.0: react "^0.14.0 || ^15.0.0" styled-components "^2.0.0" -slate@^0.44.10: - version "0.44.10" - resolved "https://registry.yarnpkg.com/slate/-/slate-0.44.10.tgz#ac7c3e3cf85570a8723a64a8a7807c881ddbfa8a" - integrity sha512-2jMPgOjExjeWfHrYUsYTMLo/ykbXjCwLUFwG3G34Q0vwRsmf4yOf3b0Zx5LoUaNlyvQDdBsOGsF1qi6yTx53DA== +slate@^0.44.11: + version "0.44.11" + resolved "https://registry.yarnpkg.com/slate/-/slate-0.44.11.tgz#0cdf454726a6d45a6dbf3174ea3f6261ad1a0428" + integrity sha512-ZtNCxHtGMuWXwUb6vmbrLR73MbHeEQ1LRJNIIyEpj1Kixk0Sd0T+zSKI+cBWCXlOOITqFEfBcJOWaSppiID+1A== dependencies: debug "^3.1.0" direction "^0.1.5" diff --git a/athena/queues/new-message-in-thread/index.js b/athena/queues/new-message-in-thread/index.js index 47f658eacb..2a68d03fb8 100644 --- a/athena/queues/new-message-in-thread/index.js +++ b/athena/queues/new-message-in-thread/index.js @@ -36,6 +36,9 @@ export default async (job: Job) => { }` ); + // Do not send notification emails for bot messages + if (incomingMessage.bot) return; + // Check to see if an existing notif exists by matching the 'event' type, with the context of the notification, within a certain time period. const existing = await checkForExistingNotification( 'MESSAGE_CREATED', diff --git a/flow-typed/npm/snarkdown_vx.x.x.js b/flow-typed/npm/snarkdown_vx.x.x.js new file mode 100644 index 0000000000..96b404c0a3 --- /dev/null +++ b/flow-typed/npm/snarkdown_vx.x.x.js @@ -0,0 +1,53 @@ +// flow-typed signature: 8f063755e65f032485439ac4cefffded +// flow-typed version: <>/snarkdown_vx.x.x/flow_v0.66.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'snarkdown' + * + * 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 'snarkdown' { + 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 'snarkdown/dist/snarkdown.es' { + declare module.exports: any; +} + +declare module 'snarkdown/dist/snarkdown' { + declare module.exports: any; +} + +declare module 'snarkdown/dist/snarkdown.umd' { + declare module.exports: any; +} + +declare module 'snarkdown/src/index' { + declare module.exports: any; +} + +// Filename aliases +declare module 'snarkdown/dist/snarkdown.es.js' { + declare module.exports: $Exports<'snarkdown/dist/snarkdown.es'>; +} +declare module 'snarkdown/dist/snarkdown.js' { + declare module.exports: $Exports<'snarkdown/dist/snarkdown'>; +} +declare module 'snarkdown/dist/snarkdown.umd.js' { + declare module.exports: $Exports<'snarkdown/dist/snarkdown.umd'>; +} +declare module 'snarkdown/src/index.js' { + declare module.exports: $Exports<'snarkdown/src/index'>; +} diff --git a/package.json b/package.json index 70dc9a1d21..a3efcd5df5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Spectrum", - "version": "2.8.3", + "version": "2.8.4", "license": "BSD-3-Clause", "devDependencies": { "@babel/preset-flow": "^7.0.0", @@ -194,6 +194,7 @@ "slate": "^0.20.1", "slate-markdown": "0.1.1", "slugg": "^1.1.0", + "snarkdown": "^1.2.2", "stopword": "^0.1.13", "string-replace-to-array": "^1.0.3", "string-similarity": "^2.0.0", @@ -293,4 +294,4 @@ ] }, "pre-commit": "lint:staged" -} \ No newline at end of file +} diff --git a/shared/clients/draft-js/utils/isShort.js b/shared/clients/draft-js/utils/isShort.js index 588fc68017..25f822494d 100644 --- a/shared/clients/draft-js/utils/isShort.js +++ b/shared/clients/draft-js/utils/isShort.js @@ -1,14 +1,9 @@ // @flow -// Determine whether a DraftJS message is short -import { toPlainText, toState } from '../../../draft-utils'; - -import type { RawDraftContentState } from 'draft-js'; +import { toPlainText } from './plaintext'; import type { MessageInfoType } from '../../../graphql/fragments/message/messageInfo'; export const isShort = (message: MessageInfoType): boolean => { if (message.messageType === 'media') return false; const jsonBody = JSON.parse(message.content.body); - return ( - jsonBody.blocks.length <= 1 && toPlainText(toState(jsonBody)).length <= 170 - ); + return jsonBody.blocks.length <= 1 && toPlainText(jsonBody).length <= 170; }; diff --git a/shared/clients/draft-js/utils/plaintext.js b/shared/clients/draft-js/utils/plaintext.js new file mode 100644 index 0000000000..a533f86fc4 --- /dev/null +++ b/shared/clients/draft-js/utils/plaintext.js @@ -0,0 +1,9 @@ +// @flow +import type { RawContentState } from 'draft-js'; + +export const toPlainText = (raw: RawContentState) => { + return raw.blocks + .filter(block => block.type === 'unstyled') + .map(block => block.text) + .join('\n'); +}; diff --git a/shared/draft-utils/add-embeds-to-draft-js.js b/shared/draft-utils/add-embeds-to-draft-js.js index b711233ecf..3e675451a6 100644 --- a/shared/draft-utils/add-embeds-to-draft-js.js +++ b/shared/draft-utils/add-embeds-to-draft-js.js @@ -34,7 +34,7 @@ export const addEmbedsToEditorState = ( if (block.type !== 'unstyled') return; const embeds = getEmbedsFromText(block.text); - if (!embeds.length === 0) return; + if (embeds.length === 0) return; embeds.forEach(embed => { lastEntityKey++; @@ -64,19 +64,6 @@ export const addEmbedsToEditorState = ( type: 'embed', }; }); - // If this is the last block we need to add an empty block below the atomic block, - // otherwise users cannot remove the embed during editing - if (index === input.blocks.length - 1) { - newBlocks.push({ - type: 'unstyled', - data: {}, - text: ' ', - depth: 0, - entityRanges: [], - inlineStyleRanges: [], - key: genKey(), - }); - } }); return { diff --git a/shared/draft-utils/message-types.js b/shared/draft-utils/message-types.js new file mode 100644 index 0000000000..9506be4bef --- /dev/null +++ b/shared/draft-utils/message-types.js @@ -0,0 +1,8 @@ +// @flow + +export const messageTypeObj = { + text: 'text', + media: 'media', + draftjs: 'draftjs', +}; +export type MessageType = $Keys; diff --git a/shared/draft-utils/process-message-content.js b/shared/draft-utils/process-message-content.js index eff34835fa..6cdeb23735 100644 --- a/shared/draft-utils/process-message-content.js +++ b/shared/draft-utils/process-message-content.js @@ -2,13 +2,9 @@ import { stateFromMarkdown } from 'draft-js-import-markdown'; import { convertFromRaw, convertToRaw, EditorState } from 'draft-js'; import { addEmbedsToEditorState } from './add-embeds-to-draft-js'; +import { messageTypeObj, type MessageType } from './message-types'; -export const messageTypeObj = { - text: 'text', - media: 'media', - draftjs: 'draftjs', -}; -export type MessageType = $Keys; +export { messageTypeObj }; export default (type: MessageType, body: string): string => { let newBody = body; diff --git a/shared/draft-utils/process-thread-content.js b/shared/draft-utils/process-thread-content.js index 5e1679a680..1b21528ee5 100644 --- a/shared/draft-utils/process-thread-content.js +++ b/shared/draft-utils/process-thread-content.js @@ -3,12 +3,12 @@ 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 => { +export default (type: 'TEXT' | 'DRAFTJS', body: ?string): string => { let newBody = body; if (type === 'TEXT') { // workaround react-mentions bug by replacing @[username] with @username // @see withspectrum/spectrum#4587 - newBody = newBody.replace(/@\[([a-z0-9_-]+)\]/g, '@$1'); + newBody = newBody ? newBody.replace(/@\[([a-z0-9_-]+)\]/g, '@$1') : ''; newBody = JSON.stringify( convertToRaw( stateFromMarkdown(newBody, { @@ -43,10 +43,10 @@ export default (type: 'TEXT' | 'DRAFTJS', body: string): string => { // Add automatic embeds to body try { - return JSON.stringify(addEmbedsToEditorState(JSON.parse(newBody))); + return JSON.stringify(addEmbedsToEditorState(JSON.parse(newBody || ''))); // Ignore errors during automatic embed detection } catch (err) { console.error(err); - return newBody; + return newBody || ''; } }; diff --git a/shared/draft-utils/test/__snapshots__/add-embeds-to-draft-js.test.js.snap b/shared/draft-utils/test/__snapshots__/add-embeds-to-draft-js.test.js.snap new file mode 100644 index 0000000000..9136aa209e --- /dev/null +++ b/shared/draft-utils/test/__snapshots__/add-embeds-to-draft-js.test.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should add embeds 1`] = ` +Object { + "blocks": Array [ + Object { + "data": Object {}, + "depth": 0, + "entityRanges": Array [], + "inlineStyleRanges": Array [], + "key": "g0000", + "text": "https://simplecast.com/s/a1f11d11", + "type": "unstyled", + }, + Object { + "data": Object {}, + "depth": 0, + "entityRanges": Array [ + Object { + "key": -Infinity, + "length": 1, + "offset": 0, + }, + ], + "inlineStyleRanges": Array [], + "key": "0", + "text": " ", + "type": "atomic", + }, + ], + "entityMap": Object { + "-Infinity": Object { + "data": Object { + "height": 200, + "src": "https://embed.simplecast.com/a1f11d11", + "url": "https://embed.simplecast.com/a1f11d11", + }, + "mutability": "MUTABLE", + "type": "embed", + }, + }, +} +`; 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 index 59dff64139..a148713256 100644 --- a/shared/draft-utils/test/add-embeds-to-draft-js.test.js +++ b/shared/draft-utils/test/add-embeds-to-draft-js.test.js @@ -1,5 +1,13 @@ // @flow -import { getEmbedsFromText } from '../add-embeds-to-draft-js'; +jest.mock('draft-js/lib/generateRandomKey', () => { + let last = 0; + const multiplier = Math.exp(24); + return () => Math.floor(last++ * multiplier).toString(32); +}); +import { + getEmbedsFromText, + addEmbedsToEditorState, +} from '../add-embeds-to-draft-js'; describe('sites', () => { describe('