diff --git a/api/loaders/channel.js b/api/loaders/channel.js index a22c672c28..c62fdfe823 100644 --- a/api/loaders/channel.js +++ b/api/loaders/channel.js @@ -7,7 +7,6 @@ import { import { getChannelsSettings } from '../models/channelSettings'; import createLoader from './create-loader'; import { getPendingUsersInChannels } from '../models/usersChannels'; -import type { Loader } from './types'; export const __createChannelLoader = createLoader(channels => getChannels(channels) diff --git a/api/loaders/index.js b/api/loaders/index.js index eab83ff918..007c826eaf 100644 --- a/api/loaders/index.js +++ b/api/loaders/index.js @@ -37,6 +37,7 @@ import { } from './directMessageThread'; import { __createReactionLoader } from './reaction'; import { __createStripeCustomersLoader } from './stripe'; +import { __createMessageLoader } from './message'; import type { DataLoaderOptions } from './types'; // Create all the necessary loaders to be attached to the GraphQL context for each request @@ -70,6 +71,7 @@ const createLoaders = (options?: DataLoaderOptions) => ({ directMessageThread: __createDirectMessageThreadLoader(options), directMessageParticipants: __createDirectMessageParticipantsLoader(options), directMessageSnippet: __createDirectMessageSnippetLoader(options), + message: __createMessageLoader(options), messageReaction: __createReactionLoader(options), }); diff --git a/api/loaders/message.js b/api/loaders/message.js new file mode 100644 index 0000000000..6950a52a01 --- /dev/null +++ b/api/loaders/message.js @@ -0,0 +1,14 @@ +// @flow +import { getManyMessages } from '../models/message'; +import createLoader from './create-loader'; +import type { Loader } from './types'; + +export const __createMessageLoader = createLoader((messages: string[]) => + getManyMessages(messages) +); + +export default () => { + throw new Error( + '⚠️ Do not import loaders directly, get them from the GraphQL context instead! ⚠️' + ); +}; diff --git a/api/loaders/user.js b/api/loaders/user.js index dd474ab552..1e8b518247 100644 --- a/api/loaders/user.js +++ b/api/loaders/user.js @@ -12,7 +12,6 @@ import { import { getUsersPermissionsInChannels } from '../models/usersChannels'; import { getThreadsNotificationStatusForUsers } from '../models/usersThreads'; import createLoader from './create-loader'; -import type { Loader } from './types'; export const __createUserLoader = createLoader(users => getUsers(users), 'id'); diff --git a/api/models/communitySettings.js b/api/models/communitySettings.js index 201247c77c..4220fc47a4 100644 --- a/api/models/communitySettings.js +++ b/api/models/communitySettings.js @@ -31,11 +31,12 @@ export const getCommunitiesSettings = ( .getAll(...communityIds, { index: 'communityId' }) .run() .then(data => { - if (!data || data.length === 0) + if (!data || data.length === 0) { return Array.from({ length: communityIds.length }, (_, index) => ({ ...defaultSettings, communityId: communityIds[index], })); + } if (data.length === communityIds.length) { return data.map( @@ -50,12 +51,23 @@ export const getCommunitiesSettings = ( } if (data.length < communityIds.length) { - return communityIds.map(community => { - const record = data.find(o => o.communityId === community); + return communityIds.map(communityId => { + const record = data.find(o => o.communityId === communityId); + if (record) return record; + return { + ...defaultSettings, + communityId, + }; + }); + } + + if (data.length > communityIds.length) { + return communityIds.map(communityId => { + const record = data.find(o => o.communityId === communityId); if (record) return record; return { ...defaultSettings, - communityId: community, + communityId, }; }); } diff --git a/api/models/message.js b/api/models/message.js index 9931a0dcae..2a0d749496 100644 --- a/api/models/message.js +++ b/api/models/message.js @@ -11,7 +11,6 @@ import { createChangefeed } from 'shared/changefeed-utils'; import { setThreadLastActive } from './thread'; export type MessageTypes = 'text' | 'media'; -// TODO: Fix this export type Message = Object; export const getMessage = (messageId: string): Promise => { @@ -25,6 +24,16 @@ export const getMessage = (messageId: string): Promise => { }); }; +export const getManyMessages = (messageIds: string[]): Promise => { + return db + .table('messages') + .getAll(...messageIds) + .run() + .then(messages => { + return messages.filter(message => message && !message.deletedAt); + }); +}; + type BackwardsPaginationOptions = { last?: number, before?: number | Date }; const getBackwardsMessages = ( diff --git a/api/mutations/message/addMessage.js b/api/mutations/message/addMessage.js index baf7c0fb6e..3fd56bb947 100644 --- a/api/mutations/message/addMessage.js +++ b/api/mutations/message/addMessage.js @@ -4,7 +4,7 @@ import { EditorState } from 'draft-js'; import type { GraphQLContext } from '../../'; import UserError from '../../utils/UserError'; import { uploadImage } from '../../utils/file-storage'; -import { storeMessage } from '../../models/message'; +import { storeMessage, getMessage } from '../../models/message'; import { setDirectMessageThreadLastActive } from '../../models/directMessageThread'; import { setUserLastSeenInDirectMessageThread } from '../../models/usersDirectMessageThreads'; import { createMemberInChannel } from '../../models/usersChannels'; @@ -25,6 +25,7 @@ type AddMessageInput = { content: { body: string, }, + parentId?: string, file?: FileUpload, }, }; @@ -86,6 +87,12 @@ export default async ( } } + if (message.parentId) { + const parent = await getMessage(message.parentId); + if (parent.threadId !== message.threadId) + throw new UserError('You can only quote messages from the same thread.'); + } + // construct the shape of the object to be stored in the db let messageForDb = Object.assign({}, message); if (message.file && message.messageType === 'media') { diff --git a/api/queries/community/brandedLogin.js b/api/queries/community/brandedLogin.js index 35c11f2066..f7c4b37d6a 100644 --- a/api/queries/community/brandedLogin.js +++ b/api/queries/community/brandedLogin.js @@ -1,6 +1,5 @@ // @flow import type { DBCommunity } from 'shared/types'; -import { getCommunitySettings } from '../../models/communitySettings'; import type { GraphQLContext } from '../../'; export default async ( diff --git a/api/queries/message/index.js b/api/queries/message/index.js index c384830190..341105ebf2 100644 --- a/api/queries/message/index.js +++ b/api/queries/message/index.js @@ -5,6 +5,7 @@ import sender from './sender'; import author from './author'; import thread from './thread'; import reactions from './reactions'; +import parent from './parent'; module.exports = { Query: { @@ -16,5 +17,6 @@ module.exports = { sender, // deprecated thread, reactions, + parent, }, }; diff --git a/api/queries/message/parent.js b/api/queries/message/parent.js new file mode 100644 index 0000000000..ff8263d5b2 --- /dev/null +++ b/api/queries/message/parent.js @@ -0,0 +1,12 @@ +// @flow +import type { DBMessage } from 'shared/types'; +import type { GraphQLContext } from '../../'; + +export default ( + { parentId }: DBMessage, + _: void, + { loaders }: GraphQLContext +) => { + if (!parentId) return null; + return loaders.message.load(parentId); +}; diff --git a/api/queries/message/rootMessage.js b/api/queries/message/rootMessage.js index 97bde58b2b..45de31a9c0 100644 --- a/api/queries/message/rootMessage.js +++ b/api/queries/message/rootMessage.js @@ -1,4 +1,6 @@ // @flow import { getMessage } from '../../models/message'; +import type { GraphQLContext } from '../../'; -export default (_: any, { id }: { id: string }) => getMessage(id); +export default (_: any, { id }: { id: string }, { loaders }: GraphQLContext) => + loaders.message.load(id); diff --git a/api/types/Message.js b/api/types/Message.js index ff4c0e04a3..daf89fd269 100644 --- a/api/types/Message.js +++ b/api/types/Message.js @@ -28,6 +28,7 @@ const Message = /* GraphQL */ ` author: ThreadParticipant! @cost(complexity: 2) reactions: ReactionData @cost(complexity: 1) messageType: MessageTypes! + parent: Message sender: User! @deprecated(reason:"Use Message.author field instead") } @@ -41,6 +42,7 @@ const Message = /* GraphQL */ ` threadType: ThreadTypes! messageType: MessageTypes! content: MessageContentInput! + parentId: String file: Upload } diff --git a/athena/queues/new-message-in-thread/index.js b/athena/queues/new-message-in-thread/index.js index 88ed9cc676..73e59a5da7 100644 --- a/athena/queues/new-message-in-thread/index.js +++ b/athena/queues/new-message-in-thread/index.js @@ -19,8 +19,11 @@ import { import { getThreadNotificationUsers } from '../../models/usersThreads'; import { getUserPermissionsInChannel } from '../../models/usersChannels'; import { getUserPermissionsInCommunity } from '../../models/usersCommunities'; +import { getUserById } from '../../models/user'; +import { getMessageById } from '../../models/message'; import { sendMentionNotificationQueue } from 'shared/bull/queues'; import type { MessageNotificationJobData, Job } from 'shared/bull/types'; +import type { DBMessage } from 'shared/types'; export default async (job: Job) => { const { message: incomingMessage } = job.data; @@ -97,7 +100,25 @@ export default async (job: Job) => { : incomingMessage.content.body; // get mentions in the message - const mentions = getMentions(body); + let mentions = getMentions(body); + // If the message quoted another message, send a mention notification to the author + // of the quoted message + if (typeof incomingMessage.parentId === 'string') { + // $FlowIssue + const parent = await getMessageById(incomingMessage.parentId); + // eslint-disable-next-line + (parent: DBMessage); + if (parent) { + const parentAuthor = await getUserById(parent.senderId); + if ( + parentAuthor && + parentAuthor.username && + mentions.indexOf(parentAuthor.username) < 0 + ) { + mentions.push(parentAuthor.username); + } + } + } if (mentions && mentions.length > 0) { mentions.forEach(username => { sendMentionNotificationQueue.add({ diff --git a/config-overrides.js b/config-overrides.js index 2d0fe39e08..37377c90de 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -16,6 +16,7 @@ const WriteFilePlugin = require('write-file-webpack-plugin'); const { ReactLoadablePlugin } = require('react-loadable/webpack'); const OfflinePlugin = require('offline-plugin'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); +const BundleBuddyWebpackPlugin = require('bundle-buddy-webpack-plugin'); // Recursively walk a folder and get all file paths function walkFolder(currentDirPath, callback) { @@ -147,6 +148,9 @@ module.exports = function override(config, env) { }) ); } + if (process.env.BUNDLE_BUDDY === 'true') { + config.plugins.push(new BundleBuddyWebpackPlugin()); + } if (process.env.NODE_ENV === 'development') { config.plugins.push( WriteFilePlugin({ @@ -155,5 +159,13 @@ module.exports = function override(config, env) { }) ); } + config.plugins.push( + new webpack.optimize.CommonsChunkPlugin({ + minChunks: 3, + name: 'main', + async: 'commons', + children: true, + }) + ); return rewireStyledComponents(config, env, { ssr: true }); }; diff --git a/cypress/integration/messages_spec.js b/cypress/integration/messages_spec.js new file mode 100644 index 0000000000..5cc7e4b99a --- /dev/null +++ b/cypress/integration/messages_spec.js @@ -0,0 +1,25 @@ +import data from '../../shared/testing/data'; + +const thread = data.threads[0]; +const community = data.communities.find( + community => community.id === thread.communityId +); +const author = data.users.find(user => user.id === thread.creatorId); + +describe('/messages/new', () => { + beforeEach(() => { + cy.auth(author.id); + cy.visit('/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.wait(2000); + // Reload page(incase page closed or crashed ,reload should have same effect) + cy.reload(); + cy.get('[contenteditable="true"]').contains(newMessage); + }); +}); diff --git a/cypress/integration/thread/chat_input_spec.js b/cypress/integration/thread/chat_input_spec.js index 3d96106012..adedbcefa4 100644 --- a/cypress/integration/thread/chat_input_spec.js +++ b/cypress/integration/thread/chat_input_spec.js @@ -83,6 +83,51 @@ describe('chat input', () => { cy.get('[contenteditable="true"]').type(''); 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="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); + }); + }); + + describe('message attachments', () => { + beforeEach(() => { + cy.auth(memberInChannelUser.id); + cy.visit(`/thread/${publicThread.id}`); + }); + + it('should allow quoting a message', () => { + // Quote a message + cy.get('[data-cy="staged-quoted-message"]').should('not.be.visible'); + cy + .get('[data-cy="message"]') + .first() + .should('be.visible') + .click(); + cy + .get('[data-cy="reply-to-message"]') + .first() + .should('be.visible') + .click(); + cy + .get('[data-cy="reply-to-message"]') + .first() + .should('not.be.visible'); + cy.get('[data-cy="staged-quoted-message"]').should('be.visible'); + // Remove quoted message again + cy + .get('[data-cy="remove-staged-quoted-message"]') + .should('be.visible') + .click(); + cy.get('[data-cy="staged-quoted-message"]').should('not.be.visible'); + }); }); // NOTE(@mxstbr): This fails in CI, but not locally for some reason diff --git a/cypress/integration/thread_spec.js b/cypress/integration/thread_spec.js index adab0ed6e9..b5617e2ac8 100644 --- a/cypress/integration/thread_spec.js +++ b/cypress/integration/thread_spec.js @@ -126,4 +126,21 @@ describe('/new/thread', () => { cy.contains(title); cy.contains(body); }); + + it('should allow to continue composing thread incase of crash or reload', () => { + const title = 'Persist Title'; + const body = 'with some persisting content'; + cy.get('[data-cy="rich-text-editor"]').should('be.visible'); + cy.get('[data-cy="composer-community-selector"]').should('be.visible'); + 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); + /////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); + }); }); diff --git a/docs/api/graphql/fragments.md b/docs/api/graphql/fragments.md index 8220688a74..7fa955ca6f 100644 --- a/docs/api/graphql/fragments.md +++ b/docs/api/graphql/fragments.md @@ -146,7 +146,7 @@ const getStory = gql` ...frequencyInfo } } - ${userInfoFragment} + ${storyInfoFragment} ${frequencyInfoFragment} `; ``` diff --git a/docs/operations/deleting-users.md b/docs/operations/deleting-users.md index b7c408bd8b..da131e4c15 100644 --- a/docs/operations/deleting-users.md +++ b/docs/operations/deleting-users.md @@ -13,7 +13,7 @@ Follow these steps to safely delete a user from Spectrum: 2a. If the user owns communities, please try to convince them not to delete their account. 2b. If they *really* want to delete their account, reach out to @brian to handle this 3. When it's confirmed that they don't own any communities, clear all necessary fields from the user record, and add a `deletedAt` field - this will trigger Vulcan to remove the user from search indexes -``` +```javascript r.db('spectrum') .table('users') .get(ID) @@ -39,7 +39,7 @@ r.db('spectrum') }) ``` 4. Remove that user as a member from all communities and channels: -``` +```javascript // usersCommunities .table('usersCommunities') .getAll(ID, { index: 'userId' }) @@ -61,7 +61,7 @@ r.db('spectrum') }) ``` 5. Remove all notifications from threads to save worker processing: -``` +```javascript // usersThreads .table('usersThreads') .getAll(ID, { index: 'userId' }) @@ -69,4 +69,4 @@ r.db('spectrum') receiveNotifications: false, }) ``` -6. Done! The user now can't be messaged, searched for, or re-logged into. The deleted user no longer affects community or channel member counts, and will not ever get pulled into Athena for notifications processing. \ No newline at end of file +6. Done! The user now can't be messaged, searched for, or re-logged into. The deleted user no longer affects community or channel member counts, and will not ever get pulled into Athena for notifications processing. diff --git a/docs/workers/intro.md b/docs/workers/intro.md index 877eeec7b2..ec445e1080 100644 --- a/docs/workers/intro.md +++ b/docs/workers/intro.md @@ -9,7 +9,7 @@ Our asynchronos background job processing is powered by a series of worker serve - [Hermes](hermes/intro.md): sends emails - [Mercury](mercury/intro.md): processes reputation events - [Pluto](pluto/intro.md): processes payments events -- [Vulan](vulcan/intro.md): indexes content for search +- [Vulcan](vulcan/intro.md): indexes content for search Each one of these can be run and developed independently with matching `npm run dev:x` and `npm run build:x` commands. (where `x` is the name of the server) @@ -28,4 +28,4 @@ As you can see we follow a loose naming scheme based on ancient Greek, Roman, an Many of our workers run off of our [Redis queue](background-jobs.md) to handle asynchronous events. -[Learn more about background jobs](background-jobs.md) \ No newline at end of file +[Learn more about background jobs](background-jobs.md) diff --git a/flow-typed/npm/react-native-keyboard-spacer_vx.x.x.js b/flow-typed/npm/react-native-keyboard-spacer_vx.x.x.js new file mode 100644 index 0000000000..c653aa1101 --- /dev/null +++ b/flow-typed/npm/react-native-keyboard-spacer_vx.x.x.js @@ -0,0 +1,24 @@ +// flow-typed signature: 80b0017129ba75aa9424b55d6795fdff +// flow-typed version: <>/react-native-keyboard-spacer_vx/flow_v0.66.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'react-native-keyboard-spacer' + * + * 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-native-keyboard-spacer' { + declare export type KeyboardSpacerProps = { + topSpacing?: number, + animationConfig?: Object, + onToggle?: (keyboardState: bool, keyboardSpace: number) => void, + }; + declare class KeyboardSpacer extends React$Component {} + declare module.exports: typeof KeyboardSpacer; +} diff --git a/mobile/.expo/settings.json b/mobile/.expo/settings.json index 86d79bb168..dfc36acefd 100644 --- a/mobile/.expo/settings.json +++ b/mobile/.expo/settings.json @@ -3,5 +3,5 @@ "lanType": "ip", "dev": true, "minify": false, - "urlRandomness": "v5-9z3" + "urlRandomness": "66-qcw" } \ No newline at end of file diff --git a/mobile/components/Anchor/index.js b/mobile/components/Anchor/index.js index b3f5b521ad..73869b4a2b 100644 --- a/mobile/components/Anchor/index.js +++ b/mobile/components/Anchor/index.js @@ -1,6 +1,6 @@ // @flow import React, { type Node } from 'react'; -import { Linking, Text } from 'react-native'; +import { Linking, Text, Share } from 'react-native'; import { WebBrowser } from 'expo'; type LinkProps = { @@ -13,13 +13,32 @@ type ButtonProps = { children: Node, }; -type Props = LinkProps | ButtonProps; +// Either URL or message has to be defined +export type ShareContent = + | { + url: string, + message?: string, + title?: string, + } + | { + url?: string, + message: string, + title?: string, + }; + +type ShareProps = { + content: ShareContent, + children: Node, +}; + +type Props = LinkProps | ButtonProps | ShareProps; export default class Anchor extends React.Component { handlePress = () => { if (typeof this.props.onPress === 'function') return this.props.onPress(); if (typeof this.props.href === 'string') return WebBrowser.openBrowserAsync(this.props.href); + if (this.props.content) return Share.share(this.props.content); }; render() { diff --git a/mobile/components/ChatInput/index.js b/mobile/components/ChatInput/index.js new file mode 100644 index 0000000000..6d9f972df7 --- /dev/null +++ b/mobile/components/ChatInput/index.js @@ -0,0 +1,48 @@ +// @flow +import React from 'react'; +import { TextInput, View, Button } from 'react-native'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; + +type Props = { + onSubmit: (text: string) => void, +}; + +type State = { + value: string, +}; + +class ChatInput extends React.Component { + state = { + value: '', + }; + + onChangeText = (value: string) => { + this.setState({ value }); + }; + + submit = () => { + this.props.onSubmit(this.state.value); + this.onChangeText(''); + }; + + render() { + return ( + + + + + + + + + )} + {showLinkPreview && + linkPreview && + linkPreview.loading && ( + + )} + {showLinkPreview && + linkPreview && + linkPreview.data && ( + + )} + + ); + } else { + return ( +
+ + { + this.editor = editor; + if (editorRef) editorRef(editor); + }} + readOnly={readOnly} + placeholder={!readOnly && placeholder} + spellCheck={true} + autoCapitalize="sentences" + autoComplete="on" + autoCorrect="on" + stripPastedStyles={true} + decorators={[mentionsDecorator]} + customStyleMap={customStyleMap} + {...rest} + /> + + {showLinkPreview && + linkPreview && + linkPreview.loading && ( + + )} + {showLinkPreview && + linkPreview && + linkPreview.data && ( + + )} + {!readOnly && ( + + + Add + + + )} +
+ ); + } + } +} + +export default Editor; diff --git a/src/components/rich-text-editor/index.js b/src/components/rich-text-editor/index.js index e9fb98ae28..56dfe6e16c 100644 --- a/src/components/rich-text-editor/index.js +++ b/src/components/rich-text-editor/index.js @@ -1,356 +1,12 @@ // @flow import React from 'react'; -import DraftEditor, { composeDecorators } from 'draft-js-plugins-editor'; -import createImagePlugin from 'draft-js-image-plugin'; -import createFocusPlugin from 'draft-js-focus-plugin'; -import createBlockDndPlugin from 'draft-js-drag-n-drop-plugin'; -import createMarkdownPlugin from 'draft-js-markdown-plugin'; -import createEmbedPlugin from 'draft-js-embed-plugin'; -import createLinkifyPlugin from 'draft-js-linkify-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 createCodeEditorPlugin from 'draft-js-code-editor-plugin'; -import OutsideClickHandler from '../outsideClickHandler'; -import Icon from '../icons'; -import { IconButton } from '../buttons'; -import mentionsDecorator from 'shared/clients/draft-js/mentions-decorator/index.web.js'; -import { renderLanguageSelect } from './LanguageSelect'; +import Loadable from 'react-loadable'; +import { Loading } from '../loading'; -import Image from './Image'; -import Embed, { addEmbed, parseEmbedUrl } from './Embed'; -import MediaInput from '../mediaInput'; -import SideToolbar from './toolbar'; -import { - Wrapper, - MediaRow, - ComposerBase, - Expander, - Action, - EmbedUI, - customStyleMap, -} from './style'; -import { LinkPreview, LinkPreviewLoading } from '../linkPreview'; +/* prettier-ignore */ +const RichTextEditor = Loadable({ + loader: () => import('./container'/* webpackChunkName: "RichTextEditor" */), + loading: ({ isLoading }) => isLoading && , +}); -type Props = { - state: Object, - onChange: Function, - showLinkPreview?: boolean, - linkPreview?: Object, - focus?: boolean, - readOnly?: boolean, - editorRef?: any => void, - placeholder?: string, - className?: string, - style?: Object, - version?: 2, -}; - -type State = { - plugins: Array, - addEmbed: (Object, string) => mixed, - addImage: (Object, string, ?Object) => mixed, - inserting: boolean, - embedding: boolean, - embedUrl: string, -}; - -class Editor extends React.Component { - editor: any; - - constructor(props: Props) { - super(props); - - const pluginState = this.getPluginState(props); - - this.state = { - ...pluginState, - inserting: false, - embedding: false, - embedUrl: '', - }; - } - - componentDidUpdate(prev: Props) { - if (prev.readOnly !== this.props.readOnly) { - this.setState({ - ...this.getPluginState(this.props), - }); - } - } - - getPluginState = (props: Props) => { - const focusPlugin = createFocusPlugin(); - const dndPlugin = createBlockDndPlugin(); - const linkifyPlugin = createLinkifyPlugin({ - target: '_blank', - }); - const embedPlugin = createEmbedPlugin({ - EmbedComponent: Embed, - }); - const prismPlugin = createPrismPlugin({ - prism: Prism, - }); - const codePlugin = createCodeEditorPlugin(); - - const decorator = composeDecorators( - focusPlugin.decorator, - dndPlugin.decorator - ); - - const imagePlugin = createImagePlugin({ - decorator, - imageComponent: Image, - }); - - return { - plugins: [ - imagePlugin, - prismPlugin, - embedPlugin, - createMarkdownPlugin({ - renderLanguageSelect: props.readOnly - ? () => null - : renderLanguageSelect, - }), - codePlugin, - linkifyPlugin, - dndPlugin, - focusPlugin, - ], - addImage: imagePlugin.addImage, - addEmbed: addEmbed, - }; - }; - - changeEmbedUrl = (evt: SyntheticInputEvent) => { - this.setState({ - embedUrl: evt.target.value, - }); - }; - - addEmbed = (evt: ?SyntheticUIEvent<>) => { - evt && evt.preventDefault(); - - const { state, onChange } = this.props; - onChange(this.state.addEmbed(state, parseEmbedUrl(this.state.embedUrl))); - this.closeToolbar(); - }; - - addImages = (files: FileList) => { - const { addImage } = this.state; - const { state, onChange } = this.props; - // Add images to editorState - // eslint-disable-next-line - for (var i = 0, file; (file = files[i]); i++) { - onChange(addImage(state, window.URL.createObjectURL(file), { file })); - } - }; - - addImage = (e: SyntheticInputEvent) => { - this.addImages(e.target.files); - this.closeToolbar(); - }; - - handleDroppedFiles = (_: any, files: FileList) => { - this.addImages(files); - }; - - toggleToolbarDisplayState = () => { - const { inserting } = this.state; - - this.setState({ - inserting: !inserting, - embedding: false, - }); - }; - - closeToolbar = () => { - this.setState({ - embedUrl: '', - embedding: false, - inserting: false, - }); - }; - - toggleEmbedInputState = () => { - const { embedding } = this.state; - - this.setState({ - embedding: !embedding, - inserting: false, - }); - }; - - render() { - const { - state, - onChange, - className, - style, - editorRef, - showLinkPreview, - linkPreview, - focus, - version, - placeholder, - readOnly, - ...rest - } = this.props; - const { embedding, inserting } = this.state; - - if (version === 2) { - return ( - - { - this.editor = editor; - if (editorRef) editorRef(editor); - }} - readOnly={readOnly} - placeholder={!readOnly && placeholder} - spellCheck={true} - autoCapitalize="sentences" - autoComplete="on" - autoCorrect="on" - stripPastedStyles={true} - decorators={[mentionsDecorator]} - customStyleMap={customStyleMap} - {...rest} - /> - {!readOnly && ( - - - - - - - - - - - - - - - - - )} - {showLinkPreview && - linkPreview && - linkPreview.loading && ( - - )} - {showLinkPreview && - linkPreview && - linkPreview.data && ( - - )} - - ); - } else { - return ( -
- - { - this.editor = editor; - if (editorRef) editorRef(editor); - }} - readOnly={readOnly} - placeholder={!readOnly && placeholder} - spellCheck={true} - autoCapitalize="sentences" - autoComplete="on" - autoCorrect="on" - stripPastedStyles={true} - decorators={[mentionsDecorator]} - customStyleMap={customStyleMap} - {...rest} - /> - - {showLinkPreview && - linkPreview && - linkPreview.loading && ( - - )} - {showLinkPreview && - linkPreview && - linkPreview.data && ( - - )} - {!readOnly && ( - - - Add - - - )} -
- ); - } - } -} - -export default Editor; +export default RichTextEditor; diff --git a/src/components/scrollRow/index.js b/src/components/scrollRow/index.js index c34f938f04..cbca631dc7 100644 --- a/src/components/scrollRow/index.js +++ b/src/components/scrollRow/index.js @@ -45,7 +45,7 @@ class ScrollRow extends Component { return ( this.hscroll = comp} + innerRef={comp => (this.hscroll = comp)} > {this.props.children} diff --git a/src/components/scrollRow/style.js b/src/components/scrollRow/style.js index f5020ab004..c746e9a428 100644 --- a/src/components/scrollRow/style.js +++ b/src/components/scrollRow/style.js @@ -2,14 +2,14 @@ import styled from 'styled-components'; import { FlexRow } from '../globals'; export const ScrollableFlexRow = styled(FlexRow)` - overflow-x: scroll; - flex-wrap: nowrap; - background: transparent; - cursor: pointer; - cursor: hand; - cursor: grab; + overflow-x: scroll; + flex-wrap: nowrap; + background: transparent; + cursor: pointer; + cursor: hand; + cursor: grab; - &:active { - cursor: grabbing; - } + &:active { + cursor: grabbing; + } `; diff --git a/src/components/theme/index.js b/src/components/theme/index.js index d70016a77d..e100ef6db8 100644 --- a/src/components/theme/index.js +++ b/src/components/theme/index.js @@ -37,8 +37,8 @@ export const theme = { alt: '#ea4335', }, github: { - default: '#1475DA', - alt: '#1475DA', + default: '#16171A', + alt: '#16171A', }, ph: { default: '#D85537', diff --git a/src/components/threadComposer/components/composer.js b/src/components/threadComposer/components/composer.js index 7ffde98ad4..254c3d7cc5 100644 --- a/src/components/threadComposer/components/composer.js +++ b/src/components/threadComposer/components/composer.js @@ -74,17 +74,33 @@ type State = { const LS_BODY_KEY = 'last-thread-composer-body'; const LS_TITLE_KEY = 'last-thread-composer-title'; +const LS_COMPOSER_EXPIRE = 'last-thread-composer-expire'; + +const ONE_DAY = () => new Date().getTime() + 60 * 60 * 24 * 1000; + +const REMOVE_STORAGE = () => { + localStorage.removeItem(LS_BODY_KEY); + localStorage.removeItem(LS_TITLE_KEY); + localStorage.removeItem(LS_COMPOSER_EXPIRE); +}; + let storedBody; let storedTitle; // We persist the body and title to localStorage // so in case the app crashes users don't loose content if (localStorage) { try { - storedBody = toState(JSON.parse(localStorage.getItem(LS_BODY_KEY) || '')); - storedTitle = localStorage.getItem(LS_TITLE_KEY); + const expireTime = localStorage.getItem(LS_COMPOSER_EXPIRE); + const currTime = new Date().getTime(); + /////if current time is greater than valid till of text then please expire title/body back to '' + if (currTime > expireTime) { + REMOVE_STORAGE(); + } else { + storedBody = toState(JSON.parse(localStorage.getItem(LS_BODY_KEY) || '')); + storedTitle = localStorage.getItem(LS_TITLE_KEY); + } } catch (err) { - localStorage.removeItem(LS_BODY_KEY); - localStorage.removeItem(LS_TITLE_KEY); + REMOVE_STORAGE(); } } @@ -92,12 +108,14 @@ const persistTitle = localStorage && debounce((title: string) => { localStorage.setItem(LS_TITLE_KEY, title); + localStorage.setItem(LS_COMPOSER_EXPIRE, ONE_DAY()); }, 500); const persistBody = localStorage && debounce(body => { localStorage.setItem(LS_BODY_KEY, JSON.stringify(toJSON(body))); + localStorage.setItem(LS_COMPOSER_EXPIRE, ONE_DAY()); }, 500); class ThreadComposerWithData extends React.Component { @@ -551,8 +569,7 @@ class ThreadComposerWithData extends React.Component { const id = data.publishThread.id; track('thread', 'published', null); - localStorage.removeItem(LS_TITLE_KEY); - localStorage.removeItem(LS_BODY_KEY); + REMOVE_STORAGE(); // stop the loading spinner on the publish button this.setState({ diff --git a/src/components/threadFeed/index.js b/src/components/threadFeed/index.js index 2831d9bea1..2ee4045e04 100644 --- a/src/components/threadFeed/index.js +++ b/src/components/threadFeed/index.js @@ -5,6 +5,7 @@ import compose from 'recompose/compose'; // NOTE(@mxstbr): This is a custom fork published of off this (as of this writing) unmerged PR: https://github.com/CassetteRocks/react-infinite-scroller/pull/38 // I literally took it, renamed the package.json and published to add support for scrollElement since our scrollable container is further outside import InfiniteList from 'src/components/infiniteScroll'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import { connect } from 'react-redux'; import Link from 'src/components/link'; import Icon from 'src/components/icons'; @@ -253,9 +254,7 @@ class ThreadFeedPure extends React.Component { ); } - const uniqueThreads = filteredThreads.filter( - (val, i, self) => self.indexOf(val) === i - ); + const uniqueThreads = deduplicateChildren(filteredThreads, 'id'); if (dataExists) { return ( diff --git a/src/components/threadFeedCard/formattedThreadLocation.js b/src/components/threadFeedCard/formattedThreadLocation.js index 149782ca7d..f855c6c7e4 100644 --- a/src/components/threadFeedCard/formattedThreadLocation.js +++ b/src/components/threadFeedCard/formattedThreadLocation.js @@ -34,34 +34,33 @@ const FormattedThreadLocation = props => { )} {(needsCommunityDetails || needsChannelDetails) && ( - - {needsCommunityDetails && ( - - {props.data.community.name} - - )} - {needsCommunityDetails && - needsChannelDetails && {' / '}} - {needsChannelDetails && ( - - {props.data.channel.isPrivate && ( - - - - )} - {props.data.channel.name} - - )} - - )} + + {needsCommunityDetails && ( + + {props.data.community.name} + + )} + {needsCommunityDetails && + needsChannelDetails && {' / '}} + {needsChannelDetails && ( + + {props.data.channel.isPrivate && ( + + + + )} + {props.data.channel.name} + + )} + + )} ); diff --git a/src/components/toasts/style.js b/src/components/toasts/style.js index 34624bfc27..aad2c88322 100644 --- a/src/components/toasts/style.js +++ b/src/components/toasts/style.js @@ -18,18 +18,18 @@ export const Container = styled.div` `; const toastFade = keyframes` - 0% { - opacity: 0; + 0% { + opacity: 0; top: 8px; - } - 5% { - opacity: 1; + } + 5% { + opacity: 1; top: 0; - } + } 95% { - opacity: 1; + opacity: 1; top: 0; - } + } 100% { opacity: 0; top: -4px; diff --git a/src/components/upsell/style.js b/src/components/upsell/style.js index 3dcacbec04..f694225f74 100644 --- a/src/components/upsell/style.js +++ b/src/components/upsell/style.js @@ -304,19 +304,19 @@ export const SigninButton = styled.a` ${props => props.after && ` - &:after { - content: 'Previously signed in with'; - position: absolute; - top: -32px; - font-size: 14px; - font-weight: 600; - left: 50%; - transform: translateX(-50%); - width: 100%; - text-align: center; - color: ${props.theme.text.alt}; - } - `} span { + &:after { + content: 'Previously signed in with'; + position: absolute; + top: -32px; + font-size: 14px; + font-weight: 600; + left: 50%; + transform: translateX(-50%); + width: 100%; + text-align: center; + color: ${props.theme.text.alt}; + } + `} span { display: inline-block; flex: 0 0 auto; margin-top: -1px; diff --git a/src/helpers/utils.js b/src/helpers/utils.js index b9efcfadc7..cfd072d512 100644 --- a/src/helpers/utils.js +++ b/src/helpers/utils.js @@ -66,13 +66,6 @@ export const draftOnlyContainsEmoji = (raw: Object) => raw.blocks.length === 1 && raw.blocks[0].type === 'unstyled' && onlyContainsEmoji(raw.blocks[0].text); -/** - * Encode a string to base64 (using the Node built-in Buffer) - * - * Stolen from http://stackoverflow.com/a/38237610/2115623 - */ -export const encode = (string: string) => - Buffer.from(string).toString('base64'); /* Best guess at if user is on a mobile device. Used in the modal components diff --git a/src/reducers/index.js b/src/reducers/index.js index 28c630e124..bd75e68e60 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,3 +1,4 @@ +// @flow import { combineReducers } from 'redux'; import users from './users'; import composer from './composer'; @@ -10,10 +11,12 @@ import newActivityIndicator from './newActivityIndicator'; import dashboardFeed from './dashboardFeed'; import threadSlider from './threadSlider'; import notifications from './notifications'; +import message from './message'; import connectionStatus from './connectionStatus'; +import type { Reducer } from 'redux'; // Allow dependency injection of extra reducers, we need this for SSR -const getReducers = extraReducers => { +const getReducers = (extraReducers: { [key: string]: Reducer<*, *> }) => { return combineReducers({ users, modals, @@ -27,6 +30,7 @@ const getReducers = extraReducers => { threadSlider, notifications, connectionStatus, + message, ...extraReducers, }); }; diff --git a/src/reducers/message.js b/src/reducers/message.js new file mode 100644 index 0000000000..3b43fb84fd --- /dev/null +++ b/src/reducers/message.js @@ -0,0 +1,22 @@ +// @flow +import type { ReplyToMessageActionType } from '../actions/message'; + +const initialState = { + quotedMessage: null, +}; + +type Actions = ReplyToMessageActionType; + +export default function message( + state: typeof initialState = initialState, + action: Actions +) { + switch (action.type) { + case 'REPLY_TO_MESSAGE': + return { + quotedMessage: action.messageId, + }; + default: + return state; + } +} diff --git a/src/routes.js b/src/routes.js index 2acd6c8cdc..d7b15c4530 100644 --- a/src/routes.js +++ b/src/routes.js @@ -15,8 +15,6 @@ import ModalRoot from './components/modals/modalRoot'; import Gallery from './components/gallery'; import Toasts from './components/toasts'; import Maintenance from './components/maintenance'; -import LoadingDMs from './views/directMessages/components/loading'; -import LoadingThread from './views/thread/components/loading'; import { Loading, LoadingScreen } from './components/loading'; import LoadingDashboard from './views/dashboard/components/dashboardLoading'; import Composer from './components/composer'; @@ -28,11 +26,8 @@ import Navbar from './views/navbar'; import Status from './views/status'; import Login from './views/login'; -/* prettier-ignore */ -const DirectMessages = Loadable({ - loader: () => import('./views/directMessages'/* webpackChunkName: "DirectMessages" */), - loading: ({ isLoading }) => isLoading && , -}); +import DirectMessages from './views/directMessages'; +import Thread from './views/thread'; /* prettier-ignore */ const Explore = Loadable({ @@ -40,12 +35,6 @@ const Explore = Loadable({ loading: ({ isLoading }) => isLoading && , }); -/* prettier-ignore */ -const Thread = Loadable({ - loader: () => import('./views/thread'/* webpackChunkName: "Thread" */), - loading: ({ isLoading }) => isLoading && , -}); - /* prettier-ignore */ const UserView = Loadable({ loader: () => import('./views/user'/* webpackChunkName: "UserView" */), diff --git a/src/views/community/components/memberGrid.js b/src/views/community/components/memberGrid.js index 460dd6f8b9..3e5c1fce7a 100644 --- a/src/views/community/components/memberGrid.js +++ b/src/views/community/components/memberGrid.js @@ -3,6 +3,7 @@ import * as React from 'react'; import compose from 'recompose/compose'; import { connect } from 'react-redux'; import InfiniteList from 'src/components/infiniteScroll'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import Icon from 'src/components/icons'; import { initNewThreadWithUser } from 'src/actions/directMessageThreads'; import { withRouter } from 'react-router'; @@ -63,6 +64,7 @@ class CommunityMemberGrid extends React.Component { if (community) { const { edges: members } = community.members; const nodes = members.map(member => member && member.node); + const uniqueNodes = deduplicateChildren(nodes, 'id'); const hasNextPage = community.members.pageInfo.hasNextPage; return ( @@ -82,7 +84,7 @@ class CommunityMemberGrid extends React.Component { threshold={750} className={'scroller-for-community-members-list'} > - {nodes.map(node => { + {uniqueNodes.map(node => { if (!node) return null; const { user, roles, reputation } = node; diff --git a/src/views/communityBilling/components/administratorEmailForm.js b/src/views/communityBilling/components/administratorEmailForm.js index 987a598345..f6cef851d3 100644 --- a/src/views/communityBilling/components/administratorEmailForm.js +++ b/src/views/communityBilling/components/administratorEmailForm.js @@ -3,7 +3,7 @@ import * as React from 'react'; import compose from 'recompose/compose'; import { connect } from 'react-redux'; import type { GetCommunitySettingsType } from 'shared/graphql/queries/community/getCommunitySettings'; -import { isEmail } from 'validator'; +import isEmail from 'validator/lib/isEmail'; import { addToastWithTimeout } from '../../../actions/toasts'; import { Input, Error } from '../../../components/formElements'; import { Notice } from '../../../components/listItems/style'; diff --git a/src/views/dashboard/components/threadFeed.js b/src/views/dashboard/components/threadFeed.js index 450d0b685f..195c1d6cf5 100644 --- a/src/views/dashboard/components/threadFeed.js +++ b/src/views/dashboard/components/threadFeed.js @@ -6,6 +6,7 @@ import { connect } from 'react-redux'; // NOTE(@mxstbr): This is a custom fork published of off this (as of this writing) unmerged PR: https://github.com/CassetteRocks/react-infinite-scroller/pull/38 // I literally took it, renamed the package.json and published to add support for scrollElement since our scrollable container is further outside import InfiniteList from 'src/components/infiniteScroll'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import FlipMove from 'react-flip-move'; import { sortByDate } from '../../../helpers/utils'; import { LoadingInboxThread } from '../../../components/loading'; @@ -259,9 +260,7 @@ class ThreadFeed extends React.Component { ); } - const uniqueThreads = filteredThreads.filter( - (val, i, self) => self.indexOf(val) === i - ); + const uniqueThreads = deduplicateChildren(filteredThreads, 'id'); return (
{ // force scroll to bottom when a message is sent in the same thread if (prev.data.messages !== data.messages && contextualScrollToBottom) { // mark this thread as unread when new messages come in and i'm viewing it - setLastSeen(data.directMessageThread.id); + if (data.directMessageThread) setLastSeen(data.directMessageThread.id); contextualScrollToBottom(); } } diff --git a/src/views/directMessages/components/threadsList.js b/src/views/directMessages/components/threadsList.js index 0a42a3e864..9be6ce5cda 100644 --- a/src/views/directMessages/components/threadsList.js +++ b/src/views/directMessages/components/threadsList.js @@ -2,6 +2,7 @@ import * as React from 'react'; import ListCardItemDirectMessageThread from './messageThreadListItem'; import InfiniteList from 'src/components/infiniteScroll'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import { LoadingDM } from 'src/components/loading'; import { ThreadsListScrollContainer } from './style'; @@ -67,6 +68,8 @@ class ThreadsList extends React.Component { return null; } + const uniqueThreads = deduplicateChildren(threads, 'id'); + return ( { threshold={30} className={'scroller-for-community-dm-threads-list'} > - {threads.map(thread => { + {uniqueThreads.map(thread => { if (!thread) return null; return ( { + constructor() { + super(); + + this.state = { + activeThread: '', + subscription: null, + }; + } + + subscribe = () => { + this.setState({ + subscription: this.props.subscribeToUpdatedDirectMessageThreads(), + }); + }; + + unsubscribe = () => { + const { subscription } = this.state; + if (subscription) { + // This unsubscribes the subscription + subscription(); + } + }; + + componentDidMount() { + this.props.markDirectMessageNotificationsSeen(); + this.subscribe(); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + setActiveThread = id => { + return this.setState({ + activeThread: id === 'new' ? '' : id, + }); + }; + + render() { + const { + match, + currentUser, + data, + hasError, + fetchMore, + isFetchingMore, + isLoading, + } = this.props; + + // Only logged-in users can view DM threads + if (!currentUser) return null; + const { activeThread } = this.state; + const isComposing = match.url === '/messages/new' && match.isExact; + const isViewingThread = !!match.params.threadId; + const ThreadDetail = isViewingThread ? ExistingThread : NewThread; + const dataExists = + currentUser && data.user && data.user.directMessageThreadsConnection; + const threads = + dataExists && + data.user.directMessageThreadsConnection.edges && + data.user.directMessageThreadsConnection.edges.length > 0 + ? data.user.directMessageThreadsConnection.edges + .map(thread => thread && thread.node) + .sort((a, b) => { + const x = + a && + a.threadLastActive && + new Date(a.threadLastActive).getTime(); + const y = + b && + b.threadLastActive && + new Date(b.threadLastActive).getTime(); + const val = parseInt(y, 10) - parseInt(x, 10); + return val; + }) + : []; + + if (hasError) return ; + + const hasNextPage = + data.user && + data.user.directMessageThreadsConnection && + data.user.directMessageThreadsConnection.pageInfo && + data.user.directMessageThreadsConnection.pageInfo.hasNextPage; + + return ( + + + + this.setActiveThread('new')}> + + + + + + + + + {dataExists && ( + + )} + + ); + } +} + +const map = state => ({ currentUser: state.users.currentUser }); +export default compose( + // $FlowIssue + connect(map), + getCurrentUserDirectMessageThreads, + markDirectMessageNotificationsSeenMutation, + viewNetworkHandler +)(DirectMessages); diff --git a/src/views/directMessages/index.js b/src/views/directMessages/index.js index 9dfccc01fe..7b6d994a0d 100644 --- a/src/views/directMessages/index.js +++ b/src/views/directMessages/index.js @@ -1,171 +1,12 @@ // @flow -import * as React from 'react'; -import compose from 'recompose/compose'; -import Link from 'src/components/link'; -import { connect } from 'react-redux'; -import getCurrentUserDirectMessageThreads from 'shared/graphql/queries/directMessageThread/getCurrentUserDMThreadConnection'; -import type { GetCurrentUserDMThreadConnectionType } from 'shared/graphql/queries/directMessageThread/getCurrentUserDMThreadConnection'; -import markDirectMessageNotificationsSeenMutation from 'shared/graphql/mutations/notification/markDirectMessageNotificationsSeen'; -import Icon from '../../components/icons'; -import ThreadsList from './components/threadsList'; -import NewThread from './containers/newThread'; -import ExistingThread from './containers/existingThread'; -import viewNetworkHandler from '../../components/viewNetworkHandler'; -import ViewError from '../../components/viewError'; -import Titlebar from '../titlebar'; -import { View, MessagesList, ComposeHeader } from './style'; +import React from 'react'; +import Loadable from 'react-loadable'; +import LoadingDMs from './components/loading'; -type Props = { - subscribeToUpdatedDirectMessageThreads: Function, - markDirectMessageNotificationsSeen: Function, - dispatch: Function, - match: Object, - currentUser?: Object, - hasError: boolean, - isFetchingMore: boolean, - isLoading: boolean, - fetchMore: Function, - data: { - user: GetCurrentUserDMThreadConnectionType, - }, -}; -type State = { - activeThread: string, - subscription: ?Function, -}; +/* prettier-ignore */ +const DirectMessages = Loadable({ + loader: () => import('./containers/index.js'/* webpackChunkName: "DirectMessages" */), + loading: ({ isLoading }) => isLoading && , +}); -class DirectMessages extends React.Component { - constructor() { - super(); - - this.state = { - activeThread: '', - subscription: null, - }; - } - - subscribe = () => { - this.setState({ - subscription: this.props.subscribeToUpdatedDirectMessageThreads(), - }); - }; - - unsubscribe = () => { - const { subscription } = this.state; - if (subscription) { - // This unsubscribes the subscription - subscription(); - } - }; - - componentDidMount() { - this.props.markDirectMessageNotificationsSeen(); - this.subscribe(); - } - - componentWillUnmount() { - this.unsubscribe(); - } - - setActiveThread = id => { - return this.setState({ - activeThread: id === 'new' ? '' : id, - }); - }; - - render() { - const { - match, - currentUser, - data, - hasError, - fetchMore, - isFetchingMore, - isLoading, - } = this.props; - - // Only logged-in users can view DM threads - if (!currentUser) return null; - const { activeThread } = this.state; - const isComposing = match.url === '/messages/new' && match.isExact; - const isViewingThread = !!match.params.threadId; - const ThreadDetail = isViewingThread ? ExistingThread : NewThread; - const dataExists = - currentUser && data.user && data.user.directMessageThreadsConnection; - const threads = - dataExists && - data.user.directMessageThreadsConnection.edges && - data.user.directMessageThreadsConnection.edges.length > 0 - ? data.user.directMessageThreadsConnection.edges - .map(thread => thread && thread.node) - .sort((a, b) => { - const x = - a && - a.threadLastActive && - new Date(a.threadLastActive).getTime(); - const y = - b && - b.threadLastActive && - new Date(b.threadLastActive).getTime(); - const val = parseInt(y, 10) - parseInt(x, 10); - return val; - }) - : []; - - if (hasError) return ; - - const hasNextPage = - data.user && - data.user.directMessageThreadsConnection && - data.user.directMessageThreadsConnection.pageInfo && - data.user.directMessageThreadsConnection.pageInfo.hasNextPage; - - return ( - - - - this.setActiveThread('new')}> - - - - - - - - - {dataExists && ( - - )} - - ); - } -} - -const map = state => ({ currentUser: state.users.currentUser }); -export default compose( - // $FlowIssue - connect(map), - getCurrentUserDirectMessageThreads, - markDirectMessageNotificationsSeenMutation, - viewNetworkHandler -)(DirectMessages); +export default DirectMessages; diff --git a/src/views/navbar/components/messagesTab.js b/src/views/navbar/components/messagesTab.js index 7edac457d8..69be3fa747 100644 --- a/src/views/navbar/components/messagesTab.js +++ b/src/views/navbar/components/messagesTab.js @@ -210,7 +210,7 @@ class MessagesTab extends React.Component { > 0 ? 'message-fill' : 'message'} - withCount={count > 10 ? '10+' : count > 0 ? count : false} + count={count > 10 ? '10+' : count > 0 ? count.toString() : null} /> diff --git a/src/views/navbar/components/notificationsTab.js b/src/views/navbar/components/notificationsTab.js index a5f3cc1655..37359fd1da 100644 --- a/src/views/navbar/components/notificationsTab.js +++ b/src/views/navbar/components/notificationsTab.js @@ -13,7 +13,7 @@ import type { GetNotificationsType } from 'shared/graphql/queries/notification/g import markNotificationsSeenMutation from 'shared/graphql/mutations/notification/markNotificationsSeen'; import { markSingleNotificationSeenMutation } from 'shared/graphql/mutations/notification/markSingleNotificationSeen'; import { Tab, NotificationTab, Label } from '../style'; -import { getDistinctNotifications } from '../../notifications/utils'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; type Props = { active: boolean, @@ -254,7 +254,7 @@ class NotificationsTab extends React.Component { if (!nodes || nodes.length === 0) return this.setCount(); // get distinct notifications by id - const distinct = getDistinctNotifications(nodes); + const distinct = deduplicateChildren(nodes, 'id'); /* 1. If the user is viewing a ?thread= url, don't display a notification @@ -332,7 +332,7 @@ class NotificationsTab extends React.Component { return curr.dispatch(updateNotificationsCount('notifications', 0)); } - const distinct = getDistinctNotifications(notifications); + const distinct = deduplicateChildren(notifications, 'id'); // set to 0 if no notifications exist yet if (!distinct || distinct.length === 0) { return curr.dispatch(updateNotificationsCount('notifications', 0)); @@ -391,7 +391,7 @@ class NotificationsTab extends React.Component { > 0 ? 'notification-fill' : 'notification'} - withCount={count > 10 ? '10+' : count > 0 ? count : false} + count={count > 10 ? '10+' : count > 0 ? count.toString() : null} /> diff --git a/src/views/navbar/components/profileDropdown.js b/src/views/navbar/components/profileDropdown.js index 6e286b89cf..f3c0c32eb4 100644 --- a/src/views/navbar/components/profileDropdown.js +++ b/src/views/navbar/components/profileDropdown.js @@ -5,7 +5,9 @@ import Link from 'src/components/link'; import Dropdown from '../../../components/dropdown'; import { SERVER_URL } from '../../../api/constants'; -const UserProfileDropdown = styled(Dropdown)`width: 200px;`; +const UserProfileDropdown = styled(Dropdown)` + width: 200px; +`; const UserProfileDropdownList = styled.ul` list-style-type: none; diff --git a/src/views/newCommunity/components/share/index.js b/src/views/newCommunity/components/share/index.js index 390922c64a..3397f385b4 100644 --- a/src/views/newCommunity/components/share/index.js +++ b/src/views/newCommunity/components/share/index.js @@ -15,7 +15,11 @@ const Share = ({ community, history, onboarding }) => {
@@ -29,7 +33,11 @@ const Share = ({ community, history, onboarding }) => { diff --git a/src/views/newCommunity/style.js b/src/views/newCommunity/style.js index 8d2121609f..635b396c2e 100644 --- a/src/views/newCommunity/style.js +++ b/src/views/newCommunity/style.js @@ -52,7 +52,9 @@ export const Divider = styled.div` margin-bottom: 24px; `; -export const ContentContainer = styled.div`padding: 0 24px 24px;`; +export const ContentContainer = styled.div` + padding: 0 24px 24px; +`; export const FormContainer = styled.div``; diff --git a/src/views/notifications/index.js b/src/views/notifications/index.js index 6dca10bd8f..6b31a36766 100644 --- a/src/views/notifications/index.js +++ b/src/views/notifications/index.js @@ -5,7 +5,8 @@ import { connect } from 'react-redux'; // NOTE(@mxstbr): This is a custom fork published of off this (as of this writing) unmerged PR: https://github.com/CassetteRocks/react-infinite-scroller/pull/38 // I literally took it, renamed the package.json and published to add support for scrollElement since our scrollable container is further outside import InfiniteList from 'src/components/infiniteScroll'; -import { parseNotification, getDistinctNotifications } from './utils'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; +import { parseNotification } from './utils'; import { NewMessageNotification } from './components/newMessageNotification'; import { NewReactionNotification } from './components/newReactionNotification'; import { NewChannelNotification } from './components/newChannelNotification'; @@ -201,7 +202,7 @@ class NotificationsPure extends React.Component { notification => notification.context.type !== 'DIRECT_MESSAGE_THREAD' ); - notifications = getDistinctNotifications(notifications); + notifications = deduplicateChildren(notifications, 'id'); notifications = sortByDate(notifications, 'modifiedAt', 'desc'); const { scrollElement } = this.state; diff --git a/src/views/notifications/style.js b/src/views/notifications/style.js index d5cc81b69d..fc8f562ad2 100644 --- a/src/views/notifications/style.js +++ b/src/views/notifications/style.js @@ -14,7 +14,9 @@ import { HorizontalRule } from '../../components/globals'; import Card from '../../components/card'; import { IconButton } from '../../components/buttons'; -export const HzRule = styled(HorizontalRule)`margin: 0;`; +export const HzRule = styled(HorizontalRule)` + margin: 0; +`; export const NotificationCard = styled(Card)` padding: 16px; @@ -151,9 +153,13 @@ export const ActorPhotosContainer = styled(FlexRow)` max-width: 100%; `; -export const ActorPhotoItem = styled.div`margin-right: 4px;`; +export const ActorPhotoItem = styled.div` + margin-right: 4px; +`; -export const ActorPhoto = styled.img`width: 100%;`; +export const ActorPhoto = styled.img` + width: 100%; +`; export const ContextRow = styled(FlexRow)` align-items: center; diff --git a/src/views/notifications/utils.js b/src/views/notifications/utils.js index 14e955030d..28a62f6220 100644 --- a/src/views/notifications/utils.js +++ b/src/views/notifications/utils.js @@ -3,18 +3,6 @@ import Link from 'src/components/link'; import { timeDifferenceShort } from '../../helpers/utils'; import { Timestamp } from './style'; -export const getDistinctNotifications = array => { - let unique = {}; - let distinct = []; - for (let i in array) { - if (typeof unique[array[i].id] === 'undefined') { - distinct.push(array[i]); - } - unique[array[i].id] = 0; - } - return distinct; -}; - export const parseNotification = notification => { return Object.assign({}, notification, { actors: notification.actors.map(actor => { diff --git a/src/views/pages/style.js b/src/views/pages/style.js index caa32be586..5236eaff94 100644 --- a/src/views/pages/style.js +++ b/src/views/pages/style.js @@ -189,7 +189,7 @@ export const SignInButton = styled.a` ${props => props.after && ` - margin: 24px 0; + margin: 24px 0; &:after { content: 'Previously signed in with'; diff --git a/src/views/search/searchInput.js b/src/views/search/searchInput.js index ae3ae832aa..1d977e9f4b 100644 --- a/src/views/search/searchInput.js +++ b/src/views/search/searchInput.js @@ -20,7 +20,6 @@ class SearchViewInput extends React.Component { close = () => { if (this.state.value.length === 0) { this.setState({ isOpen: false, searchQueryString: '' }); - this.setState({ searchQueryString: '' }); } this.searchInput.blur(); }; diff --git a/src/views/thread/components/actionBar.js b/src/views/thread/components/actionBar.js index 3949da36ef..b4e65e8d55 100644 --- a/src/views/thread/components/actionBar.js +++ b/src/views/thread/components/actionBar.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { connect } from 'react-redux'; import Clipboard from 'react-clipboard.js'; +import { Manager, Reference, Popper } from 'react-popper'; import { addToastWithTimeout } from '../../../actions/toasts'; import { openModal } from '../../../actions/modals'; import Icon from '../../../components/icons'; @@ -11,6 +12,8 @@ import Flyout from '../../../components/flyout'; import { track } from '../../../helpers/events'; import type { GetThreadType } from 'shared/graphql/queries/thread/getThread'; import toggleThreadNotificationsMutation from 'shared/graphql/mutations/thread/toggleThreadNotifications'; +import OutsideClickHandler from '../../../components/outsideClickHandler'; + import { FollowButton, ShareButtons, @@ -42,11 +45,19 @@ type Props = { type State = { notificationStateLoading: boolean, flyoutOpen: boolean, + isSettingsBtnHovering: boolean, }; class ActionBar extends React.Component { state = { notificationStateLoading: false, flyoutOpen: false, + isSettingsBtnHovering: false, + }; + + toggleHover = () => { + this.setState(({ isSettingsBtnHovering }) => ({ + isSettingsBtnHovering: !isSettingsBtnHovering, + })); }; toggleFlyout = val => { @@ -216,7 +227,11 @@ class ActionBar extends React.Component { isLockingThread, isPinningThread, } = this.props; - const { notificationStateLoading, flyoutOpen } = this.state; + const { + notificationStateLoading, + flyoutOpen, + isSettingsBtnHovering, + } = this.state; const isPinned = thread.community.pinnedThreadId === thread.id; const shouldRenderActionsDropdown = this.shouldRenderActionsDropdown(); @@ -350,129 +365,158 @@ class ActionBar extends React.Component {
{shouldRenderActionsDropdown && ( - - - - - - {thread.receiveNotifications ? 'Subscribed' : 'Notify me'} - - - - {shouldRenderEditThreadAction && ( - - - - - - )} - - {shouldRenderPinThreadAction && ( - - - - - - )} - - {shouldRenderMoveThreadAction && ( - - - Move thread - - - )} - - {shouldRenderLockThreadAction && ( - - - - - - )} - - {shouldRenderDeleteThreadAction && ( - - + + + {({ ref }) => { + return ( + + ); + }} + + {(isSettingsBtnHovering || flyoutOpen) && ( + + - - - + {({ style, ref, placement }) => { + return ( + + + + {thread.receiveNotifications + ? 'Subscribed' + : 'Notify me'} + + + + {shouldRenderEditThreadAction && ( + + + + + + )} + + {shouldRenderPinThreadAction && ( + + + + + + )} + + {shouldRenderMoveThreadAction && ( + + + Move thread + + + )} + + {shouldRenderLockThreadAction && ( + + + + + + )} + + {shouldRenderDeleteThreadAction && ( + + + + + + )} + + ); + }} + + )} - + )}
- {flyoutOpen && ( -
- setTimeout(() => { - this.toggleFlyout(false); - }) - } - /> - )} ); } diff --git a/src/views/thread/components/messages.js b/src/views/thread/components/messages.js index 2abaa762fa..49e0f6bfe6 100644 --- a/src/views/thread/components/messages.js +++ b/src/views/thread/components/messages.js @@ -3,6 +3,7 @@ import * as React from 'react'; import compose from 'recompose/compose'; import { withRouter } from 'react-router'; import InfiniteList from 'src/components/infiniteScroll'; +import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import { sortAndGroupMessages } from 'shared/clients/group-messages'; import ChatMessages from '../../../components/messageGroup'; import { LoadingChat } from '../../../components/loading'; @@ -181,7 +182,7 @@ class MessagesWithData extends React.Component { return array; }; - const uniqueMessages = unique(unsortedMessages); + const uniqueMessages = deduplicateChildren(unsortedMessages, 'id'); const sortedMessages = sortAndGroupMessages(uniqueMessages); return ( diff --git a/src/views/thread/container.js b/src/views/thread/container.js new file mode 100644 index 0000000000..ad3d427e86 --- /dev/null +++ b/src/views/thread/container.js @@ -0,0 +1,562 @@ +// @flow +import * as React from 'react'; +import compose from 'recompose/compose'; +import { connect } from 'react-redux'; +import { withApollo } from 'react-apollo'; +import { track } from '../../helpers/events'; +import generateMetaInfo from 'shared/generate-meta-info'; +import { addCommunityToOnboarding } from '../../actions/newUserOnboarding'; +import Titlebar from '../../views/titlebar'; +import ThreadDetail from './components/threadDetail'; +import Messages from './components/messages'; +import Head from '../../components/head'; +import ChatInput from '../../components/chatInput'; +import ViewError from '../../components/viewError'; +import viewNetworkHandler from '../../components/viewNetworkHandler'; +import { + getThreadByMatch, + getThreadByMatchQuery, +} from 'shared/graphql/queries/thread/getThread'; +import { NullState } from '../../components/upsell'; +import JoinChannel from '../../components/upsell/joinChannel'; +import { toState } from 'shared/draft-utils'; +import LoadingView from './components/loading'; +import ThreadCommunityBanner from './components/threadCommunityBanner'; +import Sidebar from './components/sidebar'; +import type { GetThreadType } from 'shared/graphql/queries/thread/getThread'; +import { + ThreadViewContainer, + ThreadContentView, + Content, + Input, + Detail, + ChatInputWrapper, + WatercoolerDescription, + WatercoolerIntroContainer, + WatercoolerTitle, + WatercoolerAvatar, +} from './style'; + +type Props = { + data: { + thread: GetThreadType, + refetch: Function, + }, + isLoading: boolean, + hasError: boolean, + currentUser: Object, + dispatch: Function, + slider: boolean, + threadViewContext: 'slider' | 'fullscreen' | 'inbox', + threadSliderIsOpen: boolean, + client: Object, +}; + +type State = { + scrollElement: any, + isEditing: boolean, + messagesContainer: any, + // Cache lastSeen and lastActive so it doesn't jump around + // while looking at a live thread + lastSeen: ?number | ?string, + lastActive: ?number | ?string, +}; + +class ThreadContainer extends React.Component { + chatInput: any; + + state = { + messagesContainer: null, + scrollElement: null, + isEditing: false, + lastSeen: null, + lastActive: null, + }; + + componentWillReceiveProps(next: Props) { + const curr = this.props; + const newThread = !curr.data.thread && next.data.thread; + const threadChanged = + curr.data.thread && + next.data.thread && + curr.data.thread.id !== next.data.thread.id; + // Update the cached lastSeen value when switching threads + if (newThread || threadChanged) { + this.setState({ + lastSeen: next.data.thread.currentUserLastSeen + ? next.data.thread.currentUserLastSeen + : null, + lastActive: next.data.thread.lastActive + ? next.data.thread.lastActive + : null, + }); + } + } + + toggleEdit = () => { + const { isEditing } = this.state; + this.setState({ + isEditing: !isEditing, + }); + }; + + setMessagesContainer = elem => { + if (this.state.messagesContainer) return; + this.setState({ + messagesContainer: elem, + }); + }; + + // Locally update thread.currentUserLastSeen + updateThreadLastSeen = threadId => { + const { currentUser, client } = this.props; + // No currentUser, no reason to update currentUserLastSeen + if (!currentUser || !threadId) return; + try { + const threadData = client.readQuery({ + query: getThreadByMatchQuery, + variables: { + id: threadId, + }, + }); + + client.writeQuery({ + query: getThreadByMatchQuery, + variables: { + id: threadId, + }, + data: { + ...threadData, + thread: { + ...threadData.thread, + currentUserLastSeen: new Date(), + __typename: 'Thread', + }, + }, + }); + } catch (err) { + // Errors that happen with this shouldn't crash the app + console.error(err); + } + }; + + componentDidMount() { + const elem = document.getElementById('scroller-for-inbox-thread-view'); + this.setState({ + // NOTE(@mxstbr): This is super un-reacty but it works. This refers to + // the AppViewWrapper which is the scrolling part of the site. + scrollElement: elem, + }); + } + + componentDidUpdate(prevProps) { + // if the user is in the inbox and changes threads, it should initially scroll + // to the top before continuing with logic to force scroll to the bottom + if ( + prevProps.data && + prevProps.data.thread && + this.props.data && + this.props.data.thread && + prevProps.data.thread.id !== this.props.data.thread.id + ) { + track('thread', 'viewed', null); + + // if the user is new and signed up through a thread view, push + // the thread's community data into the store to hydrate the new user experience + // with their first community they should join + this.props.dispatch( + addCommunityToOnboarding(this.props.data.thread.community) + ); + + // Update thread.currentUserLastSeen for the last thread when we switch away from it + if (prevProps.threadId) { + this.updateThreadLastSeen(prevProps.threadId); + } + this.forceScrollToTop(); + } + + // we never autofocus on mobile + if (window && window.innerWidth < 768) return; + + const { currentUser, data: { thread }, threadSliderIsOpen } = this.props; + + // if no thread has been returned yet from the query, we don't know whether or not to focus yet + if (!thread) return; + + // only when the thread has been returned for the first time should evaluate whether or not to focus the chat input + const threadAndUser = currentUser && thread; + if (threadAndUser && this.chatInput) { + // if the user is viewing the inbox, opens the thread slider, and then closes it again, refocus the inbox inpu + if (prevProps.threadSliderIsOpen && !threadSliderIsOpen) { + return this.chatInput.triggerFocus(); + } + + // if the thread slider is open while in the inbox, don't focus in the inbox + if (threadSliderIsOpen) return; + + return this.chatInput.triggerFocus(); + } + } + + forceScrollToTop = () => { + const { messagesContainer } = this.state; + if (!messagesContainer) return; + messagesContainer.scrollTop = 0; + }; + + forceScrollToBottom = () => { + const { messagesContainer } = this.state; + if (!messagesContainer) return; + const node = messagesContainer; + node.scrollTop = node.scrollHeight - node.clientHeight; + }; + + contextualScrollToBottom = () => { + const { messagesContainer } = this.state; + if (!messagesContainer) return; + const node = messagesContainer; + if (node.scrollHeight - node.clientHeight < node.scrollTop + 280) { + node.scrollTop = node.scrollHeight - node.clientHeight; + } + }; + + renderChatInputOrUpsell = () => { + const { isEditing } = this.state; + const { data: { thread }, currentUser } = this.props; + + if (!thread) return null; + if (thread.isLocked) return null; + if (isEditing) return null; + if (thread.channel.isArchived) return null; + + const { channelPermissions } = thread.channel; + const { communityPermissions } = thread.community; + + const isBlockedInChannelOrCommunity = + channelPermissions.isBlocked || communityPermissions.isBlocked; + + if (isBlockedInChannelOrCommunity) return null; + + const LS_KEY = 'last-chat-input-content'; + const LS_KEY_EXPIRE = 'last-chat-input-content-expire'; + let storedContent; + // We persist the body and title to localStorage + // so in case the app crashes users don't loose content + if (localStorage) { + try { + storedContent = toState(JSON.parse(localStorage.getItem(LS_KEY) || '')); + } catch (err) { + localStorage.removeItem(LS_KEY); + localStorage.removeItem(LS_KEY_EXPIRE); + } + } + + const chatInputComponent = ( + + + (this.chatInput = chatInput)} + refetchThread={this.props.data.refetch} + /> + + + ); + + if (!currentUser) { + return chatInputComponent; + } + + if (currentUser && !currentUser.id) { + return chatInputComponent; + } + + if (storedContent) { + return chatInputComponent; + } + + if (channelPermissions.isMember) { + return chatInputComponent; + } + + return ( + + ); + }; + + render() { + const { + data: { thread }, + currentUser, + isLoading, + hasError, + slider, + threadViewContext = 'fullscreen', + } = this.props; + const { isEditing, lastSeen, lastActive } = this.state; + + if (thread && thread.id) { + // successful network request to get a thread + const { title, description } = generateMetaInfo({ + type: 'thread', + data: { + title: thread.content.title, + body: thread.content.body, + type: thread.type, + communityName: thread.community.name, + }, + }); + + // get the data we need to render the view + const { channelPermissions } = thread.channel; + const { communityPermissions } = thread.community; + const { isLocked, isAuthor, participants } = thread; + const isChannelOwner = currentUser && channelPermissions.isOwner; + const isCommunityOwner = currentUser && communityPermissions.isOwner; + const isChannelModerator = currentUser && channelPermissions.isModerator; + const isCommunityModerator = + currentUser && communityPermissions.isModerator; + const isModerator = + isChannelOwner || + isCommunityOwner || + isChannelModerator || + isCommunityModerator; + const isParticipantOrAuthor = + currentUser && + (isAuthor || + (participants && + participants.length > 0 && + participants.some( + participant => participant && participant.id === currentUser.id + ))); + + const shouldRenderThreadSidebar = threadViewContext === 'fullscreen'; + + if (channelPermissions.isBlocked || communityPermissions.isBlocked) { + return ( + + + + + + ); + } + + if (thread.watercooler) + return ( + + {shouldRenderThreadSidebar && ( + + )} + + + + + + + + + + The {thread.community.name} watercooler + + + Welcome to the {thread.community.name} watercooler, a new + space for general chat with everyone in the community. + Jump in to the conversation below or introduce yourself! + + + {!isEditing && ( + 0} + isModerator={isModerator} + /> + )} + + {!isEditing && + isLocked && ( + + )} + + + + {this.renderChatInputOrUpsell()} + + + ); + + return ( + + {shouldRenderThreadSidebar && ( + + )} + + + + + + + + + + + {!isEditing && ( + 0} + isModerator={isModerator} + threadIsLocked={isLocked} + /> + )} + + {!isEditing && + isLocked && ( + + )} + + + + {this.renderChatInputOrUpsell()} + + + ); + } + + if (isLoading) { + return ; + } + + return ( + + + + + + ); + } +} + +const map = state => ({ currentUser: state.users.currentUser }); +export default compose( + // $FlowIssue + connect(map), + getThreadByMatch, + viewNetworkHandler, + withApollo +)(ThreadContainer); diff --git a/src/views/thread/index.js b/src/views/thread/index.js index 04c0c92b54..207c707841 100644 --- a/src/views/thread/index.js +++ b/src/views/thread/index.js @@ -1,560 +1,12 @@ // @flow -import * as React from 'react'; -import compose from 'recompose/compose'; -import { connect } from 'react-redux'; -import { withApollo } from 'react-apollo'; -import { track } from '../../helpers/events'; -import generateMetaInfo from 'shared/generate-meta-info'; -import { addCommunityToOnboarding } from '../../actions/newUserOnboarding'; -import Titlebar from '../../views/titlebar'; -import ThreadDetail from './components/threadDetail'; -import Messages from './components/messages'; -import Head from '../../components/head'; -import ChatInput from '../../components/chatInput'; -import ViewError from '../../components/viewError'; -import viewNetworkHandler from '../../components/viewNetworkHandler'; -import { - getThreadByMatch, - getThreadByMatchQuery, -} from 'shared/graphql/queries/thread/getThread'; -import { NullState } from '../../components/upsell'; -import JoinChannel from '../../components/upsell/joinChannel'; -import { toState } from 'shared/draft-utils'; -import LoadingView from './components/loading'; -import ThreadCommunityBanner from './components/threadCommunityBanner'; -import Sidebar from './components/sidebar'; -import type { GetThreadType } from 'shared/graphql/queries/thread/getThread'; -import { - ThreadViewContainer, - ThreadContentView, - Content, - Input, - Detail, - ChatInputWrapper, - WatercoolerDescription, - WatercoolerIntroContainer, - WatercoolerTitle, - WatercoolerAvatar, -} from './style'; +import React from 'react'; +import Loadable from 'react-loadable'; +import LoadingThread from './components/loading'; -type Props = { - data: { - thread: GetThreadType, - refetch: Function, - }, - isLoading: boolean, - hasError: boolean, - currentUser: Object, - dispatch: Function, - slider: boolean, - threadViewContext: 'slider' | 'fullscreen' | 'inbox', - threadSliderIsOpen: boolean, - client: Object, -}; +/* prettier-ignore */ +const Thread = Loadable({ + loader: () => import('./container'/* webpackChunkName: "Thread" */), + loading: ({ isLoading }) => isLoading && , +}); -type State = { - scrollElement: any, - isEditing: boolean, - messagesContainer: any, - // Cache lastSeen and lastActive so it doesn't jump around - // while looking at a live thread - lastSeen: ?number | ?string, - lastActive: ?number | ?string, -}; - -class ThreadContainer extends React.Component { - chatInput: any; - - state = { - messagesContainer: null, - scrollElement: null, - isEditing: false, - lastSeen: null, - lastActive: null, - }; - - componentWillReceiveProps(next: Props) { - const curr = this.props; - const newThread = !curr.data.thread && next.data.thread; - const threadChanged = - curr.data.thread && - next.data.thread && - curr.data.thread.id !== next.data.thread.id; - // Update the cached lastSeen value when switching threads - if (newThread || threadChanged) { - this.setState({ - lastSeen: next.data.thread.currentUserLastSeen - ? next.data.thread.currentUserLastSeen - : null, - lastActive: next.data.thread.lastActive - ? next.data.thread.lastActive - : null, - }); - } - } - - toggleEdit = () => { - const { isEditing } = this.state; - this.setState({ - isEditing: !isEditing, - }); - }; - - setMessagesContainer = elem => { - if (this.state.messagesContainer) return; - this.setState({ - messagesContainer: elem, - }); - }; - - // Locally update thread.currentUserLastSeen - updateThreadLastSeen = threadId => { - const { currentUser, client } = this.props; - // No currentUser, no reason to update currentUserLastSeen - if (!currentUser || !threadId) return; - try { - const threadData = client.readQuery({ - query: getThreadByMatchQuery, - variables: { - id: threadId, - }, - }); - - client.writeQuery({ - query: getThreadByMatchQuery, - variables: { - id: threadId, - }, - data: { - ...threadData, - thread: { - ...threadData.thread, - currentUserLastSeen: new Date(), - __typename: 'Thread', - }, - }, - }); - } catch (err) { - // Errors that happen with this shouldn't crash the app - console.error(err); - } - }; - - componentDidMount() { - const elem = document.getElementById('scroller-for-inbox-thread-view'); - this.setState({ - // NOTE(@mxstbr): This is super un-reacty but it works. This refers to - // the AppViewWrapper which is the scrolling part of the site. - scrollElement: elem, - }); - } - - componentDidUpdate(prevProps) { - // if the user is in the inbox and changes threads, it should initially scroll - // to the top before continuing with logic to force scroll to the bottom - if ( - prevProps.data && - prevProps.data.thread && - this.props.data && - this.props.data.thread && - prevProps.data.thread.id !== this.props.data.thread.id - ) { - track('thread', 'viewed', null); - - // if the user is new and signed up through a thread view, push - // the thread's community data into the store to hydrate the new user experience - // with their first community they should join - this.props.dispatch( - addCommunityToOnboarding(this.props.data.thread.community) - ); - - // Update thread.currentUserLastSeen for the last thread when we switch away from it - if (prevProps.threadId) { - this.updateThreadLastSeen(prevProps.threadId); - } - this.forceScrollToTop(); - } - - // we never autofocus on mobile - if (window && window.innerWidth < 768) return; - - const { currentUser, data: { thread }, threadSliderIsOpen } = this.props; - - // if no thread has been returned yet from the query, we don't know whether or not to focus yet - if (!thread) return; - - // only when the thread has been returned for the first time should evaluate whether or not to focus the chat input - const threadAndUser = currentUser && thread; - if (threadAndUser && this.chatInput) { - // if the user is viewing the inbox, opens the thread slider, and then closes it again, refocus the inbox inpu - if (prevProps.threadSliderIsOpen && !threadSliderIsOpen) { - return this.chatInput.triggerFocus(); - } - - // if the thread slider is open while in the inbox, don't focus in the inbox - if (threadSliderIsOpen) return; - - return this.chatInput.triggerFocus(); - } - } - - forceScrollToTop = () => { - const { messagesContainer } = this.state; - if (!messagesContainer) return; - messagesContainer.scrollTop = 0; - }; - - forceScrollToBottom = () => { - const { messagesContainer } = this.state; - if (!messagesContainer) return; - const node = messagesContainer; - node.scrollTop = node.scrollHeight - node.clientHeight; - }; - - contextualScrollToBottom = () => { - const { messagesContainer } = this.state; - if (!messagesContainer) return; - const node = messagesContainer; - if (node.scrollHeight - node.clientHeight < node.scrollTop + 280) { - node.scrollTop = node.scrollHeight - node.clientHeight; - } - }; - - renderChatInputOrUpsell = () => { - const { isEditing } = this.state; - const { data: { thread }, currentUser } = this.props; - - if (!thread) return null; - if (thread.isLocked) return null; - if (isEditing) return null; - if (thread.channel.isArchived) return null; - - const { channelPermissions } = thread.channel; - const { communityPermissions } = thread.community; - - const isBlockedInChannelOrCommunity = - channelPermissions.isBlocked || communityPermissions.isBlocked; - - if (isBlockedInChannelOrCommunity) return null; - - const LS_KEY = 'last-chat-input-content'; - let storedContent; - // We persist the body and title to localStorage - // so in case the app crashes users don't loose content - if (localStorage) { - try { - storedContent = toState(JSON.parse(localStorage.getItem(LS_KEY) || '')); - } catch (err) { - localStorage.removeItem(LS_KEY); - } - } - - const chatInputComponent = ( - - - (this.chatInput = chatInput)} - refetchThread={this.props.data.refetch} - /> - - - ); - - if (!currentUser) { - return chatInputComponent; - } - - if (currentUser && !currentUser.id) { - return chatInputComponent; - } - - if (storedContent) { - return chatInputComponent; - } - - if (channelPermissions.isMember) { - return chatInputComponent; - } - - return ( - - ); - }; - - render() { - const { - data: { thread }, - currentUser, - isLoading, - hasError, - slider, - threadViewContext = 'fullscreen', - } = this.props; - const { isEditing, lastSeen, lastActive } = this.state; - - if (thread && thread.id) { - // successful network request to get a thread - const { title, description } = generateMetaInfo({ - type: 'thread', - data: { - title: thread.content.title, - body: thread.content.body, - type: thread.type, - communityName: thread.community.name, - }, - }); - - // get the data we need to render the view - const { channelPermissions } = thread.channel; - const { communityPermissions } = thread.community; - const { isLocked, isAuthor, participants } = thread; - const isChannelOwner = currentUser && channelPermissions.isOwner; - const isCommunityOwner = currentUser && communityPermissions.isOwner; - const isChannelModerator = currentUser && channelPermissions.isModerator; - const isCommunityModerator = - currentUser && communityPermissions.isModerator; - const isModerator = - isChannelOwner || - isCommunityOwner || - isChannelModerator || - isCommunityModerator; - const isParticipantOrAuthor = - currentUser && - (isAuthor || - (participants && - participants.length > 0 && - participants.some( - participant => participant && participant.id === currentUser.id - ))); - - const shouldRenderThreadSidebar = threadViewContext === 'fullscreen'; - - if (channelPermissions.isBlocked || communityPermissions.isBlocked) { - return ( - - - - - - ); - } - - if (thread.watercooler) - return ( - - {shouldRenderThreadSidebar && ( - - )} - - - - - - - - - - The {thread.community.name} watercooler - - - Welcome to the {thread.community.name} watercooler, a new - space for general chat with everyone in the community. - Jump in to the conversation below or introduce yourself! - - - {!isEditing && ( - 0} - isModerator={isModerator} - /> - )} - - {!isEditing && - isLocked && ( - - )} - - - - {this.renderChatInputOrUpsell()} - - - ); - - return ( - - {shouldRenderThreadSidebar && ( - - )} - - - - - - - - - - - {!isEditing && ( - 0} - isModerator={isModerator} - threadIsLocked={isLocked} - /> - )} - - {!isEditing && - isLocked && ( - - )} - - - - {this.renderChatInputOrUpsell()} - - - ); - } - - if (isLoading) { - return ; - } - - return ( - - - - - - ); - } -} - -const map = state => ({ currentUser: state.users.currentUser }); -export default compose( - // $FlowIssue - connect(map), - getThreadByMatch, - viewNetworkHandler, - withApollo -)(ThreadContainer); +export default Thread; diff --git a/src/views/thread/style.js b/src/views/thread/style.js index f0232b7fac..76da42bf19 100644 --- a/src/views/thread/style.js +++ b/src/views/thread/style.js @@ -184,17 +184,9 @@ export const DropWrap = styled(FlexCol)` } .flyout { - display: none; position: absolute; - top: 100%; - right: 0; - transition: ${Transition.hover.off}; - } - - &:hover .flyout, - &.open > .flyout { - display: inline-block; - transition: ${Transition.hover.on}; + right: auto; + width: 200px; } `; diff --git a/yarn.lock b/yarn.lock index 6746b0f0f0..bad93f9fa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -572,12 +572,21 @@ ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" -ansi-styles@^3.1.0, ansi-styles@^3.2.0, ansi-styles@^3.2.1: +ansi-styles@^3.0.0, ansi-styles@^3.1.0, ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" dependencies: color-convert "^1.9.0" +ansy@^1.0.0: + version "1.0.13" + resolved "https://registry.yarnpkg.com/ansy/-/ansy-1.0.13.tgz#972cbd54c525112f36814fdefe26269ef993810f" + dependencies: + ansi-styles "^3.0.0" + custom-return "^1.0.0" + supports-color "^3.1.2" + ul "^5.2.1" + anymatch@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" @@ -875,6 +884,24 @@ asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" +asciify-pixel-matrix@^1.0.8: + version "1.0.12" + resolved "https://registry.yarnpkg.com/asciify-pixel-matrix/-/asciify-pixel-matrix-1.0.12.tgz#8a7d36cf37757861af0c8c218044f7ecab6ad278" + dependencies: + asciify-pixel "^1.0.0" + ul "^5.2.1" + +asciify-pixel@^1.0.0: + version "1.2.12" + resolved "https://registry.yarnpkg.com/asciify-pixel/-/asciify-pixel-1.2.12.tgz#08adbc5bd09a48da4bada726ccb6d46ade9ae8c9" + dependencies: + couleurs "^6.0.0" + deffy "^2.2.1" + pixel-bg "^1.0.0" + pixel-class "^1.0.0" + pixel-white-bg "^1.0.0" + ul "^5.2.1" + asn1.js@^4.0.0: version "4.10.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" @@ -2216,6 +2243,27 @@ bull@3.3.10: semver "^5.4.1" uuid "^3.1.0" +bundle-buddy-webpack-plugin@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/bundle-buddy-webpack-plugin/-/bundle-buddy-webpack-plugin-0.3.0.tgz#31c596950817eca9221ed6420ac4567c9c0392f3" + dependencies: + asciify-pixel-matrix "^1.0.8" + bundle-buddy "^0.1.2" + chalk "^2.0.1" + webpack-defaults "^1.5.0" + +bundle-buddy@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/bundle-buddy/-/bundle-buddy-0.1.2.tgz#211b74ee609eb0ddb65ade42fde36f13bfdcdb1e" + dependencies: + chalk "^2.0.1" + globby "^6.1.0" + http-server "^0.10.0" + meow "^3.7.0" + openport "^0.0.4" + opn "^5.1.0" + source-map "^0.5.6" + busboy@^0.2.14: version "0.2.14" resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" @@ -2237,6 +2285,18 @@ bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" +cac@^3.0.3: + version "3.0.4" + resolved "https://registry.yarnpkg.com/cac/-/cac-3.0.4.tgz#6d24ceec372efe5c9b798808bc7f49b47242a4ef" + dependencies: + camelcase-keys "^3.0.0" + chalk "^1.1.3" + indent-string "^3.0.0" + minimist "^1.2.0" + read-pkg-up "^1.0.1" + suffix "^0.1.0" + text-table "^0.2.0" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -2285,6 +2345,13 @@ camelcase-keys@^2.0.0: camelcase "^2.0.0" map-obj "^1.0.0" +camelcase-keys@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-3.0.0.tgz#fc0c6c360363f7377e3793b9a16bccf1070c1ca4" + dependencies: + camelcase "^3.0.0" + map-obj "^1.0.0" + camelcase@4.1.0, camelcase@^4.0.0, camelcase@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" @@ -2645,7 +2712,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.3.0, color-convert@^1.9.0: +color-convert@^1.0.0, color-convert@^1.3.0, color-convert@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed" dependencies: @@ -2685,14 +2752,14 @@ colors@0.6.x, colors@0.x.x, colors@~0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" +colors@1.0.3, colors@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" + colors@^1.1.2: version "1.2.1" resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.1.tgz#f4a3d302976aaf042356ba1ade3b1a2c62d9d794" -colors@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - colors@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" @@ -2881,6 +2948,10 @@ cors@^2.8.3: object-assign "^4" vary "^1" +corser@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87" + cosmiconfig@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-1.1.0.tgz#0dea0f9804efdfb929fbb1b188e25553ea053d37" @@ -2906,6 +2977,25 @@ cosmiconfig@^2.1.0, cosmiconfig@^2.1.1: parse-json "^2.2.0" require-from-string "^1.1.0" +couleurs@^6.0.0: + version "6.0.9" + resolved "https://registry.yarnpkg.com/couleurs/-/couleurs-6.0.9.tgz#b2b2a3ee37dae51875c9efd243ec7e7894afbc9e" + dependencies: + ansy "^1.0.0" + color-convert "^1.0.0" + iterate-object "^1.3.1" + typpy "^2.3.1" + +cp-file@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/cp-file/-/cp-file-4.2.0.tgz#715361663b71ede0b6dddbc3c80e2ba02e725ec3" + dependencies: + graceful-fs "^4.1.2" + make-dir "^1.0.0" + nested-error-stacks "^2.0.0" + pify "^2.3.0" + safe-buffer "^5.0.1" + crc@3.4.4: version "3.4.4" resolved "https://registry.yarnpkg.com/crc/-/crc-3.4.4.tgz#9da1e980e3bd44fc5c93bf5ab3da3378d85e466b" @@ -2987,6 +3077,13 @@ cross-spawn@5.1.0, cross-spawn@^5.0.1, cross-spawn@^5.1.0: shebang-command "^1.2.0" which "^1.2.9" +cross-spawn@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-4.0.2.tgz#7b9247621c23adfdd3856004a823cbe397424d41" + dependencies: + lru-cache "^4.0.1" + which "^1.2.9" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -3153,6 +3250,12 @@ curry2@^1.0.0: dependencies: fast-bind "^1.0.0" +custom-return@^1.0.0: + version "1.0.10" + resolved "https://registry.yarnpkg.com/custom-return/-/custom-return-1.0.10.tgz#ba875b2a97c9fba1fc12729ce1c21eeaa5de6a0c" + dependencies: + noop6 "^1.0.0" + cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" @@ -3394,6 +3497,12 @@ default-require-extensions@^1.0.0: dependencies: strip-bom "^2.0.0" +deffy@^2.2.1, deffy@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/deffy/-/deffy-2.2.2.tgz#088f40913cb47078653fa6f697c206e03471d523" + dependencies: + typpy "^2.0.0" + define-properties@^1.1.1, define-properties@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" @@ -3865,6 +3974,15 @@ ecdsa-sig-formatter@1.0.9: base64url "^2.0.0" safe-buffer "^5.0.1" +ecstatic@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ecstatic/-/ecstatic-2.2.1.tgz#b5087fad439dd9dd49d31e18131454817fe87769" + dependencies: + he "^1.1.1" + mime "^1.2.11" + minimist "^1.1.0" + url-join "^2.0.2" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -4377,6 +4495,10 @@ eventemitter3@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba" +eventemitter3@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.0.1.tgz#4ce66c3fc5b5a6b9f2245e359e1938f1ab10f960" + events@^1.0.0, events@^1.1.0, events@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -4853,7 +4975,7 @@ flow-typed@^2.1.5: which "^1.3.0" yargs "^4.2.0" -follow-redirects@^1.2.5: +follow-redirects@^1.0.0, follow-redirects@^1.2.5: version "1.4.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.4.1.tgz#d8120f4518190f55aac65bb6fc7b85fcd666d6aa" dependencies: @@ -5039,6 +5161,12 @@ function-bind@^1.0.2, function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" +function.name@^1.0.3: + version "1.0.10" + resolved "https://registry.yarnpkg.com/function.name/-/function.name-1.0.10.tgz#ed00c828d98ff9a80186926fd439bdd7c16d4f0d" + dependencies: + noop6 "^1.0.1" + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" @@ -5510,7 +5638,7 @@ hawk@~6.0.2: hoek "4.x.x" sntp "2.x.x" -he@1.1.x: +he@1.1.x, he@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" @@ -5741,6 +5869,27 @@ http-proxy@^1.16.2: eventemitter3 "1.x.x" requires-port "1.x.x" +http-proxy@^1.8.1: + version "1.17.0" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" + dependencies: + eventemitter3 "^3.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +http-server@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/http-server/-/http-server-0.10.0.tgz#b2a446b16a9db87ed3c622ba9beb1b085b1234a7" + dependencies: + colors "1.0.3" + corser "~2.0.0" + ecstatic "^2.0.0" + http-proxy "^1.8.1" + opener "~1.4.0" + optimist "0.6.x" + portfinder "^1.0.13" + union "~0.4.3" + http-signature@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" @@ -6456,6 +6605,10 @@ iterall@^1.1.3, iterall@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" +iterate-object@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/iterate-object/-/iterate-object-1.3.2.tgz#24ec15affa5d0039e8839695a21c2cae1f45b66b" + jest-changed-files@^22.4.3: version "22.4.3" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-22.4.3.tgz#8882181e022c38bd46a2e4d18d44d19d90a90fb2" @@ -6745,7 +6898,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" -js-yaml@^3.4.3, js-yaml@^3.7.0, js-yaml@^3.9.1: +js-yaml@^3.4.3, js-yaml@^3.7.0, js-yaml@^3.8.2, js-yaml@^3.9.1: version "3.11.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef" dependencies: @@ -7693,6 +7846,24 @@ moment@2.x.x, "moment@>= 2.9.0", moment@^2.11.2, moment@^2.15.2, moment@^2.18.1, version "2.22.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.0.tgz#7921ade01017dd45186e7fee5f424f0b8663a730" +mrm-core@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mrm-core/-/mrm-core-1.1.0.tgz#97df4be3bc208d623519d6a18f18407ef79aa226" + dependencies: + babel-code-frame "^6.22.0" + chalk "^1.1.3" + cp-file "^4.1.1" + js-yaml "^3.8.2" + lodash "^4.17.4" + mkdirp "^0.5.1" + prop-ini "^0.0.2" + readme-badger "^0.1.2" + split-lines "^1.1.0" + strip-bom "^3.0.0" + strip-json-comments "^2.0.1" + webpack-merge "^4.0.0" + yarn-install "^0.2.1" + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -7761,6 +7932,12 @@ neo-async@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.5.0.tgz#76b1c823130cca26acfbaccc8fbaf0a2fa33b18f" +nested-error-stacks@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.0.tgz#98b2ffaefb4610fa3936f1e71435d30700de2840" + dependencies: + inherits "~2.0.1" + next-tick@1: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" @@ -7870,6 +8047,10 @@ nodemon@^1.11.0: undefsafe "^2.0.2" update-notifier "^2.3.0" +noop6@^1.0.0, noop6@^1.0.1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/noop6/-/noop6-1.0.7.tgz#96767bf2058ba59ca8cb91559347ddc80239fa8e" + nopt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" @@ -8077,10 +8258,14 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" -opener@^1.4.3: +opener@^1.4.3, opener@~1.4.0: version "1.4.3" resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8" +openport@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/openport/-/openport-0.0.4.tgz#1d6715d8a8789695f985fa84f68dd4cd1ba426cb" + opn@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.1.0.tgz#72ce2306a17dbea58ff1041853352b4a8fc77519" @@ -8121,7 +8306,7 @@ optimist@0.6.0: minimist "~0.0.1" wordwrap "~0.0.2" -optimist@^0.6.1, optimist@~0.6.0: +optimist@0.6.x, optimist@^0.6.1, optimist@~0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" dependencies: @@ -8473,7 +8658,7 @@ performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" -pify@^2.0.0: +pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -8495,6 +8680,24 @@ pinpoint@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pinpoint/-/pinpoint-1.1.0.tgz#0cf7757a6977f1bf7f6a32207b709e377388e874" +pixel-bg@^1.0.0: + version "1.0.8" + resolved "https://registry.yarnpkg.com/pixel-bg/-/pixel-bg-1.0.8.tgz#24292649c50566558fbf030890f6919e14366c58" + dependencies: + pixel-class "^1.0.0" + +pixel-class@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/pixel-class/-/pixel-class-1.0.7.tgz#904a858f1d4a0cc032d461a1e47e7bc0ec7def23" + dependencies: + deffy "^2.2.1" + +pixel-white-bg@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/pixel-white-bg/-/pixel-white-bg-1.0.7.tgz#0cfbc5b385e4cd8e5da33662f896bc9958399587" + dependencies: + pixel-bg "^1.0.0" + pkg-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4" @@ -8535,7 +8738,7 @@ popper.js@^1.14.1: version "1.14.3" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.3.tgz#1438f98d046acf7b4d78cd502bf418ac64d4f095" -portfinder@^1.0.9: +portfinder@^1.0.13, portfinder@^1.0.9: version "1.0.13" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.13.tgz#bb32ecd87c27104ae6ee44b5a3ccbf0ebb1aede9" dependencies: @@ -8948,6 +9151,12 @@ prompt@0.2.14: utile "0.2.x" winston "0.8.x" +prop-ini@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/prop-ini/-/prop-ini-0.0.2.tgz#6733a7cb5242acab2be42e607583d8124b172a5b" + dependencies: + extend "^3.0.0" + prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0: version "15.6.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca" @@ -9025,6 +9234,10 @@ qs@6.5.1, qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" +qs@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-2.3.3.tgz#e9e85adbe75da0bbe4c8e0476a086290f863b404" + qs@~6.0.4: version "6.0.4" resolved "https://registry.yarnpkg.com/qs/-/qs-6.0.4.tgz#51019d84720c939b82737e84556a782338ecea7b" @@ -9514,6 +9727,10 @@ readline-sync@^1.4.7: version "1.4.9" resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.9.tgz#3eda8e65f23cd2a17e61301b1f0003396af5ecda" +readme-badger@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/readme-badger/-/readme-badger-0.1.2.tgz#81b138df9723c733df6a27c7bd9caebd383e08a5" + realpath-native@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.0.0.tgz#7885721a83b43bd5327609f0ddecb2482305fdf0" @@ -9802,7 +10019,7 @@ require-uncached@^1.0.3: caller-path "^0.1.0" resolve-from "^1.0.0" -requires-port@1.0.x, requires-port@1.x.x, requires-port@~1.0.0: +requires-port@1.0.x, requires-port@1.x.x, requires-port@^1.0.0, requires-port@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" @@ -10429,6 +10646,10 @@ spdy@^3.4.1: select-hose "^2.0.0" spdy-transport "^2.0.18" +split-lines@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/split-lines/-/split-lines-1.1.0.tgz#3abba8f598614142f9db8d27ab6ab875662a1e09" + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -10625,14 +10846,14 @@ strip-indent@^1.0.1: dependencies: get-stdin "^4.0.1" +strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + strip-json-comments@~0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-0.1.3.tgz#164c64e370a8a3cc00c9e01b539e569823f0ee54" -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - stripe@^4.15.0: version "4.25.0" resolved "https://registry.yarnpkg.com/stripe/-/stripe-4.25.0.tgz#16af99c255e4fe22adbaf629f392af0715370760" @@ -10701,6 +10922,10 @@ subscriptions-transport-ws@0.9.x: symbol-observable "^1.0.4" ws "^3.0.0" +suffix@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/suffix/-/suffix-0.1.1.tgz#cc58231646a0ef1102f79478ef3a9248fd9c842f" + supports-color@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5" @@ -10886,7 +11111,7 @@ test-exclude@^4.2.1: read-pkg-up "^1.0.1" require-main-filename "^1.0.1" -text-table@0.2.0, text-table@~0.2.0: +text-table@0.2.0, text-table@^0.2.0, text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -11081,6 +11306,12 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" +typpy@^2.0.0, typpy@^2.3.1, typpy@^2.3.4: + version "2.3.10" + resolved "https://registry.yarnpkg.com/typpy/-/typpy-2.3.10.tgz#63a39e4171cbbb4cdefb590009228a3de9a22b2f" + dependencies: + function.name "^1.0.3" + ua-parser-js@^0.7.9: version "0.7.17" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" @@ -11138,6 +11369,13 @@ uid2@0.0.x: version "0.0.3" resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" +ul@^5.2.1: + version "5.2.13" + resolved "https://registry.yarnpkg.com/ul/-/ul-5.2.13.tgz#9ff0504ea35ca1f74c0bf59e6480def009bad7b5" + dependencies: + deffy "^2.2.2" + typpy "^2.3.4" + ultron@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" @@ -11161,6 +11399,12 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^0.4.3" +union@~0.4.3: + version "0.4.6" + resolved "https://registry.yarnpkg.com/union/-/union-0.4.6.tgz#198fbdaeba254e788b0efcb630bc11f24a2959e0" + dependencies: + qs "~2.3.3" + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" @@ -11277,6 +11521,10 @@ urix@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" +url-join@^2.0.2: + version "2.0.5" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-2.0.5.tgz#5af22f18c052a000a48d7b82c5e9c2e2feeda728" + url-loader@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7" @@ -11536,6 +11784,13 @@ webpack-bundle-analyzer@^2.9.1: opener "^1.4.3" ws "^4.0.0" +webpack-defaults@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/webpack-defaults/-/webpack-defaults-1.6.0.tgz#0eb33b36860e3bafbf035f78ca06139f658b3dda" + dependencies: + chalk "^1.1.3" + mrm-core "^1.1.0" + webpack-dev-middleware@^1.11.0: version "1.12.2" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.12.2.tgz#f8fc1120ce3b4fc5680ceecb43d777966b21105e" @@ -11585,6 +11840,12 @@ webpack-manifest-plugin@1.3.2: fs-extra "^0.30.0" lodash ">=3.5 <5" +webpack-merge@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.1.2.tgz#5d372dddd3e1e5f8874f5bf5a8e929db09feb216" + dependencies: + lodash "^4.17.5" + webpack-node-externals@^1.6.0: version "1.7.2" resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz#6e1ee79ac67c070402ba700ef033a9b8d52ac4e3" @@ -12009,6 +12270,14 @@ yargs@~3.10.0: decamelize "^1.0.0" window-size "0.1.0" +yarn-install@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/yarn-install/-/yarn-install-0.2.1.tgz#43841c12d7099a481f89fbfa6ca49d3bf92d15e3" + dependencies: + cac "^3.0.3" + chalk "^1.1.3" + cross-spawn "^4.0.2" + yauzl@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005"