diff --git a/analytics/utils/transformations.js b/analytics/utils/transformations.js index dfc6a0a377..3fdac44b75 100644 --- a/analytics/utils/transformations.js +++ b/analytics/utils/transformations.js @@ -10,7 +10,7 @@ import type { DBMessage, DBUsersThreads, } from 'shared/types'; -import { getTruthyValuesFromObject } from './truthy-values'; +import { getTruthyValuesFromObject } from 'shared/truthy-values'; type AnalyticsChannel = { id: ?string, diff --git a/api/models/utils.js b/api/models/utils.js index 68f28120d2..d0bb9c9883 100644 --- a/api/models/utils.js +++ b/api/models/utils.js @@ -53,7 +53,11 @@ export const getAu = (range: Timeframe) => { const { current } = parseRange(range); return db .table('users') - .filter(db.row('lastSeen').during(db.now().sub(current), db.now())) + .filter(row => + row + .hasFields('lastSeen') + .and(row('lastSeen').during(db.now().sub(current), db.now())) + ) .count() .default(0) .run(); @@ -102,6 +106,8 @@ export const getCount = (table: string, filter: mixed) => { export const getCoreMetrics = () => { return db .table('coreMetrics') + .orderBy(db.desc('date')) + .limit(90) .orderBy('date') .run(); }; diff --git a/chronos/models/coreMetrics.js b/chronos/models/coreMetrics.js index 48ef70f9e3..a3af12b31e 100644 --- a/chronos/models/coreMetrics.js +++ b/chronos/models/coreMetrics.js @@ -38,7 +38,11 @@ export const getAu = (range: string) => { const RANGE = parseRange(range); return db .table('users') - .filter(db.row('lastSeen').during(db.now().sub(RANGE), db.now())) + .filter(row => + row + .hasFields('lastSeen') + .and(row('lastSeen').during(db.now().sub(RANGE), db.now())) + ) .count() .default(0) .run(); diff --git a/chronos/models/usersSettings.js b/chronos/models/usersSettings.js index 3a5d362c5f..b6d99e3b30 100644 --- a/chronos/models/usersSettings.js +++ b/chronos/models/usersSettings.js @@ -28,8 +28,12 @@ export const getUsersForDigest = ( // save some processing time by making sure the user has a username .filter(row => row.hasFields('username').and(row('username').ne(null))) // save some processing time by making sure the user was active in the last month - .filter( - db.row('lastSeen').during(db.now().sub(60 * 60 * 24 * 30), db.now()) + .filter(row => + row + .hasFields('lastSeen') + .and( + row('lastSeen').during(db.now().sub(60 * 60 * 24 * 30), db.now()) + ) ) .pluck(['userId', 'email', 'firstName', 'name', 'username']) .distinct() diff --git a/config-overrides.js b/config-overrides.js index 4bfb9d465f..4ec428f269 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -8,6 +8,7 @@ const debug = require('debug')('build:config-overrides'); const webpack = require('webpack'); const { injectBabelPlugin } = require('react-app-rewired'); const rewireStyledComponents = require('react-app-rewire-styled-components'); +const rewireReactHotLoader = require('react-app-rewire-hot-loader'); const swPrecachePlugin = require('sw-precache-webpack-plugin'); const fs = require('fs'); const path = require('path'); @@ -74,15 +75,19 @@ const transpileShared = config => { module.exports = function override(config, env) { if (process.env.NODE_ENV === 'development') { config.output.path = path.join(__dirname, './build'); + config = rewireReactHotLoader(config, env); + config.plugins.push( + WriteFilePlugin({ + log: true, + useHashIndex: false, + }) + ); } config.plugins.push( new ReactLoadablePlugin({ filename: './build/react-loadable.json', }) ); - if (process.env.NODE_ENV === 'production') { - removeEslint(config); - } config = injectBabelPlugin('react-loadable/babel', config); config = transpileShared(config); // Filter the default serviceworker plugin, add offline plugin instead @@ -126,14 +131,6 @@ 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({ - log: true, - useHashIndex: false, - }) - ); - } config.plugins.unshift( new webpack.optimize.CommonsChunkPlugin({ names: ['bootstrap'], @@ -142,6 +139,7 @@ module.exports = function override(config, env) { }) ); if (process.env.NODE_ENV === 'production') { + removeEslint(config); config.plugins.push( new webpack.DefinePlugin({ 'process.env': { diff --git a/cypress/integration/concierge_spec.js b/cypress/integration/concierge_spec.js new file mode 100644 index 0000000000..1187462752 --- /dev/null +++ b/cypress/integration/concierge_spec.js @@ -0,0 +1,9 @@ +describe('Renders conceirge page ', () => { + beforeEach(() => { + cy.visit(`/pricing/concierge`); + }); + + it('should render key conceirge page components', () => { + cy.get('[data-cy="concierge-page"]').should('be.visible'); + }); +}); diff --git a/flow-typed/npm/electron-context-menu_vx.x.x.js b/flow-typed/npm/electron-context-menu_vx.x.x.js new file mode 100644 index 0000000000..efae92b65f --- /dev/null +++ b/flow-typed/npm/electron-context-menu_vx.x.x.js @@ -0,0 +1,18 @@ +// flow-typed signature: fa2daa44307abd285e881b992fa9d76c +// flow-typed version: <>/electron-context-menu_v0.9.1/flow_v0.66.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'electron-context-menu' + * + * 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 'electron-context-menu' { + declare module.exports: any; +} diff --git a/hermes/send-email.js b/hermes/send-email.js index 5eca3a86dd..dd540bf456 100644 --- a/hermes/send-email.js +++ b/hermes/send-email.js @@ -59,23 +59,6 @@ const sendEmail = (options: Options) => { return; } - if (!isEmail(To)) { - if (userId) { - trackQueue.add({ - userId: userId, - event: events.EMAIL_BOUNCED, - // we can safely log the To field because it's not a valid email, thus not PII - properties: { - tag: Tag, - to: To, - error: 'To field was not a valid email address', - }, - }); - } - - return; - } - // $FlowFixMe return new Promise((res, rej) => { client.sendEmailWithTemplate( diff --git a/mobile/App.js b/mobile/App.js index 879164ba6f..850c6121c2 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -3,18 +3,18 @@ import Sentry from 'sentry-expo'; import React, { Fragment } from 'react'; import { StatusBar } from 'react-native'; import { SecureStore, AppLoading } from 'expo'; -import { createStore } from 'redux'; import { Provider } from 'react-redux'; import { ApolloProvider } from 'react-apollo'; import { ThemeProvider } from 'styled-components'; import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import { type ApolloClient } from 'apollo-client'; +import { initStore } from './reducers/store'; +import Toasts from './components/Toasts'; import theme from '../shared/theme'; import { createClient } from '../shared/graphql'; import Login from './components/Login'; import TabBar from './views/TabBar'; -import reducers from './reducers'; import { authenticate } from './actions/authentication'; let sentry = Sentry.config( @@ -24,7 +24,7 @@ let sentry = Sentry.config( // Need to guard this for HMR to work if (sentry && sentry.install) sentry.install(); -export const store = createStore(reducers); +export const store = initStore(); type State = { authLoaded: ?boolean, @@ -61,7 +61,9 @@ class App extends React.Component<{}, State> { }; listen = () => { - const { authentication } = store.getState(); + const storeState = store.getState(); + // $FlowFixMe + const authentication = storeState && storeState.authentication; const { token: oldToken } = this.state; if (authentication.token !== oldToken) { this.setState({ @@ -89,6 +91,7 @@ class App extends React.Component<{}, State> { + {!token ? : } diff --git a/mobile/actions/toasts.js b/mobile/actions/toasts.js new file mode 100644 index 0000000000..1d8d7ea146 --- /dev/null +++ b/mobile/actions/toasts.js @@ -0,0 +1,42 @@ +// @flow +import type { Dispatch } from 'redux'; +import type { GlyphTypes } from '../components/Icon/types'; + +type ToastTypes = 'notification' | 'success' | 'error' | 'neutral'; + +export type AddToastType = { + type: ToastTypes, + message: string, + onPressHandler: Function, + icon?: ?GlyphTypes, +}; + +export type ToastType = { + ...$Exact, + id: number, +}; + +export type AddToastActionType = { + type: 'ADD_TOAST', + payload: { + ...$Exact, + }, +}; + +let nextToastId = 0; +export const addToast = (payload: AddToastType) => ( + dispatch: Dispatch +) => { + const id = nextToastId++; + return dispatch({ + type: 'ADD_TOAST', + payload: { + id, + ...payload, + }, + }); +}; + +export const removeToast = (id: number) => { + return { type: 'REMOVE_TOAST', payload: { id } }; +}; diff --git a/mobile/components/ChatInput/input.js b/mobile/components/ChatInput/input.js index f1b967f8ea..c244cd4be5 100644 --- a/mobile/components/ChatInput/input.js +++ b/mobile/components/ChatInput/input.js @@ -46,7 +46,7 @@ class ChatInput extends React.Component { onSubmitEditing={this.submit} /> - + {/* NOTE(@mxstbr): Magic number, otherwise the chatinput is way above the keyboard */} diff --git a/mobile/components/Icon/glyphs.js b/mobile/components/Icon/glyphs.js index 3f12ddc2e7..23caf343f4 100644 --- a/mobile/components/Icon/glyphs.js +++ b/mobile/components/Icon/glyphs.js @@ -827,6 +827,7 @@ export default (glyph: GlyphTypes, color: string) => { return ( @@ -836,6 +837,8 @@ export default (glyph: GlyphTypes, color: string) => { return ( diff --git a/mobile/components/Lists/ThreadListItem/index.js b/mobile/components/Lists/ThreadListItem/index.js index 11dc4e223d..1f1c5b1ef9 100644 --- a/mobile/components/Lists/ThreadListItem/index.js +++ b/mobile/components/Lists/ThreadListItem/index.js @@ -1,5 +1,6 @@ // @flow import React, { Component } from 'react'; +import { connect } from 'react-redux'; import { Share } from 'react-native'; import { connectActionSheet } from '@expo/react-native-action-sheet'; import compose from 'recompose/compose'; @@ -20,6 +21,7 @@ import deleteThreadMutation, { } from '../../../../shared/graphql/mutations/thread/deleteThread'; import pinThreadMutation from '../../../../shared/graphql/mutations/community/pinCommunityThread'; import triggerDeleteAlert from '../../DeleteAlert'; +import { addToast } from '../../../actions/toasts'; type Props = { thread: GetThreadType, @@ -33,6 +35,7 @@ type Props = { pinThread: Function, deleteThread: Function, toggleThreadNotifications: Function, + dispatch: Function, // refetches the parent query that resolved this thread - used when // a thread is deleted and we want to refetch the parent query, regardless // of where that query was called from @@ -75,12 +78,48 @@ class ThreadListItemHandlers extends Component { }; toggleLockThread = () => { - const { thread, setThreadLock } = this.props; + const { thread, setThreadLock, dispatch, navigation } = this.props; return setThreadLock({ threadId: thread.id, value: !thread.isLocked, - }); + }) + .then(() => { + return dispatch( + addToast({ + type: 'neutral', + message: thread.isLocked + ? 'Conversation unlocked' + : 'Conversation locked', + onPressHandler: () => + navigation.navigate({ + routeName: 'Thread', + key: thread.id, + params: { + id: thread.id, + }, + }), + icon: 'checkmark', + }) + ); + }) + .catch(err => { + return dispatch( + addToast({ + type: 'error', + message: 'Unable to lock conversation', + onPressHandler: () => + navigation.navigate({ + routeName: 'Thread', + key: thread.id, + params: { + id: thread.id, + }, + }), + icon: 'checkmark', + }) + ); + }); }; togglePinThread = () => { @@ -295,6 +334,7 @@ class ThreadListItemHandlers extends Component { } export const ThreadListItem = compose( + connect(), setThreadLockMutation, deleteThreadMutation, pinThreadMutation, diff --git a/mobile/components/Login/index.js b/mobile/components/Login/index.js index 34271d76a3..6aa10bc0e9 100644 --- a/mobile/components/Login/index.js +++ b/mobile/components/Login/index.js @@ -17,6 +17,7 @@ import { CodeOfConduct, Link, } from './style'; +import { events, track } from '../../utils/analytics'; const API_URL = process.env.NODE_ENV === 'production' @@ -30,7 +31,12 @@ type Props = { }; class Login extends React.Component { + componentDidMount() { + track(events.LOGIN_PAGE_VIEWED); + } + authenticate = (provider: Provider) => async () => { + track(events.LOGIN_PAGE_AUTH_CLICKED, { provider }); const redirectUrl = AuthSession.getRedirectUrl(); const result = await AuthSession.startAsync({ authUrl: `${API_URL}/auth/${provider}?r=${redirectUrl}&authType=token`, diff --git a/mobile/components/Messages/index.js b/mobile/components/Messages/index.js index c2073c4a6c..fd9204982b 100644 --- a/mobile/components/Messages/index.js +++ b/mobile/components/Messages/index.js @@ -8,7 +8,7 @@ import Message from '../Message'; import InfiniteList from '../InfiniteList'; import { ThreadMargin } from '../../views/Thread/style'; import { sortAndGroupMessages } from '../../../shared/clients/group-messages'; -import { convertTimestampToDate } from '../../../src/helpers/utils'; +import { convertTimestampToDate } from '../../../shared/time-formatting'; import { withCurrentUser } from '../../components/WithCurrentUser'; import RoboText from './RoboText'; import Author from './Author'; diff --git a/mobile/components/Toasts/Toast.js b/mobile/components/Toasts/Toast.js new file mode 100644 index 0000000000..a0fdc1e137 --- /dev/null +++ b/mobile/components/Toasts/Toast.js @@ -0,0 +1,60 @@ +// @flow +import * as React from 'react'; +import { + ToastWrapper, + ToastLabel, + IconContainer, + ViewForwardContainer, +} from './style'; +import type { GlyphTypes } from '../Icon/types'; +import Icon from '../Icon'; + +type Props = {| + type: 'notification' | 'success' | 'neutral' | 'error', + message: string, + onPressHandler: Function, + icon?: ?GlyphTypes, +|}; + +export default class Toast extends React.Component { + render() { + const { + message = 'Toasty! this a asjd al aisdj alsdkj alskdfj alsdkfj alskdfj alsdkf jalsdkf jasldfkj', + icon, + onPressHandler, + } = this.props; + + return ( + + + {icon && ( + + theme.text.reverse} + glyph={icon} + size={24} + /> + + )} + theme.text.reverse} + numberOfLines={1} + ellipsizeMode={'tail'} + > + {message} + + + {onPressHandler && ( + + theme.text.reverse} + glyph={'view-forward'} + size={24} + /> + + )} + + + ); + } +} diff --git a/mobile/components/Toasts/index.js b/mobile/components/Toasts/index.js new file mode 100644 index 0000000000..3c8c1a8014 --- /dev/null +++ b/mobile/components/Toasts/index.js @@ -0,0 +1,148 @@ +// @flow +import * as React from 'react'; +import { withTheme } from 'styled-components'; +import compose from 'recompose/compose'; +import { connect } from 'react-redux'; +import { Animated, Dimensions, Easing } from 'react-native'; +import { removeToast, type ToastType } from '../../actions/toasts'; +import type { State as ReduxState } from '../../reducers'; +import Toast from './Toast'; +import { Container, ToastContainer } from './style'; +import { getToastColorFromType } from './utils'; + +const width = Dimensions.get('window').width; + +type Props = {| + toasts: Array, + theme: Object, + dispatch: Function, +|}; + +type State = { + activeToast: ?ToastType, +}; + +class Toasts extends React.Component { + leftBlockPosition = new Animated.Value(-width); + opacityValue = new Animated.Value(0); + containerTimeoutTiming = 3000; + animationDuration = 200; + removeToastTimeoutTiming = this.containerTimeoutTiming + + this.animationDuration; + hideContainerTimeout = undefined; + + state = { + activeToast: null, + }; + + componentDidUpdate(prevProps) { + const currProps = this.props; + const { dispatch } = currProps; + + const receivedNewToast = currProps.toasts.length > prevProps.toasts.length; + const receivedFirstToast = + prevProps.toasts.length === 0 && currProps.toasts.length === 1; + const lastToastRemoved = + currProps.toasts.length === 0 && prevProps.toasts.length > 0; + + if (lastToastRemoved) { + return this.setState({ activeToast: null }); + } + + if (currProps.toasts.length === 0) return; + + const getActiveToast = () => { + const toast = currProps.toasts[currProps.toasts.length - 1]; + if (!toast) return null; + return { + ...toast, + }; + }; + + const setActiveToast = () => { + const activeToast = getActiveToast(); + if (!activeToast) return; + this.setState({ activeToast }); + setTimeout( + () => dispatch(removeToast(activeToast.id)), + this.removeToastTimeoutTiming + ); + }; + + if (receivedFirstToast) { + setActiveToast(); + this.showContainer(); + this.hideContainerTimeout = setTimeout( + () => this.hideContainer(), + this.containerTimeoutTiming + ); + return; + } + + if (receivedNewToast) { + clearTimeout(this.hideContainerTimeout); + return setActiveToast(); + } + } + + animateContainerPosition = (val: 'in' | 'out') => { + return Animated.spring(this.leftBlockPosition, { + toValue: val === 'in' ? -16 : width, + friction: 8, + tension: 54, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }); + }; + + animateContainerOpacity = (val: 'in' | 'out') => { + return Animated.timing(this.opacityValue, { + toValue: val === 'in' ? 1 : 0, + duration: this.animationDuration, + useNativeDriver: true, + }); + }; + + showContainer = () => { + return Animated.parallel([ + this.animateContainerPosition('in'), + this.animateContainerOpacity('in'), + ]).start(); + }; + + hideContainer = () => { + Animated.parallel([ + this.animateContainerPosition('out'), + this.animateContainerOpacity('out'), + ]).start(); + }; + + render() { + const { theme } = this.props; + const { activeToast } = this.state; + + if (!activeToast) return null; + + return ( + + + + + + ); + } +} + +const map = (state: ReduxState): * => ({ toasts: state.toasts }); +export default compose(connect(map), withTheme)(Toasts); diff --git a/mobile/components/Toasts/style.js b/mobile/components/Toasts/style.js new file mode 100644 index 0000000000..d5bbe11dc8 --- /dev/null +++ b/mobile/components/Toasts/style.js @@ -0,0 +1,54 @@ +// @flow +import styled from 'styled-components/native'; +import { isIPhoneX } from '../../utils/platform'; +import TouchableHighlight from '../TouchableHighlight'; +import { Animated, Dimensions } from 'react-native'; + +const width = Dimensions.get('window').width; + +export const Container = styled.View` + position: absolute; + bottom: ${isIPhoneX() ? '82px' : '48px'}; + left: 0; + right: 0; + width: 100%; + height: 48px; + z-index: 1; + overflow: hidden; +`; + +export const ToastWrapper = styled(TouchableHighlight)` + display: flex; + padding: 12px 8px 12px 16px; + align-items: center; + justify-content: flex-start; + flex-direction: row; + position: relative; +`; + +export const IconContainer = styled.View` + margin-right: 8px; +`; + +export const ToastLabel = styled(Animated.Text)` + font-size: 15px; + font-weight: 600; + color: ${props => props.color(props.theme)}; + display: flex; + flex: 1; +`; + +export const ViewForwardContainer = styled.View` + display: flex; + align-content: flex-end; + justify-content: flex-end; + margin-left: 4px; +`; + +export const ToastContainer = styled(Animated.View)` + display: flex; + flex: 1; + height: 48px; + width: ${width + 32}px; + padding: 0 16px; +`; diff --git a/mobile/components/Toasts/utils.js b/mobile/components/Toasts/utils.js new file mode 100644 index 0000000000..ed0ed79369 --- /dev/null +++ b/mobile/components/Toasts/utils.js @@ -0,0 +1,19 @@ +// @flow + +export const getToastColorFromType = (type: string, theme: Object) => { + switch (type) { + case 'success': { + return theme.success.default; + } + case 'notification': { + return theme.brand.alt; + } + case 'error': { + return theme.warn.default; + } + case 'neutral': + default: { + return theme.text.alt; + } + } +}; diff --git a/mobile/reducers/index.js b/mobile/reducers/index.js index 3ccb4d17e6..d3b9008901 100644 --- a/mobile/reducers/index.js +++ b/mobile/reducers/index.js @@ -1,14 +1,23 @@ // @flow -import { combineReducers } from 'redux'; +import { combineReducers, type Reducer } from 'redux'; import authentication, { type AuthenticationState } from './authentication'; +import toasts, { type ToastsState } from './toasts'; import message from '../../src/reducers/message'; export type State = { +authentication: $Exact, +message: $Exact<$Call>, + +toasts: $Exact, }; -export default combineReducers({ - authentication, - message, -}); +// Allow dependency injection of extra reducers, we need this for SSR +const getReducers = (extraReducers: { [key: string]: Reducer<*, *> }) => { + return combineReducers({ + authentication, + message, + toasts, + ...extraReducers, + }); +}; + +export default getReducers; diff --git a/mobile/reducers/store.js b/mobile/reducers/store.js new file mode 100644 index 0000000000..c258639fd8 --- /dev/null +++ b/mobile/reducers/store.js @@ -0,0 +1,30 @@ +// @flow +import { createStore, compose, applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import getReducers from './index'; + +// this enables the chrome devtools for redux only in development +const composeEnhancers = compose; + +// init the store with the thunkMiddleware which allows us to make async actions play nicely with the store +// Allow dependency injection of extra reducers and middleware, we need this for SSR +export const initStore = ( + // $FlowFixMe + initialState, + // $FlowFixMe + { middleware = [], reducers = {} } = {} +) => { + if (initialState) { + return createStore( + getReducers(reducers), + initialState, + composeEnhancers(applyMiddleware(...middleware, thunkMiddleware)) + ); + } else { + return createStore( + getReducers(reducers), + {}, + composeEnhancers(applyMiddleware(...middleware, thunkMiddleware)) + ); + } +}; diff --git a/mobile/reducers/toasts.js b/mobile/reducers/toasts.js new file mode 100644 index 0000000000..6226922f86 --- /dev/null +++ b/mobile/reducers/toasts.js @@ -0,0 +1,25 @@ +// @flow +import type { ToastType, AddToastActionType } from '../actions/toasts'; + +const initialState = []; + +export type ToastsState = Array; + +export default function toasts( + state: ToastsState = initialState, + action: AddToastActionType +) { + switch (action.type) { + case 'ADD_TOAST': { + return [...state, action.payload]; + } + case 'REMOVE_TOAST': { + const toasts = state.filter( + toast => toast && toast.id !== action.payload.id + ); + return toasts; + } + default: + return state; + } +} diff --git a/mobile/utils/analytics.js b/mobile/utils/analytics.js new file mode 100644 index 0000000000..f9ee8f26c1 --- /dev/null +++ b/mobile/utils/analytics.js @@ -0,0 +1,29 @@ +// @flow +import { Amplitude } from 'expo'; +import { createAmplitudeHelpers } from '../../shared/clients/analytics'; + +const AMPLITUDE_API_KEY = + process.env.NODE_ENV === 'production' + ? process.env.AMPLITUDE_API_KEY + : process.env.AMPLITUDE_API_KEY_DEVELOPMENT; + +if (AMPLITUDE_API_KEY) { + Amplitude.initialize(AMPLITUDE_API_KEY); +} else { + console.warn( + '[Analytics] No Amplitude API key provided, not tracking events.' + ); +} + +const { + events, + track, + setUser, + unsetUser, + transformations, +} = createAmplitudeHelpers({ + amplitude: { getInstance: () => Amplitude }, + client: 'mobile', +}); + +export { events, track, setUser, unsetUser, transformations }; diff --git a/mobile/views/Channel/index.js b/mobile/views/Channel/index.js index 24726fb796..bb05f46760 100644 --- a/mobile/views/Channel/index.js +++ b/mobile/views/Channel/index.js @@ -9,6 +9,7 @@ import getChannelThreadConnection from '../../../shared/graphql/queries/channel/ import ViewNetworkHandler from '../../components/ViewNetworkHandler'; import ThreadFeed from '../../components/ThreadFeed'; import Loading from '../../components/Loading'; +import { track, transformations, events } from '../../utils/analytics'; import { Wrapper, @@ -25,11 +26,12 @@ import { } from './style'; import ErrorBoundary from '../../components/ErrorBoundary'; import { FullscreenNullState } from '../../components/NullStates'; +import type { NavigationProps } from 'react-navigation'; type Props = { isLoading: boolean, hasError: boolean, - navigation: Object, + navigation: NavigationProps, data: { channel?: GetChannelType, }, @@ -38,6 +40,46 @@ type Props = { const ChannelThreadFeed = compose(getChannelThreadConnection)(ThreadFeed); class Channel extends Component { + trackView = () => { + const { data: { channel } } = this.props; + if (!channel) return; + track(events.CHANNEL_VIEWED, { + channel: transformations.analyticsChannel(channel), + community: transformations.analyticsCommunity(channel.community), + }); + }; + + setTitle = () => { + const { data: { channel }, navigation } = this.props; + let title; + if (channel) { + title = channel.name; + } else { + title = 'Loading channel...'; + } + if (navigation.state.params.title === title) return; + navigation.setParams({ title }); + }; + + componentDidMount() { + this.trackView(); + this.setTitle(); + } + + componentDidUpdate(prev) { + const curr = this.props; + const first = !prev.data.channel && curr.data.channel; + const changed = + prev.data.channel && + curr.data.channel && + prev.data.channel.id !== curr.data.channel.id; + if (first || changed) { + this.trackView(); + } + + this.setTitle(); + } + render() { const { data, isLoading, hasError, navigation } = this.props; if (data.channel) { diff --git a/mobile/views/Community/index.js b/mobile/views/Community/index.js index c87d34732d..3f54db93e7 100644 --- a/mobile/views/Community/index.js +++ b/mobile/views/Community/index.js @@ -12,6 +12,7 @@ import ThreadFeed from '../../components/ThreadFeed'; import { ThreadListItem } from '../../components/Lists'; import { getThreadById } from '../../../shared/graphql/queries/thread/getThread'; import Loading from '../../components/Loading'; +import { track, events, transformations } from '../../utils/analytics'; import { Wrapper, @@ -61,6 +62,45 @@ const RemoteThreadItem = compose(getThreadById, withNavigation)( const CommunityThreadFeed = compose(getCommunityThreads)(ThreadFeed); class Community extends Component { + trackView = () => { + const { data: { community } } = this.props; + if (!community) return; + track(events.COMMUNITY_VIEWED, { + community: transformations.analyticsCommunity(community), + }); + }; + + setTitle = () => { + const { data: { community }, navigation } = this.props; + let title; + if (community) { + title = community.name; + } else { + title = 'Loading community...'; + } + if (navigation.state.params.title === title) return; + navigation.setParams({ title }); + }; + + componentDidMount() { + this.trackView(); + this.setTitle(); + } + + componentDidUpdate(prev) { + const curr = this.props; + const first = !prev.data.community && curr.data.community; + const changed = + prev.data.community && + curr.data.community && + prev.data.community.id !== curr.data.community.id; + if (first || changed) { + this.trackView(); + } + + this.setTitle(); + } + render() { const { data: { community }, isLoading, hasError, navigation } = this.props; diff --git a/mobile/views/Dashboard/index.js b/mobile/views/Dashboard/index.js index 5f12645410..e9e5302c98 100644 --- a/mobile/views/Dashboard/index.js +++ b/mobile/views/Dashboard/index.js @@ -12,6 +12,7 @@ import { } from '../../../shared/graphql/queries/user/getUser'; import { Wrapper } from './style'; import ErrorBoundary from '../../components/ErrorBoundary'; +import { track, events } from '../../utils/analytics'; const EverythingThreadFeed = compose(getCurrentUserEverythingFeed)(ThreadFeed); @@ -23,6 +24,10 @@ type Props = { }; class Dashboard extends Component { + componentDidMount() { + track(events.INBOX_EVERYTHING_VIEWED); + } + render() { const { navigation } = this.props; diff --git a/mobile/views/DirectMessageThread/components/DirectMessageThread.js b/mobile/views/DirectMessageThread/components/DirectMessageThread.js index 927cb4ed49..45ead568ed 100644 --- a/mobile/views/DirectMessageThread/components/DirectMessageThread.js +++ b/mobile/views/DirectMessageThread/components/DirectMessageThread.js @@ -13,6 +13,7 @@ import ViewNetworkHandler, { } from '../../../components/ViewNetworkHandler'; import Loading from '../../../components/Loading'; import ErrorBoundary from '../../../components/ErrorBoundary'; +import { track, events } from '../../../utils/analytics'; import sentencify from '../../../../shared/sentencify'; import getDirectMessageThread, { @@ -40,6 +41,41 @@ type Props = { }; class DirectMessageThread extends Component { + trackView = () => { + const { data: { directMessageThread } } = this.props; + if (!directMessageThread) return; + track(events.DIRECT_MESSAGE_THREAD_VIEWED); + }; + + setTitle = () => { + const { data: { directMessageThread }, navigation } = this.props; + let title = directMessageThread + ? sentencify(directMessageThread.participants.map(({ name }) => name)) + : 'Loading thread...'; + if (navigation.state.params.title === title) return; + navigation.setParams({ title }); + }; + + componentDidMount() { + this.trackView(); + this.setTitle(); + } + + componentDidUpdate(prev) { + const curr = this.props; + const first = + !prev.data.directMessageThread && curr.data.directMessageThread; + const changed = + prev.data.directMessageThread && + curr.data.directMessageThread && + prev.data.directMessageThread.id !== curr.data.directMessageThread.id; + if (first || changed) { + this.trackView(); + } + + this.setTitle(); + } + sendMessage = text => { if (!this.props.data.directMessageThread) return; this.props.sendDirectMessage({ diff --git a/mobile/views/DirectMessageThread/index.js b/mobile/views/DirectMessageThread/index.js index d5dd1badfe..9668259d4d 100644 --- a/mobile/views/DirectMessageThread/index.js +++ b/mobile/views/DirectMessageThread/index.js @@ -8,16 +8,11 @@ import DirectMessageThread from './components/DirectMessageThread'; import { Wrapper } from './style'; import ErrorBoundary from '../../components/ErrorBoundary'; import type { GetUserType } from '../../../shared/graphql/queries/user/getUser'; +import type { NavigationProps } from 'react-navigation'; type Props = { currentUser: ?GetUserType, - navigation?: { - state: { - params: { - id: string, - }, - }, - }, + navigation: NavigationProps, }; class DirectMessageThreadView extends React.Component { diff --git a/mobile/views/DirectMessages/index.js b/mobile/views/DirectMessages/index.js index b7ac724e7c..16e0969af8 100644 --- a/mobile/views/DirectMessages/index.js +++ b/mobile/views/DirectMessages/index.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import DirectMessageThreadsList from './components/DirectMessageThreadsList'; import { Wrapper } from './style'; import ErrorBoundary from '../../components/ErrorBoundary'; +import { track, events } from '../../utils/analytics'; import type { NavigationProps } from 'react-navigation'; type Props = { @@ -10,6 +11,14 @@ type Props = { }; class DirectMessages extends Component { + trackView = () => { + track(events.DIRECT_MESSAGES_VIEWED); + }; + + componentDidMount() { + this.trackView(); + } + render() { const { navigation } = this.props; diff --git a/mobile/views/TabBar/BaseStack.js b/mobile/views/TabBar/BaseStack.js index b0d83800a8..09d44d63b6 100644 --- a/mobile/views/TabBar/BaseStack.js +++ b/mobile/views/TabBar/BaseStack.js @@ -12,26 +12,26 @@ const BaseStack = { Thread: { screen: withMappedNavigationProps(Thread), navigationOptions: ({ navigation }: NavigationScreenConfigProps) => ({ - headerTitle: navigation.state.params.title || 'Thread', + headerTitle: navigation.state.params.title || null, tabBarVisible: false, }), }, Community: { screen: withMappedNavigationProps(Community), navigationOptions: ({ navigation }: NavigationScreenConfigProps) => ({ - headerTitle: null, + headerTitle: navigation.state.params.title || null, }), }, Channel: { screen: withMappedNavigationProps(Channel), navigationOptions: ({ navigation }: NavigationScreenConfigProps) => ({ - headerTitle: null, + headerTitle: navigation.state.params.title || null, }), }, User: { screen: withMappedNavigationProps(User), navigationOptions: ({ navigation }: NavigationScreenConfigProps) => ({ - header: null, + headerTitle: navigation.state.params.title || null, }), }, }; diff --git a/mobile/views/TabBar/index.js b/mobile/views/TabBar/index.js index a4d12a8888..6944dea501 100644 --- a/mobile/views/TabBar/index.js +++ b/mobile/views/TabBar/index.js @@ -17,6 +17,8 @@ import { ProfileIcon, } from './style'; +const IS_PROD = process.env.NODE_ENV === 'production'; + const routeConfiguration = { Home: { screen: HomeStack, @@ -52,12 +54,13 @@ const routeConfiguration = { const tabBarConfiguration = { tabBarOptions: { - activeTintColor: theme.brand.alt, - inactiveTintColor: theme.text.alt, + activeTintColor: IS_PROD ? theme.brand.alt : theme.text.reverse, + inactiveTintColor: IS_PROD ? theme.text.alt : theme.warn.border, labelStyle: { fontWeight: 'bold', marginBottom: 3, }, + style: IS_PROD ? {} : { backgroundColor: theme.warn.alt }, }, }; diff --git a/mobile/views/TabBar/style.js b/mobile/views/TabBar/style.js index b12c8d67a0..f2b04eb92b 100644 --- a/mobile/views/TabBar/style.js +++ b/mobile/views/TabBar/style.js @@ -9,31 +9,35 @@ type IconProps = { }; export const HomeIcon = ({ color }: IconProps) => ( - + ); export const MessageIcon = ({ color }: IconProps) => ( - + ); export const ExploreIcon = ({ color }: IconProps) => ( - + ( ); export const SearchIcon = ({ color }: IconProps) => ( - + ); export const NotificationIcon = ({ color }: IconProps) => ( - + + + - - - - ); export const ProfileIcon = ({ color }: IconProps) => ( - + - diff --git a/mobile/views/Thread/index.js b/mobile/views/Thread/index.js index 3ca01b3fd7..69b67decc7 100644 --- a/mobile/views/Thread/index.js +++ b/mobile/views/Thread/index.js @@ -12,7 +12,7 @@ import Messages from '../../components/Messages'; import ChatInput from '../../components/ChatInput'; import getThreadMessageConnection from '../../../shared/graphql/queries/thread/getThreadMessageConnection'; import sendMessageMutation from '../../../shared/graphql/mutations/message/sendMessage'; -import { convertTimestampToDate } from '../../../src/helpers/utils'; +import { convertTimestampToDate } from '../../../shared/time-formatting'; import { withCurrentUser } from '../../components/WithCurrentUser'; import CommunityHeader from './components/CommunityHeader'; import Byline from './components/Byline'; @@ -24,6 +24,7 @@ import type { NavigationProps } from 'react-navigation'; import Loading from '../../components/Loading'; import ErrorBoundary from '../../components/ErrorBoundary'; import { FullscreenNullState } from '../../components/NullStates'; +import { track, events, transformations } from '../../utils/analytics'; const ThreadMessages = getThreadMessageConnection(Messages); @@ -40,6 +41,47 @@ type Props = { }; class Thread extends Component { + trackView = () => { + const { data: { thread } } = this.props; + if (!thread) return; + track(events.THREAD_VIEWED, { + thread: transformations.analyticsThread(thread), + channel: transformations.analyticsChannel(thread.channel), + community: transformations.analyticsCommunity(thread.community), + }); + }; + + setTitle = () => { + const { data: { thread }, navigation } = this.props; + let title; + if (thread) { + title = thread.content.title; + } else { + title = 'Loading thread...'; + } + if (navigation.state.params.title === title) return; + navigation.setParams({ title }); + }; + + componentDidMount() { + this.trackView(); + this.setTitle(); + } + + componentDidUpdate(prev) { + const curr = this.props; + const first = !prev.data.thread && curr.data.thread; + const changed = + prev.data.thread && + curr.data.thread && + prev.data.thread.id !== curr.data.thread.id; + if (first || changed) { + this.trackView(); + } + + this.setTitle(); + } + sendMessage = (body: string, user: Object) => { const { quotedMessage, data: { thread } } = this.props; if (!thread) return; diff --git a/mobile/views/User/index.js b/mobile/views/User/index.js index 15d64ae429..daa8a95870 100644 --- a/mobile/views/User/index.js +++ b/mobile/views/User/index.js @@ -7,9 +7,11 @@ import { } from '../../../shared/graphql/queries/user/getUser'; import ViewNetworkHandler from '../../components/ViewNetworkHandler'; import Profile from './profile'; +import type { NavigationProps } from 'react-navigation'; type Props = { id: ?string, + navigation: NavigationProps, }; type State = { diff --git a/mobile/views/User/profile.js b/mobile/views/User/profile.js index 33155b8cb1..16cf372a5d 100644 --- a/mobile/views/User/profile.js +++ b/mobile/views/User/profile.js @@ -42,13 +42,24 @@ const UserThreadFeed = compose(getUserThreadConnection)(ThreadFeed); class User extends Component { state = { feed: 'participant' }; - componentDidUpdate() { + setTitle = () => { const { data: { user }, navigation } = this.props; - if (!user) return; - const title = navigation.getParam('title'); - if (!title && user) return navigation.setParams({ title: user.name }); - if (title && title !== user.name) - return navigation.setParams({ title: user.name }); + let title; + if (user) { + title = `${user.name} (@${user.username})`; + } else { + title = 'Loading user...'; + } + if (navigation.state.params.title === title) return; + navigation.setParams({ title }); + }; + + componentDidMount() { + this.setTitle(); + } + + componentDidUpdate() { + this.setTitle(); } toggleFeed = (feed: string) => this.setState({ feed }); diff --git a/package.json b/package.json index b99d3c1ffc..f46f8499eb 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "micromatch": "^3.0.4", "prettier": "^1.0.0", "raw-loader": "^0.5.1", + "react-app-rewire-hot-loader": "^1.0.1", + "react-hot-loader": "^4.3.1", "react-scripts": "^1.0.0", "rimraf": "^2.6.1", "sw-precache-webpack-plugin": "^0.11.4", diff --git a/shared/analytics/event-types/index.js b/shared/analytics/event-types/index.js index bd133befea..aaf746b30a 100644 --- a/shared/analytics/event-types/index.js +++ b/shared/analytics/event-types/index.js @@ -14,6 +14,7 @@ import * as pageHomeEvents from './page-home' import * as pageInboxEvents from './page-inbox' import * as pageLoginEvents from './page-login' import * as pagePricingEvents from './page-pricing' +import * as pageConciergeEvents from './page-concierge' import * as pageSupportEvents from './page-support' import * as pwaEvents from './pwa' import * as reactionEvents from './reaction' @@ -39,6 +40,7 @@ export const events = { ...pageInboxEvents, ...pageLoginEvents, ...pagePricingEvents, + ...pageConciergeEvents, ...pageSupportEvents, ...pagePricingEvents, ...pwaEvents, diff --git a/shared/analytics/event-types/page-concierge.js b/shared/analytics/event-types/page-concierge.js new file mode 100644 index 0000000000..315a72f9bd --- /dev/null +++ b/shared/analytics/event-types/page-concierge.js @@ -0,0 +1,5 @@ +// @flow + +export const CONCIERGE_PAGE_VIEWED = 'Concierge Page Viewed'; +export const CONCIERGE_PAGE_CONTACT_US_CLICKED = 'Concierge Page Contact Us Clicked'; +export const CONCIERGE_PAGE_LEARN_MORE_CLICKED = 'Concierge Page Learn More Clicked'; \ No newline at end of file diff --git a/shared/clients/analytics/index.js b/shared/clients/analytics/index.js new file mode 100644 index 0000000000..f7ae6c4516 --- /dev/null +++ b/shared/clients/analytics/index.js @@ -0,0 +1,32 @@ +// @flow +import { events } from '../../analytics'; +import { createTrack } from './track'; +import { createSetUser } from './setUser'; +import { createUnsetUser } from './unsetUser'; +import * as transformations from './transformations'; +require('./raven'); + +export type AmplitudeClient = 'web' | 'desktop' | 'mobile'; + +export type Amplitude = { + // NOTE(@mxstbr): The web client has a .getInstance() method you have to call each time, + // but the mobile client doesn't, so we just manually add that on mobile + getInstance: () => { + setUserId: (userId: ?string) => Promise, + logEvent: (eventType: string, eventParams: Object) => Promise, + }, +}; + +type CreateAmplitudeHelpersInput = { + amplitude: Object, + client: AmplitudeClient, +}; + +export const createAmplitudeHelpers = (input: CreateAmplitudeHelpersInput) => { + const { amplitude, client } = input; + const track = createTrack(amplitude, client); + const setUser = createSetUser(amplitude); + const unsetUser = createUnsetUser(amplitude); + + return { events, track, setUser, unsetUser, transformations }; +}; diff --git a/src/helpers/analytics/raven.js b/shared/clients/analytics/raven.js similarity index 100% rename from src/helpers/analytics/raven.js rename to shared/clients/analytics/raven.js diff --git a/src/helpers/analytics/setUser.js b/shared/clients/analytics/setUser.js similarity index 83% rename from src/helpers/analytics/setUser.js rename to shared/clients/analytics/setUser.js index d4848d274c..a6d36395e4 100644 --- a/src/helpers/analytics/setUser.js +++ b/shared/clients/analytics/setUser.js @@ -1,8 +1,7 @@ // @flow +import type { Amplitude } from './'; -const amplitude = window.amplitude; - -export const setUser = (userId: string) => { +export const createSetUser = (amplitude: Amplitude) => (userId: string) => { if (!amplitude) { console.warn('No amplitude function attached to window'); return; diff --git a/src/helpers/analytics/track.js b/shared/clients/analytics/track.js similarity index 74% rename from src/helpers/analytics/track.js rename to shared/clients/analytics/track.js index 96a4c0e2b0..7b5641be2b 100644 --- a/src/helpers/analytics/track.js +++ b/shared/clients/analytics/track.js @@ -1,7 +1,10 @@ // @flow -import { isDesktopApp } from '../is-desktop-app'; -const amplitude = window.amplitude; -export const track = (eventType: string, eventProperties?: Object = {}) => { +import type { AmplitudeClient, Amplitude } from './'; + +export const createTrack = (amplitude: Amplitude, client: AmplitudeClient) => ( + eventType: string, + eventProperties?: Object = {} +) => { if (!amplitude) { console.warn('No amplitude function attached to window'); return; @@ -21,7 +24,7 @@ export const track = (eventType: string, eventProperties?: Object = {}) => { // console.warn(`[Amplitude] Tracking ${eventType}`); return amplitude.getInstance().logEvent(eventType, { ...eventProperties, - client: isDesktopApp() ? 'desktop' : 'web', + client, }); }; diff --git a/src/helpers/analytics/transformations.js b/shared/clients/analytics/transformations.js similarity index 96% rename from src/helpers/analytics/transformations.js rename to shared/clients/analytics/transformations.js index 56ebdaca60..14b9fe747e 100644 --- a/src/helpers/analytics/transformations.js +++ b/shared/clients/analytics/transformations.js @@ -2,7 +2,7 @@ import type { ChannelInfoType } from 'shared/graphql/fragments/channel/channelInfo'; import type { CommunityInfoType } from 'shared/graphql/fragments/community/communityInfo'; import type { ThreadInfoType } from 'shared/graphql/fragments/thread/threadInfo'; -import { getTruthyValuesFromObject } from 'src/helpers/utils'; +import { getTruthyValuesFromObject } from '../../truthy-values'; type AnalyticsChannel = { id: string, diff --git a/src/helpers/analytics/unsetUser.js b/shared/clients/analytics/unsetUser.js similarity index 84% rename from src/helpers/analytics/unsetUser.js rename to shared/clients/analytics/unsetUser.js index 46aecdc034..b560d4d8d9 100644 --- a/src/helpers/analytics/unsetUser.js +++ b/shared/clients/analytics/unsetUser.js @@ -1,8 +1,7 @@ // @flow +import type { Amplitude } from './'; -const amplitude = window.amplitude; - -export const unsetUser = () => { +export const createUnsetUser = (amplitude: Amplitude) => () => { if (!amplitude) { console.warn('No amplitude function attached to window'); return; diff --git a/shared/time-formatting.js b/shared/time-formatting.js new file mode 100644 index 0000000000..006a9e1a49 --- /dev/null +++ b/shared/time-formatting.js @@ -0,0 +1,49 @@ +// @flow + +export const convertTimestampToDate = (timestamp: number) => { + let monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + let date = new Date(timestamp); + let day = date.getDate(); + let monthIndex = date.getMonth(); + let month = monthNames[monthIndex]; + let year = date.getFullYear(); + let hours = date.getHours() || 0; + let cleanHours; + if (hours === 0) { + cleanHours = 12; // if timestamp is between midnight and 1am, show 12:XX am + } else { + cleanHours = hours > 12 ? hours - 12 : hours; // else show proper am/pm -- todo: support 24hr time + } + let minutes = date.getMinutes(); + minutes = minutes >= 10 ? minutes : '0' + minutes.toString(); // turns 4 minutes into 04 minutes + let ampm = hours >= 12 ? 'pm' : 'am'; // todo: support 24hr time + return `${month} ${day}, ${year} · ${cleanHours}:${minutes}${ampm}`; +}; + +export const convertTimestampToTime = (timestamp: Date) => { + let date = new Date(timestamp); + let hours = date.getHours() || 0; + let cleanHours; + if (hours === 0) { + cleanHours = 12; // if timestamp is between midnight and 1am, show 12:XX am + } else { + cleanHours = hours > 12 ? hours - 12 : hours; // else show proper am/pm -- todo: support 24hr time + } + let minutes = date.getMinutes(); + minutes = minutes >= 10 ? minutes : '0' + minutes.toString(); // turns 4 minutes into 04 minutes + let ampm = hours >= 12 ? 'pm' : 'am'; // todo: support 24hr time + return `${cleanHours}:${minutes}${ampm}`; +}; diff --git a/analytics/utils/truthy-values.js b/shared/truthy-values.js similarity index 100% rename from analytics/utils/truthy-values.js rename to shared/truthy-values.js diff --git a/src/components/listItems/index.js b/src/components/listItems/index.js index d526c4e4ad..b99ccad954 100644 --- a/src/components/listItems/index.js +++ b/src/components/listItems/index.js @@ -5,7 +5,7 @@ import compose from 'recompose/compose'; import Icon from '../icons'; import Badge from '../badges'; import Avatar from '../avatar'; -import { convertTimestampToDate } from '../../helpers/utils'; +import { convertTimestampToDate } from 'shared/time-formatting'; import Reputation from '../reputation'; import { Wrapper, diff --git a/src/components/messageGroup/index.js b/src/components/messageGroup/index.js index 67fe6cc236..e1ec336c5d 100644 --- a/src/components/messageGroup/index.js +++ b/src/components/messageGroup/index.js @@ -2,7 +2,7 @@ import React, { Component, Fragment } from 'react'; import { connect } from 'react-redux'; import Link from 'src/components/link'; -import { convertTimestampToDate } from '../../helpers/utils'; +import { convertTimestampToDate } from 'shared/time-formatting'; import Badge from '../badges'; import Avatar from '../avatar'; import Message from '../message'; diff --git a/src/helpers/analytics/index.js b/src/helpers/analytics/index.js index c700c40442..6a84588c5a 100644 --- a/src/helpers/analytics/index.js +++ b/src/helpers/analytics/index.js @@ -1,9 +1,16 @@ // @flow -import { events } from 'shared/analytics'; -import { track } from './track'; -import { setUser } from './setUser'; -import { unsetUser } from './unsetUser'; -import * as transformations from './transformations'; -require('./raven'); +import { createAmplitudeHelpers } from 'shared/clients/analytics'; +import { isDesktopApp } from 'src/helpers/is-desktop-app'; + +const { + events, + track, + setUser, + unsetUser, + transformations, +} = createAmplitudeHelpers({ + amplitude: window.amplitude, + client: isDesktopApp() ? 'desktop' : 'web', +}); export { events, track, setUser, unsetUser, transformations }; diff --git a/src/helpers/is-viewing-marketing-page.js b/src/helpers/is-viewing-marketing-page.js index 9ddb853a27..984af51845 100644 --- a/src/helpers/is-viewing-marketing-page.js +++ b/src/helpers/is-viewing-marketing-page.js @@ -13,6 +13,7 @@ export const isViewingMarketingPage = ( viewing === '/about' || viewing === '/code-of-conduct' || viewing === '/contact' || + viewing === '/pricing/concierge' || viewing === '/pricing' || viewing === '/privacy' || viewing === '/privacy.html' || diff --git a/src/helpers/utils.js b/src/helpers/utils.js index 98defd4358..676403beba 100644 --- a/src/helpers/utils.js +++ b/src/helpers/utils.js @@ -2,54 +2,6 @@ import React from 'react'; import replace from 'string-replace-to-array'; -export const convertTimestampToDate = (timestamp: number) => { - let monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - let date = new Date(timestamp); - let day = date.getDate(); - let monthIndex = date.getMonth(); - let month = monthNames[monthIndex]; - let year = date.getFullYear(); - let hours = date.getHours() || 0; - let cleanHours; - if (hours === 0) { - cleanHours = 12; // if timestamp is between midnight and 1am, show 12:XX am - } else { - cleanHours = hours > 12 ? hours - 12 : hours; // else show proper am/pm -- todo: support 24hr time - } - let minutes = date.getMinutes(); - minutes = minutes >= 10 ? minutes : '0' + minutes.toString(); // turns 4 minutes into 04 minutes - let ampm = hours >= 12 ? 'pm' : 'am'; // todo: support 24hr time - return `${month} ${day}, ${year} · ${cleanHours}:${minutes}${ampm}`; -}; - -export const convertTimestampToTime = (timestamp: Date) => { - let date = new Date(timestamp); - let hours = date.getHours() || 0; - let cleanHours; - if (hours === 0) { - cleanHours = 12; // if timestamp is between midnight and 1am, show 12:XX am - } else { - cleanHours = hours > 12 ? hours - 12 : hours; // else show proper am/pm -- todo: support 24hr time - } - let minutes = date.getMinutes(); - minutes = minutes >= 10 ? minutes : '0' + minutes.toString(); // turns 4 minutes into 04 minutes - let ampm = hours >= 12 ? 'pm' : 'am'; // todo: support 24hr time - return `${cleanHours}:${minutes}${ampm}`; -}; - /* Best guess at if user is on a mobile device. Used in the modal components to determine where the modal should be positioned, how it should close and @@ -173,7 +125,3 @@ export const renderMarkdownLinks = (text: string) => { )); }; - -export const getTruthyValuesFromObject = (object: Object): Array => { - return Object.keys(object).filter(key => object[key] === true); -}; diff --git a/src/index.js b/src/index.js index 5acbd36401..13c151d32d 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import queryString from 'query-string'; import Loadable from 'react-loadable'; import * as OfflinePluginRuntime from 'offline-plugin/runtime'; import { HelmetProvider } from 'react-helmet-async'; +import { hot } from 'react-hot-loader'; import webPushManager from './helpers/web-push-manager'; import { history } from './helpers/history'; import { client } from 'shared/graphql'; @@ -61,13 +62,8 @@ if (t && (!existingUser || !existingUser.currentUser)) { const store = initStore(window.__SERVER_STATE__ || initialState); -const renderMethod = !!window.__SERVER_STATE__ - ? // $FlowIssue - ReactDOM.hydrate - : ReactDOM.render; - -function render() { - return renderMethod( +const App = hot(module)(() => { + return ( @@ -76,7 +72,18 @@ function render() { - , + + ); +}); + +const renderMethod = !!window.__SERVER_STATE__ + ? // $FlowIssue + ReactDOM.hydrate + : ReactDOM.render; + +function render() { + return renderMethod( + , // $FlowIssue document.querySelector('#root') ); diff --git a/src/routes.js b/src/routes.js index 22d5a6da14..bb849d2783 100644 --- a/src/routes.js +++ b/src/routes.js @@ -205,6 +205,7 @@ class Routes extends React.Component<{||}> { + diff --git a/src/views/directMessages/components/style.js b/src/views/directMessages/components/style.js index ab9d7281ef..f0b28f61ee 100644 --- a/src/views/directMessages/components/style.js +++ b/src/views/directMessages/components/style.js @@ -356,7 +356,6 @@ export const SelectedUsersPills = styled.ul` font-size: 16px; padding: 9px 12px; width: 100%; - z-index: ${zIndex.chatInput + 1}; background: #fff; `; diff --git a/src/views/explore/style.js b/src/views/explore/style.js index 0555d91e56..90a67bc7cd 100644 --- a/src/views/explore/style.js +++ b/src/views/explore/style.js @@ -266,6 +266,7 @@ export const SearchWrapper = styled(Card)` box-shadow: ${Shadow.low} ${props => hexa(props.theme.bg.reverse, 0.15)}; transition: ${Transition.hover.off}; z-index: ${zIndex.search}; + border-radius: 8px; &:hover { box-shadow: ${Shadow.high} ${props => hexa(props.theme.bg.reverse, 0.25)}; diff --git a/src/views/pages/components/nav.js b/src/views/pages/components/nav.js index 975aa9bf5a..8ab195d636 100644 --- a/src/views/pages/components/nav.js +++ b/src/views/pages/components/nav.js @@ -77,7 +77,10 @@ class Nav extends React.Component { @@ -134,7 +137,10 @@ class Nav extends React.Component { Pricing diff --git a/src/views/pages/concierge/components/intro.js b/src/views/pages/concierge/components/intro.js new file mode 100644 index 0000000000..f0fc0dcafa --- /dev/null +++ b/src/views/pages/concierge/components/intro.js @@ -0,0 +1,91 @@ +// @flow +import React from 'react'; +import Link from 'src/components/link'; +import Section from 'src/components/themedSection'; +import Feature from '../../pricing/components/feature'; +import { + TwoUp, + Left, + Right, + Heading, + Copy, + CTA, + TextCTA, + PlanSection, + PlanPrice, + PlanDescription, + PlanFeatures, + Actions, +} from '../../pricing/style'; +import { track, events } from 'src/helpers/analytics'; + +type Props = { + goopColor?: string, +}; + +const Intro = (props: Props) => { + const { goopColor = 'bg.reverse' } = props; + return ( +
+ + + Introducing Concierge + + As a team, we’ve spent years building online communities and we’ve + seen how valuable they can be to businesses when managed well. But + that’s a full-time job, and a lot of businesses don’t have the + access or the budget to hire experienced, full-time community + managers. + + + There are a lot of hard problems to solve when you’re starting a + community from scratch, but now you don’t have to face these + problems alone. + + + track(events.CONCIERGE_PAGE_CONTACT_US_CLICKED)} + href={'mailto:hi@spectrum.chat'} + > + + Contact our team + + + + track(events.CONCIERGE_PAGE_LEARN_MORE_CLICKED)} + to={`/thread/c5a6ea22-eb8f-4247-bed4-c322f6177c94`} + > + Learn more + + + + + + Let Spectrum handle the hard parts. + + Concierge makes the most painful elements of community management + easy: + + + + + + + + + + + +
+ ); +}; + +export default Intro; diff --git a/src/views/pages/concierge/index.js b/src/views/pages/concierge/index.js new file mode 100644 index 0000000000..10ea95af8c --- /dev/null +++ b/src/views/pages/concierge/index.js @@ -0,0 +1,30 @@ +// @flow +import * as React from 'react'; +import PageFooter from '../components/footer'; +import { Wrapper } from '../style'; +import Intro from './components/intro'; +import type { ContextRouter } from 'react-router'; +import { track, events } from 'src/helpers/analytics'; + +type Props = { + ...$Exact, +}; + +class Concierge extends React.Component { + componentDidMount() { + track(events.CONCIERGE_PAGE_VIEWED); + } + + render() { + return ( + + + + + ); + } +} + +export { Intro }; + +export default Concierge; diff --git a/src/views/pages/index.js b/src/views/pages/index.js index 7a368886ea..1210d84eae 100644 --- a/src/views/pages/index.js +++ b/src/views/pages/index.js @@ -4,6 +4,7 @@ import Nav from './components/nav'; import Support from './support'; import Features from './features'; import Pricing from './pricing'; +import Concierge from './concierge'; import Home from './home'; import Terms from './terms'; import Privacy from './privacy'; @@ -20,6 +21,9 @@ class Pages extends React.Component { case '/support': { return ; } + case '/pricing/concierge': { + return ; + } case '/pricing': { return ; } diff --git a/src/views/pages/pricing/components/feature.js b/src/views/pages/pricing/components/feature.js index a07c8fd26e..b8fe4d5710 100644 --- a/src/views/pages/pricing/components/feature.js +++ b/src/views/pages/pricing/components/feature.js @@ -6,6 +6,7 @@ import { FeatureTitle, FeatureDescription, FeaturePrice, + FeatureRender, } from '../style'; type Props = { @@ -15,6 +16,7 @@ type Props = { color?: string, priceLabel?: string, hideIconsOnMobile?: boolean, + render?: Function, }; const Feature = (props: Props) => { @@ -24,6 +26,7 @@ const Feature = (props: Props) => { icon = 'checkmark', color = 'success', priceLabel, + render, } = props; return ( @@ -32,6 +35,7 @@ const Feature = (props: Props) => { {title} {subtitle && {subtitle}} {priceLabel && {priceLabel}} + {render && {render()}} ); }; diff --git a/src/views/pages/pricing/components/paid.js b/src/views/pages/pricing/components/paid.js index 94783ae88a..e25d6854ef 100644 --- a/src/views/pages/pricing/components/paid.js +++ b/src/views/pages/pricing/components/paid.js @@ -20,7 +20,7 @@ import Link from 'src/components/link'; class Paid extends React.Component<{}> { render() { return ( -
+
diff --git a/src/views/pages/pricing/index.js b/src/views/pages/pricing/index.js index 302152af85..c40a0efafa 100644 --- a/src/views/pages/pricing/index.js +++ b/src/views/pages/pricing/index.js @@ -6,6 +6,7 @@ import Paid from './components/paid'; import Intro from './components/intro'; import Discount from './components/discount'; import Faq from './components/faq'; +import { Intro as Concierge } from '../concierge'; import type { ContextRouter } from 'react-router'; import { track, events } from 'src/helpers/analytics'; @@ -13,13 +14,7 @@ type Props = { ...$Exact, }; -type State = { - ownsCommunities: boolean, -}; - -class Pricing extends React.Component { - state = { ownsCommunities: false }; - +class Pricing extends React.Component { componentDidMount() { track(events.PRICING_PAGE_VIEWED); } @@ -29,6 +24,7 @@ class Pricing extends React.Component { + diff --git a/src/views/pages/pricing/style.js b/src/views/pages/pricing/style.js index e9a39389a9..674f774c9a 100644 --- a/src/views/pages/pricing/style.js +++ b/src/views/pages/pricing/style.js @@ -1,7 +1,8 @@ // @flow -import styled, { css } from 'styled-components'; -import { Button } from 'src/components/buttons'; +import styled from 'styled-components'; +import { Button, TextButton } from 'src/components/buttons'; import { hexa, zIndex } from 'src/components/globals'; +import Link from 'src/components/link'; export const ContentContainer = styled.div` padding: 128px 32px 72px; @@ -88,6 +89,11 @@ export const CTA = styled(Button)` align-self: flex-start; `; +export const TextCTA = styled(TextButton)` + padding: 16px 24px; + font-size: 18px; +`; + export const TwoUp = styled(ContentContainer)` display: grid; max-width: 100%; @@ -111,14 +117,6 @@ export const TwoUp = styled(ContentContainer)` ${Heading}, ${Copy} { padding: 0px 32px; } - - ${props => - props.reverse && - css` - & + .goop { - display: none; - } - `}; } `; @@ -256,9 +254,9 @@ export const PlanSection = styled.div` @media (max-width: 768px) { max-width: 100%; - padding: 18px 24px; - border-radius: 0; - box-shadow: none; + padding: 24px; + padding-bottom: 32px; + margin: 0 8px; } a { @@ -380,6 +378,19 @@ export const CommunityListActions = styled.div` } `; +export const Actions = styled.div` + display: flex; + flex: none; + align-items: center; + justify-content: flex-start; + margin: 32px; + margin-left: 0; + + @media (max-width: 768px) { + margin-left: 32px; + } +`; + export const CommunityCardButton = styled.button` -webkit-display: none; border-radius: 20px; @@ -510,8 +521,8 @@ export const FeaturePrice = styled.span` export const FeatureWrapper = styled.div` display: grid; grid-template-columns: auto 1fr auto; - grid-template-rows: min-content; - grid-template-areas: 'icon title price' '. description description'; + grid-template-rows: repeat(2, min-content); + grid-template-areas: 'icon title price' '. description description' '. render render'; color: ${props => props.theme[props.color].default}; padding: 16px 0; align-items: center; @@ -532,6 +543,11 @@ export const FeatureWrapper = styled.div` } `; +export const FeatureRender = styled.div` + grid-area: render; + margin-top: 16px; +`; + export const FeatureTitle = styled.p` grid-area: title; font-size: 17px; @@ -550,3 +566,13 @@ export const FeatureDescription = styled.p` padding-right: 24px; margin-top: 8px; `; + +export const ConciergeLink = styled(Link)` + display: flex; + flex: none; + + button { + padding: 12px 16px; + margin-top: 0 !important; + } +`; diff --git a/src/views/thread/components/threadDetail.js b/src/views/thread/components/threadDetail.js index 2f17a68e7e..19728f16b8 100644 --- a/src/views/thread/components/threadDetail.js +++ b/src/views/thread/components/threadDetail.js @@ -4,10 +4,8 @@ import compose from 'recompose/compose'; import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import Link from 'src/components/link'; -import { - getLinkPreviewFromUrl, - convertTimestampToDate, -} from '../../../helpers/utils'; +import { getLinkPreviewFromUrl } from '../../../helpers/utils'; +import { convertTimestampToDate } from 'shared/time-formatting'; import { timeDifference } from 'shared/time-difference'; import isURL from 'validator/lib/isURL'; import { URLS } from '../../../helpers/regexps'; diff --git a/src/views/userSettings/components/recurringPaymentsList.js b/src/views/userSettings/components/recurringPaymentsList.js index 6e31f2f278..342eca6386 100644 --- a/src/views/userSettings/components/recurringPaymentsList.js +++ b/src/views/userSettings/components/recurringPaymentsList.js @@ -6,7 +6,7 @@ import { BillingListItem } from 'src/components/listItems'; import { IconButton } from 'src/components/buttons'; import { UpsellUpgradeToPro } from 'src/components/upsell'; import { openModal } from 'src/actions/modals'; -import { convertTimestampToDate } from 'src/helpers/utils'; +import { convertTimestampToDate } from 'shared/time-formatting'; import getCurrentUserRecurringPayments from 'shared/graphql/queries/user/getCurrentUserRecurringPayments'; import type { GetCurrentUserRecurringPaymentsType } from 'shared/graphql/queries/user/getCurrentUserRecurringPayments'; import { displayLoadingCard } from 'src/components/loading'; diff --git a/yarn.lock b/yarn.lock index 4147c5e700..00f672008a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4529,7 +4529,7 @@ fast-json-stable-stringify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" -fast-levenshtein@~2.0.4: +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -5029,7 +5029,7 @@ global-prefix@^1.0.1: is-windows "^1.0.1" which "^1.2.14" -global@^4.3.2: +global@^4.3.0, global@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" dependencies: @@ -8919,6 +8919,12 @@ react-apollo@2.x: lodash "4.17.5" prop-types "^15.6.0" +react-app-rewire-hot-loader@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-app-rewire-hot-loader/-/react-app-rewire-hot-loader-1.0.1.tgz#511f06d85e1c05d3ea1c3cdaede352e1871183ba" + dependencies: + webpack "^3.6.0" + react-app-rewire-styled-components@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/react-app-rewire-styled-components/-/react-app-rewire-styled-components-3.0.2.tgz#e1acfaff2738af7ff4c4ad557ebd4599d6cda862" @@ -9002,12 +9008,27 @@ react-helmet-async@^0.1.0: prop-types "^15.6.1" shallowequal "^1.0.2" +react-hot-loader@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.3.1.tgz#47f59fe09b97ec655e77a1f90819785eda513a86" + dependencies: + fast-levenshtein "^2.0.6" + global "^4.3.0" + hoist-non-react-statics "^2.5.0" + prop-types "^15.6.1" + react-lifecycles-compat "^3.0.4" + shallowequal "^1.0.2" + react-infinite-scroller-with-scroll-element@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/react-infinite-scroller-with-scroll-element/-/react-infinite-scroller-with-scroll-element-2.0.2.tgz#1a59ee022cd798260593c1322794ed809cd5e2a5" dependencies: prop-types "^15.5.8" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-loadable@^5.4.0: version "5.4.0" resolved "https://registry.yarnpkg.com/react-loadable/-/react-loadable-5.4.0.tgz#3b6b7d51121a7868fd155be848a36e02084742c9" @@ -11388,6 +11409,33 @@ webpack@^3.0.0: webpack-sources "^1.0.1" yargs "^8.0.2" +webpack@^3.6.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.12.0.tgz#3f9e34360370602fcf639e97939db486f4ec0d74" + dependencies: + acorn "^5.0.0" + acorn-dynamic-import "^2.0.0" + ajv "^6.1.0" + ajv-keywords "^3.1.0" + async "^2.1.2" + enhanced-resolve "^3.4.0" + escope "^3.6.0" + interpret "^1.0.0" + json-loader "^0.5.4" + json5 "^0.5.1" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + mkdirp "~0.5.0" + node-libs-browser "^2.0.0" + source-map "^0.5.3" + supports-color "^4.2.1" + tapable "^0.2.7" + uglifyjs-webpack-plugin "^0.4.6" + watchpack "^1.4.0" + webpack-sources "^1.0.1" + yargs "^8.0.2" + websocket-driver@>=0.5.1: version "0.7.0" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"