├── README.md ├── src ├── components │ ├── App │ │ ├── index.js │ │ └── App.js │ ├── Email │ │ ├── index.js │ │ └── Email.js │ ├── Avatar │ │ ├── index.js │ │ └── Avatar.js │ ├── Button │ │ ├── index.js │ │ └── Button.js │ ├── Header │ │ ├── index.js │ │ └── Header.js │ ├── Search │ │ ├── index.js │ │ └── Search.js │ ├── Sidebar │ │ ├── index.js │ │ └── Sidebar.js │ ├── Spacer │ │ ├── index.js │ │ └── Spacer.js │ ├── EmailList │ │ ├── index.js │ │ └── EmailList.js │ ├── Foldable │ │ ├── index.js │ │ └── Foldable.js │ ├── MainPane │ │ ├── index.js │ │ └── MainPane.js │ ├── Providers │ │ ├── index.js │ │ └── Providers.js │ ├── Scoocher │ │ ├── index.js │ │ └── Scoocher.js │ ├── Transport │ │ ├── index.js │ │ ├── Transport.types.js │ │ ├── Transport.stories.js │ │ ├── Transport.helpers.js │ │ └── Transport.js │ ├── ComposeEmail │ │ ├── index.js │ │ └── ComposeEmail.js │ ├── EmailPreview │ │ ├── index.js │ │ └── EmailPreview.js │ ├── ComposeButton │ │ ├── index.js │ │ └── ComposeButton.js │ ├── NotificationDot │ │ ├── index.js │ │ ├── NotificationDot.stories.js │ │ └── NotificationDot.js │ ├── SidebarHeader │ │ ├── index.js │ │ └── SidebarHeader.js │ ├── SidebarHeading │ │ ├── index.js │ │ └── SidebarHeading.js │ ├── EtchASketchShaker │ │ ├── index.js │ │ └── EtchASketchShaker.js │ ├── WindowDimensions │ │ ├── index.js │ │ ├── WindowDimensions.stories.js │ │ └── WindowDimensions.js │ ├── ComposeEmailEnvelope │ │ ├── index.js │ │ └── ComposeEmailEnvelope.js │ ├── HighlightRectangle │ │ ├── index.js │ │ └── HighlightRectangle.js │ ├── NodeProvider │ │ ├── index.js │ │ └── NodeProvider.js │ ├── ComposeEmailContainer │ │ ├── index.js │ │ └── ComposeEmailContainer.js │ ├── EmailProvider │ │ ├── index.js │ │ ├── EmailProvider.js │ │ └── EmailProvider.data.js │ ├── ModalProvider │ │ ├── index.js │ │ └── ModalProvider.js │ ├── ComposeEmailAddressInput │ │ ├── index.js │ │ └── ComposeEmailAddressInput.js │ └── AuthenticationProvider │ │ ├── index.js │ │ └── AuthenticationProvider.js ├── assets │ ├── pop.wav │ ├── woosh-1.mp3 │ ├── woosh-2.mp3 │ ├── air-mail.png │ └── avatars │ │ ├── me.jpg │ │ ├── dodds.jpg │ │ ├── kermit.gif │ │ ├── vihart.png │ │ ├── avatar-1.jpg │ │ ├── avatar-10.jpg │ │ ├── avatar-11.jpg │ │ ├── avatar-12.jpg │ │ ├── avatar-13.jpg │ │ ├── avatar-14.jpg │ │ ├── avatar-15.jpg │ │ ├── avatar-16.jpg │ │ ├── avatar-2.jpg │ │ ├── avatar-3.jpg │ │ ├── avatar-4.jpg │ │ ├── avatar-5.jpg │ │ ├── avatar-6.jpg │ │ ├── avatar-7.jpg │ │ ├── avatar-8.jpg │ │ ├── avatar-9.jpg │ │ ├── nickycase.jpg │ │ ├── wheeler.jpg │ │ ├── hydro-quebec.jpg │ │ └── attribution.md ├── index.js ├── types.js ├── constants.js ├── helpers │ └── email.helpers.js └── utils.js ├── .new-component-config.json ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .storybook ├── addons.js └── config.js ├── .flowconfig ├── config-overrides.js ├── .gitignore └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # Mail Client Demo -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./App"; 2 | -------------------------------------------------------------------------------- /src/components/Email/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Email'; 2 | -------------------------------------------------------------------------------- /src/components/Avatar/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Avatar'; 2 | -------------------------------------------------------------------------------- /src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Button'; 2 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Header'; 2 | -------------------------------------------------------------------------------- /src/components/Search/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Search'; 2 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Sidebar'; 2 | -------------------------------------------------------------------------------- /src/components/Spacer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Spacer'; 2 | -------------------------------------------------------------------------------- /src/components/EmailList/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './EmailList'; 2 | -------------------------------------------------------------------------------- /src/components/Foldable/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Foldable'; 2 | -------------------------------------------------------------------------------- /src/components/MainPane/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './MainPane'; 2 | -------------------------------------------------------------------------------- /src/components/Providers/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Providers'; 2 | -------------------------------------------------------------------------------- /src/components/Scoocher/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Scoocher'; 2 | -------------------------------------------------------------------------------- /src/components/Transport/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Transport'; 2 | -------------------------------------------------------------------------------- /src/components/ComposeEmail/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ComposeEmail'; 2 | -------------------------------------------------------------------------------- /src/components/EmailPreview/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './EmailPreview'; 2 | -------------------------------------------------------------------------------- /src/components/ComposeButton/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ComposeButton'; 2 | -------------------------------------------------------------------------------- /src/components/NotificationDot/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './NotificationDot'; 2 | -------------------------------------------------------------------------------- /src/components/SidebarHeader/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SidebarHeader'; 2 | -------------------------------------------------------------------------------- /src/components/SidebarHeading/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SidebarHeading'; 2 | -------------------------------------------------------------------------------- /src/components/EtchASketchShaker/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './EtchASketchShaker'; 2 | -------------------------------------------------------------------------------- /src/components/WindowDimensions/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './WindowDimensions'; 2 | -------------------------------------------------------------------------------- /src/components/ComposeEmailEnvelope/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ComposeEmailEnvelope'; 2 | -------------------------------------------------------------------------------- /src/components/HighlightRectangle/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './HighlightRectangle'; 2 | -------------------------------------------------------------------------------- /src/components/NodeProvider/index.js: -------------------------------------------------------------------------------- 1 | export { NodeConsumer, default } from './NodeProvider'; 2 | -------------------------------------------------------------------------------- /.new-component-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettierConfig": { 3 | "singleQuote": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/ComposeEmailContainer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ComposeEmailContainer'; 2 | -------------------------------------------------------------------------------- /src/components/EmailProvider/index.js: -------------------------------------------------------------------------------- 1 | export { EmailConsumer, default } from './EmailProvider'; 2 | -------------------------------------------------------------------------------- /src/components/ModalProvider/index.js: -------------------------------------------------------------------------------- 1 | export { ModalConsumer, default } from './ModalProvider'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/pop.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/pop.wav -------------------------------------------------------------------------------- /src/components/ComposeEmailAddressInput/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ComposeEmailAddressInput'; 2 | -------------------------------------------------------------------------------- /src/assets/woosh-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/woosh-1.mp3 -------------------------------------------------------------------------------- /src/assets/woosh-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/woosh-2.mp3 -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /src/assets/air-mail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/air-mail.png -------------------------------------------------------------------------------- /src/assets/avatars/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/me.jpg -------------------------------------------------------------------------------- /src/assets/avatars/dodds.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/dodds.jpg -------------------------------------------------------------------------------- /src/assets/avatars/kermit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/kermit.gif -------------------------------------------------------------------------------- /src/assets/avatars/vihart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/vihart.png -------------------------------------------------------------------------------- /src/components/AuthenticationProvider/index.js: -------------------------------------------------------------------------------- 1 | export { AuthenticationConsumer, default } from './AuthenticationProvider'; 2 | -------------------------------------------------------------------------------- /src/assets/avatars/avatar-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-1.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-10.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-11.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-12.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-13.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-14.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-15.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-16.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-2.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-3.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-4.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-5.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-6.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-7.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-8.jpg -------------------------------------------------------------------------------- /src/assets/avatars/avatar-9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/avatar-9.jpg -------------------------------------------------------------------------------- /src/assets/avatars/nickycase.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/nickycase.jpg -------------------------------------------------------------------------------- /src/assets/avatars/wheeler.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/wheeler.jpg -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | node_modules 3 | 4 | [include] 5 | 6 | [libs] 7 | 8 | [lints] 9 | 10 | [options] 11 | 12 | [strict] 13 | -------------------------------------------------------------------------------- /src/assets/avatars/hydro-quebec.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshwcomeau/whimsical-mail-client/HEAD/src/assets/avatars/hydro-quebec.jpg -------------------------------------------------------------------------------- /src/components/Spacer/Spacer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | width: ${props => props.size}px; 5 | height: ${props => props.size}px; 6 | `; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './components/App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const rewireStyledComponents = require('react-app-rewire-styled-components'); 2 | 3 | /* config-overrides.js */ 4 | module.exports = function override(config, env) { 5 | config = rewireStyledComponents(config, env); 6 | return config; 7 | }; 8 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | const components = require.context('../src/components', true, /.stories.js$/); 4 | 5 | function loadStories() { 6 | components.keys().forEach(filename => components(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/avatars/attribution.md: -------------------------------------------------------------------------------- 1 | Avatar photos from Unsplash, by photographers: 2 | 3 | • Marivi Pazos 4 | • Allef Vinicius 5 | • Chuttersnap 6 | • Ayo Ogunseinde 7 | • Christiana Rivers 8 | • Clem Onojeghuo 9 | • Hust Wilson 10 | • Kelly Sikkema 11 | • Edward Cisneros 12 | • Warren Wong 13 | • Autumn Goodman 14 | • Gabriel Silverio 15 | • Hunger Johnson 16 | • Joe Gardner 17 | • Xenia Bogarova 18 | -------------------------------------------------------------------------------- /src/components/WindowDimensions/WindowDimensions.stories.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import WindowDimensions from './WindowDimensions'; 5 | 6 | storiesOf('WindowDimensions', module).add('default', () => ( 7 | 8 | {({ width, height }) => `${width}px by ${height}px`} 9 | 10 | )); 11 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | export type UserData = { 2 | name?: string, 3 | email: string, 4 | avatarSrc?: string, 5 | }; 6 | 7 | export type EmailData = { 8 | id: number, 9 | from: UserData, 10 | to: UserData, 11 | timestamp: number, 12 | subject: string, 13 | body: React$Node, 14 | read: boolean, 15 | }; 16 | 17 | export type ComposingEmailData = { 18 | ...$Shape, 19 | toEmail: string, 20 | }; 21 | 22 | export type ModalId = 'compose'; 23 | 24 | export type BoxId = 'inbox' | 'outbox' | 'drafts'; 25 | 26 | export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; 27 | -------------------------------------------------------------------------------- /src/components/Providers/Providers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | 4 | import AuthenticationProvider from '../AuthenticationProvider'; 5 | import EmailProvider from '../EmailProvider'; 6 | import ModalProvider from '../ModalProvider'; 7 | import NodeProvider from '../NodeProvider'; 8 | 9 | type Props = { children: React$Node }; 10 | 11 | // prettier-ignore 12 | const Providers = ({ children }: Props) => ( 13 | 14 | 15 | 16 | 17 | {children} 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | export default Providers; 25 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export const COLORS = { 3 | pink: { 4 | '500': '#f40088', 5 | '700': '#cc0072', 6 | }, 7 | red: { 8 | '500': '#EF5350', 9 | '700': '#D50000', 10 | }, 11 | green: { 12 | '500': '#00E676', 13 | '700': '#00C853', 14 | }, 15 | blue: { 16 | '500': '#42A5F5', 17 | '700': '#2962FF', 18 | }, 19 | purple: { 20 | '500': '#6139f5', 21 | '700': '#4520cc', 22 | }, 23 | gray: { 24 | '100': '#f2f2f2', 25 | '200': '#eaeaea', 26 | '300': '#cccccc', 27 | '400': '#aaaaaa', 28 | '500': '#888888', 29 | '700': '#444', 30 | '800': '#2A2A2A', 31 | '900': '#111', 32 | }, 33 | }; 34 | 35 | export const Z_INDICES = { 36 | modalBackdrop: 100, 37 | }; 38 | -------------------------------------------------------------------------------- /src/helpers/email.helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { UserData } from '../types'; 3 | 4 | export const parseEmailString = (str: string): UserData => { 5 | // Emails are written as "First Last " in the compose 6 | // modal. 7 | // NOTE: In a real app, there would be some sort of tagging system with 8 | // contact-list tie-in, but for this demo I'm just assuming anything the 9 | // user writes is valid. 10 | const matcher = /(.+)\s*<(.+)>/i; 11 | 12 | if (!str) { 13 | return { name: '', email: ''}; 14 | } 15 | 16 | const match = str.match(matcher); 17 | 18 | if (!match) { 19 | return { name: str.split('@')[0], email: str }; 20 | } 21 | 22 | const [, name, email] = match; 23 | 24 | return { name, email }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/Avatar/Avatar.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { COLORS } from '../../constants'; 6 | 7 | type Props = { 8 | src: string, 9 | size: number, 10 | }; 11 | const Avatar = ({ src, size }: Props) => { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | const Wrapper = styled.div` 20 | display: inline-block; 21 | width: ${props => props.size}px; 22 | height: ${props => props.size}px; 23 | background: ${COLORS.gray[300]}; 24 | border-radius: 50%; 25 | `; 26 | const AvatarImg = styled.img` 27 | display: block; 28 | width: 100%; 29 | height: 100%; 30 | border-radius: 50%; 31 | `; 32 | 33 | export default Avatar; 34 | -------------------------------------------------------------------------------- /src/components/ComposeButton/ComposeButton.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | 4 | import { ModalConsumer } from '../ModalProvider'; 5 | import { NodeConsumer } from '../NodeProvider'; 6 | import Button from '../Button'; 7 | 8 | const ComposeButton = () => { 9 | return ( 10 | 11 | {({ openModal }) => ( 12 | 13 | {({ refCapturer, nodes }) => ( 14 | 20 | )} 21 | 22 | )} 23 | 24 | ); 25 | }; 26 | 27 | export default ComposeButton; 28 | -------------------------------------------------------------------------------- /src/components/NotificationDot/NotificationDot.stories.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import NotificationDot from './NotificationDot'; 5 | 6 | class Toggler extends Component { 7 | state = { 8 | toggled: false, 9 | }; 10 | 11 | toggle = () => { 12 | this.setState({ toggled: !this.state.toggled }); 13 | }; 14 | 15 | render() { 16 | return ( 17 | 18 | 19 |
20 |
21 |
22 | {this.state.toggled && } 23 |
24 |
25 | ); 26 | } 27 | } 28 | 29 | storiesOf('NotificationDot', module) 30 | .add('default', () => ) 31 | .add('large', () => ); 32 | -------------------------------------------------------------------------------- /src/components/WindowDimensions/WindowDimensions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { PureComponent } from 'react'; 3 | 4 | import { debounce } from '../../utils'; 5 | 6 | type State = { 7 | windowWidth: number, 8 | windowHeight: number, 9 | }; 10 | 11 | type Props = { 12 | children: (args: State) => React$Node, 13 | }; 14 | 15 | class WindowDimensions extends PureComponent { 16 | state = { 17 | windowWidth: window.innerWidth, 18 | windowHeight: window.innerHeight, 19 | }; 20 | 21 | componentDidMount() { 22 | window.addEventListener('resize', this.updateWindowSize); 23 | } 24 | 25 | componentWillUnmount() { 26 | window.removeEventListener('resize', this.updateWindowSize); 27 | } 28 | 29 | updateWindowSize = debounce(() => { 30 | this.setState({ 31 | windowWidth: window.innerWidth, 32 | windowHeight: window.innerHeight, 33 | }); 34 | }, 100); 35 | 36 | render() { 37 | return this.props.children(this.state); 38 | } 39 | } 40 | 41 | export default WindowDimensions; 42 | -------------------------------------------------------------------------------- /src/components/MainPane/MainPane.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Header from '../Header'; 5 | import Email from '../Email'; 6 | import { EmailConsumer } from '../EmailProvider'; 7 | 8 | class MainPane extends Component { 9 | render() { 10 | const { headerHeight } = this.props; 11 | 12 | return ( 13 | 14 |
15 | 16 | 17 | {({ selectedEmail }) => ( 18 | 19 | 20 | 21 | )} 22 | 23 | 24 | ); 25 | } 26 | } 27 | 28 | const Wrapper = styled.div` 29 | display: flex; 30 | flex-direction: column; 31 | height: 100%; 32 | `; 33 | 34 | const EmailWrapper = styled.div` 35 | height: calc(100% - ${({ headerHeight }) => headerHeight}px); 36 | overflow-y: auto; 37 | `; 38 | 39 | export default MainPane; 40 | -------------------------------------------------------------------------------- /src/components/Transport/Transport.types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // `ClientRect`, the type returned by `getBoundingClientRect`, is helpful, but 4 | // it doesn't tell us everything we need. It's missing: 5 | // - The X/Y coordinates for the center of the node 6 | // - The distance from the right and bottom edges of the viewport. 7 | export type AugmentedClientRect = { 8 | // Standard ClientRect width/height in pixels 9 | width: number, 10 | height: number, 11 | 12 | top: number, 13 | left: number, 14 | right: number, 15 | bottom: number, 16 | 17 | // Distance to the center of the node in pixels from the top/left 18 | centerX: number, 19 | centerY: number, 20 | 21 | // This augmented property gives us information about the opposite viewport 22 | // corner. 23 | fromBottomRight: { 24 | top: number, 25 | left: number, 26 | right: number, 27 | bottom: number, 28 | centerX: number, 29 | centerY: number, 30 | }, 31 | }; 32 | 33 | export type MinimumFixedPosition = { 34 | top?: number, 35 | left?: number, 36 | right?: number, 37 | bottom?: number, 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mail-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test --env=jsdom", 9 | "eject": "react-scripts eject", 10 | "flow": "flow", 11 | "storybook": "start-storybook -p 9009 -s public", 12 | "build-storybook": "build-storybook -s public" 13 | }, 14 | "dependencies": { 15 | "date-fns": "^1.29.0", 16 | "immer": "^1.1.1", 17 | "react": "16.3", 18 | "react-app-rewire-styled-components": "^3.0.0", 19 | "react-dom": "16.3", 20 | "react-icons": "^2.2.7", 21 | "react-motion": "^0.5.2", 22 | "react-scripts": "1.1.1", 23 | "react-sound": "^1.1.0", 24 | "sharkhorse": "^4.0.0", 25 | "styled-components": "^3.1.6" 26 | }, 27 | "devDependencies": { 28 | "@storybook/addon-actions": "^3.3.15", 29 | "@storybook/addon-links": "^3.3.15", 30 | "@storybook/addons": "^3.3.15", 31 | "@storybook/react": "^3.3.15", 32 | "babel-core": "^6.26.0", 33 | "flow-bin": "^0.66.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/EmailList/EmailList.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { EmailConsumer } from '../EmailProvider'; 6 | import EmailPreview from '../EmailPreview'; 7 | 8 | import type { BoxId } from '../../types'; 9 | 10 | type Props = { 11 | itemHeight: number, 12 | selectedBoxId: BoxId, 13 | }; 14 | 15 | class EmailList extends Component { 16 | render() { 17 | const { itemHeight, selectedBoxId } = this.props; 18 | 19 | return ( 20 | 21 | {({ emailList, selectedEmailId, viewEmail }) => ( 22 | 23 | {emailList.map(email => ( 24 | viewEmail(email.id)} 31 | /> 32 | ))} 33 | 34 | )} 35 | 36 | ); 37 | } 38 | } 39 | 40 | const Wrapper = styled.div``; 41 | 42 | export default EmailList; 43 | -------------------------------------------------------------------------------- /src/components/SidebarHeading/SidebarHeading.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { capitalize } from '../../utils'; 6 | 7 | import { NodeConsumer } from '../NodeProvider'; 8 | 9 | import type { BoxId } from '../../types'; 10 | 11 | type Props = { 12 | boxId: string, 13 | height: number, 14 | isSelected: boolean, 15 | handleClick: (box: BoxId) => void, 16 | }; 17 | 18 | const SidebarHeading = ({ boxId, height, isSelected, handleClick }: Props) => ( 19 | 20 | {({ refCapturer }) => ( 21 | refCapturer(boxId, node)} 23 | height={height} 24 | isSelected={isSelected} 25 | onClick={handleClick} 26 | > 27 | {capitalize(boxId)} 28 | 29 | )} 30 | 31 | ); 32 | 33 | const SidebarHeaderBox = styled.button` 34 | display: block; 35 | position: relative; 36 | height: ${props => props.height}px; 37 | background: transparent; 38 | border: none; 39 | font-weight: 500; 40 | font-size: 16px; 41 | opacity: ${props => (props.isSelected ? 1 : 0.35)}; 42 | transition: opacity 500ms; 43 | cursor: pointer; 44 | outline: none; 45 | `; 46 | 47 | export default SidebarHeading; 48 | -------------------------------------------------------------------------------- /src/components/Search/Search.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * NOTE: Doesn't do anything. Just for show :) 4 | */ 5 | import React from 'react'; 6 | import styled from 'styled-components'; 7 | import SearchIcon from 'react-icons/lib/md/search'; 8 | 9 | import { COLORS } from '../../constants'; 10 | 11 | type Props = { height: number }; 12 | 13 | const Search = ({ height = 36 }: Props) => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | 23 | const Wrapper = styled.label` 24 | display: flex; 25 | position: relative; 26 | height: ${props => props.height}px; 27 | background: white; 28 | border-radius: ${props => props.height / 2}px; 29 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 30 | `; 31 | 32 | const SearchIconWrapper = styled.div` 33 | width: 30px; 34 | display: flex; 35 | justify-content: flex-end; 36 | align-items: center; 37 | color: ${COLORS.gray[500]}; 38 | `; 39 | 40 | const Input = styled.input` 41 | flex: 1; 42 | display: flex; 43 | height: 100%; 44 | background: transparent; 45 | border: none; 46 | font-size: 14px; 47 | padding-left: 8px; 48 | /* Optical centering */ 49 | transform: translateY(-1px); 50 | 51 | outline: none; 52 | 53 | &::placeholder { 54 | opacity: 0.5; 55 | } 56 | `; 57 | 58 | export default Search; 59 | -------------------------------------------------------------------------------- /src/components/Button/Button.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { COLORS } from '../../constants'; 6 | 7 | type Props = { 8 | primary: boolean, 9 | secondary: boolean, 10 | }; 11 | const Button = ({ primary, secondary, ...delegated }: Props) => { 12 | if (secondary) { 13 | return ; 14 | } 15 | 16 | return ; 17 | }; 18 | 19 | const ButtonBase = styled.button` 20 | display: inline-flex; 21 | justify-content: center; 22 | align-items: center; 23 | height: 40px; 24 | border: none; 25 | cursor: pointer; 26 | -webkit-font-smoothing: antialiased; 27 | `; 28 | 29 | const PrimaryButton = styled(ButtonBase)` 30 | padding: 0 20px; 31 | background: linear-gradient( 32 | -10deg, 33 | ${COLORS.purple[500]}, 34 | ${COLORS.pink[500]} 85% 35 | ); 36 | border-radius: 4px; 37 | color: white; 38 | font-size: 14px; 39 | font-weight: bold; 40 | box-shadow: inset 0px -2px 0px rgba(0, 0, 0, 0.1); 41 | text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.2); 42 | 43 | &:active { 44 | background: linear-gradient( 45 | -10deg, 46 | ${COLORS.purple[700]}, 47 | ${COLORS.pink[500]} 48 | ); 49 | } 50 | `; 51 | 52 | const SecondaryButton = styled(ButtonBase)` 53 | padding: 0 10px; 54 | font-size: 25px; 55 | color: ${COLORS.gray[800]}; 56 | `; 57 | 58 | export default Button; 59 | -------------------------------------------------------------------------------- /src/components/AuthenticationProvider/AuthenticationProvider.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | 4 | import avatarMe from '../../assets/avatars/me.jpg'; 5 | 6 | import type { UserData } from '../../types'; 7 | 8 | // $FlowFixMe 9 | const AuthenticationContext = React.createContext('authentication'); 10 | 11 | // NOTE: So this is a demo, in which authentication is just "background noise". 12 | // This is not a real authentication provider, it's just a container for this 13 | // fake "fixture" data: 14 | const USER_DATA = { 15 | name: 'Josh Comeau', 16 | email: 'joshua@khanacademy.org', 17 | avatarSrc: avatarMe, 18 | }; 19 | 20 | type Props = { 21 | children: React$Node, 22 | }; 23 | 24 | type State = { 25 | isAuthenticated: boolean, 26 | userData: UserData, 27 | }; 28 | 29 | class AuthenticationProvider extends Component { 30 | state = { 31 | isAuthenticated: true, 32 | userData: USER_DATA, 33 | }; 34 | 35 | render() { 36 | const { isAuthenticated, userData } = this.state; 37 | 38 | return ( 39 | 46 | {this.props.children} 47 | 48 | ); 49 | } 50 | } 51 | 52 | export const AuthenticationConsumer = AuthenticationContext.Consumer; 53 | 54 | export default AuthenticationProvider; 55 | -------------------------------------------------------------------------------- /src/components/HighlightRectangle/HighlightRectangle.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { COLORS } from '../../constants'; 5 | 6 | type Props = { 7 | height: number, 8 | offset: number, 9 | color: string, 10 | }; 11 | 12 | class HighlightRectangle extends Component { 13 | state = { 14 | distance: 0, 15 | }; 16 | 17 | static defaultProps = { 18 | color: 'white', 19 | }; 20 | 21 | componentWillReceiveProps(nextProps) { 22 | if (this.props.offset !== nextProps.offset) { 23 | this.setState({ 24 | distance: Math.abs(this.props.offset - nextProps.offset), 25 | }); 26 | } 27 | } 28 | 29 | render() { 30 | const { height, color, offset } = this.props; 31 | const { distance } = this.state; 32 | 33 | return ( 34 | 40 | ); 41 | } 42 | } 43 | 44 | const Rectangle = styled.div` 45 | position: absolute; 46 | z-index: 0; 47 | top: 0; 48 | right: -1px; 49 | height: ${props => props.height}px; 50 | left: 10px; 51 | background: ${props => props.color}; 52 | box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.1); 53 | /* border-bottom: 2px solid rgba(0, 0, 0, 0.2); */ 54 | transform: translateY(${props => props.offset}px); 55 | transition: transform ${props => props.distance * 1.35}ms ease-out; 56 | /* transition: transform 400ms ease-out; */ 57 | `; 58 | 59 | export default HighlightRectangle; 60 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled from 'styled-components'; 4 | import Sound from 'react-sound'; 5 | 6 | // For some reason, Flow complains about this module not being found. 7 | // Maybe because it's a .wav? Works fine though. $FlowFixMe 8 | import popSoundSrc from '../../assets/pop.wav'; 9 | 10 | import Providers from '../Providers'; 11 | import Sidebar from '../Sidebar'; 12 | import MainPane from '../MainPane'; 13 | import ComposeEmailModal from '../ComposeEmailContainer'; 14 | 15 | type Props = {}; 16 | 17 | class App extends Component { 18 | render() { 19 | const HEADER_HEIGHT = 60; 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {/* Preload sounds */} 35 | 36 | 37 | ); 38 | } 39 | } 40 | 41 | const Wrapper = styled.div` 42 | display: flex; 43 | height: 100%; 44 | `; 45 | 46 | const SidebarWrapper = styled.div` 47 | position: relative; 48 | height: 100%; 49 | z-index: 1; 50 | `; 51 | 52 | const MainPaneWrapper = styled.div` 53 | position: relative; 54 | height: 100%; 55 | background: white; 56 | flex: 1; 57 | z-index: 2; 58 | `; 59 | 60 | export default App; 61 | -------------------------------------------------------------------------------- /src/components/NodeProvider/NodeProvider.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * This web app requires knowledge of where certain elements are on the page. 4 | * It should contain a map of critical HTML element nodes, and should provide 5 | * a consumer that can provide ref-capturers so that they can be gathered. 6 | * Also, resize handling presumably? 7 | */ 8 | import React, { Component } from 'react'; 9 | 10 | // $FlowFixMe 11 | const NodeContext = React.createContext('node'); 12 | 13 | export type Nodes = { [key: string]: HTMLElement }; 14 | export type BoundingBoxes = { [key: string]: ClientRect }; 15 | 16 | type Props = { children: React$Node }; 17 | type State = { 18 | nodes: Nodes, 19 | boundingBoxes: BoundingBoxes, 20 | }; 21 | 22 | class NodeProvider extends Component { 23 | state = { 24 | nodes: {}, 25 | boundingBoxes: {}, 26 | refCapturer: (id: string, node: HTMLElement) => { 27 | if (!node) { 28 | return; 29 | } 30 | 31 | if (this.state.nodes[id]) { 32 | return; 33 | } 34 | 35 | this.setState({ 36 | nodes: { 37 | ...this.state.nodes, 38 | [id]: node, 39 | }, 40 | boundingBoxes: { 41 | ...this.state.boundingBoxes, 42 | [id]: node.getBoundingClientRect(), 43 | }, 44 | }); 45 | }, 46 | }; 47 | 48 | render() { 49 | return ( 50 | 51 | {this.props.children} 52 | 53 | ); 54 | } 55 | } 56 | 57 | export const NodeConsumer = NodeContext.Consumer; 58 | 59 | export default NodeProvider; 60 | -------------------------------------------------------------------------------- /src/components/ModalProvider/ModalProvider.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | 4 | import type { ModalId } from '../../types'; 5 | 6 | // $FlowFixMe 7 | const ModalContext = React.createContext('modal'); 8 | 9 | type Props = { children: React$Node }; 10 | type State = { 11 | // For this app, I've chosen to limit it to 1 active modal at a time. 12 | // This is an artificial constraint, though. If you needed multiple modals, 13 | // you could use a map-like object, eg { [modalId: string]: boolean } 14 | currentModal: ?ModalId, 15 | openFromNode: ?HTMLElement, 16 | delegated: any, 17 | }; 18 | 19 | class ModalProvider extends Component { 20 | state = { 21 | currentModal: null, 22 | openFromNode: null, 23 | delegated: null, 24 | }; 25 | 26 | openModal = (modalId: ModalId, openFromNode: HTMLElement, delegated: any) => 27 | this.setState({ currentModal: modalId, openFromNode, delegated }); 28 | 29 | closeModal = () => { 30 | this.setState({ currentModal: null }); 31 | }; 32 | 33 | render() { 34 | const { children } = this.props; 35 | const { currentModal, openFromNode, delegated } = this.state; 36 | 37 | return ( 38 | 50 | {children} 51 | 52 | ); 53 | } 54 | } 55 | 56 | export const ModalConsumer = ModalContext.Consumer; 57 | 58 | export default ModalProvider; 59 | -------------------------------------------------------------------------------- /src/components/EtchASketchShaker/EtchASketchShaker.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled, { keyframes } from 'styled-components'; 4 | 5 | type Props = { 6 | children: React$Node, 7 | shake: boolean, 8 | }; 9 | 10 | class EtchASketchShaker extends Component { 11 | render() { 12 | return ( 13 | 14 | 15 | {this.props.children} 16 | 17 | 18 | ); 19 | } 20 | } 21 | 22 | const rotate = keyframes` 23 | 0% { 24 | transform: rotate(0deg); 25 | } 26 | 27 | 25% { 28 | transform: rotate(-2deg); 29 | } 30 | 31 | 50% { 32 | transform: rotate(3deg); 33 | } 34 | 35 | 75% { 36 | transform: rotate(-1deg); 37 | } 38 | 39 | 95% { 40 | transform: rotate(0deg); 41 | } 42 | `; 43 | 44 | const shakeUpDown = keyframes` 45 | 0% { 46 | transform: translateY(0); 47 | } 48 | 49 | 12.5% { 50 | transform: translateY(20px); 51 | } 52 | 53 | 25% { 54 | transform: translateY(-20px); 55 | } 56 | 57 | 37.5% { 58 | transform: translateY(20px); 59 | } 60 | 61 | 50% { 62 | transform: translateY(-40px); 63 | } 64 | 65 | 62.5% { 66 | transform: translateY(20px); 67 | } 68 | 69 | 75% { 70 | transform: translateY(-20px); 71 | } 72 | 73 | 74 | 100% { 75 | transform: translateY(0); 76 | } 77 | `; 78 | 79 | const Rotate = styled.div` 80 | transform-origin: bottom center; 81 | animation: ${props => (props.shake ? `${rotate} 1000ms alternate` : null)}; 82 | animation-iteration-count: 1; 83 | `; 84 | 85 | const ShakeUpDown = styled.div` 86 | animation: ${props => (props.shake ? `${shakeUpDown} 1000ms` : null)}; 87 | animation-iteration-count: 1; 88 | `; 89 | 90 | export default EtchASketchShaker; 91 | -------------------------------------------------------------------------------- /src/components/ComposeEmailEnvelope/ComposeEmailEnvelope.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import airMailSrc from '../../assets/air-mail.png'; 6 | import { parseEmailString } from '../../helpers/email.helpers'; 7 | 8 | import type { UserData } from '../../types'; 9 | 10 | type Props = { 11 | toEmail: $Shape, 12 | subject: string, 13 | }; 14 | 15 | class ComposeEmailEnvelope extends Component { 16 | render() { 17 | const { subject, toEmail } = this.props; 18 | 19 | const { email } = parseEmailString(toEmail); 20 | 21 | return ( 22 | 29 | ); 30 | } 31 | } 32 | 33 | const Wrapper = styled.div` 34 | position: relative; 35 | width: 100%; 36 | height: 100%; 37 | `; 38 | 39 | const AirMailBorder = styled.div` 40 | position: absolute; 41 | top: 0; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | z-index: 1; 46 | background: url(${airMailSrc}); 47 | background-size: 64px; 48 | `; 49 | 50 | const InnerContents = styled.div` 51 | position: absolute; 52 | z-index: 2; 53 | top: 10px; 54 | left: 10px; 55 | right: 10px; 56 | bottom: 10px; 57 | background: white; 58 | display: flex; 59 | flex-direction: column; 60 | justify-content: center; 61 | align-items: center; 62 | `; 63 | 64 | const Subject = styled.div` 65 | max-width: 60%; 66 | margin-left: auto; 67 | margin-right: auto; 68 | margin-bottom: 15px; 69 | font-size: 15px; 70 | font-weight: bold; 71 | `; 72 | 73 | const To = styled.div` 74 | font-size: 15px; 75 | opacity: 0.8; 76 | `; 77 | 78 | export default ComposeEmailEnvelope; 79 | -------------------------------------------------------------------------------- /src/components/Email/Email.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled from 'styled-components'; 4 | import format from 'date-fns/format'; 5 | 6 | import { COLORS } from '../../constants'; 7 | 8 | import type { EmailData } from '../../types'; 9 | 10 | type Props = { 11 | data: EmailData, 12 | }; 13 | 14 | class Email extends Component { 15 | render() { 16 | const { subject, to, from, timestamp, body } = this.props.data; 17 | 18 | const formattedBody = body 19 | .split('\n') 20 | .map((paragraph, index) => ( 21 | {paragraph} 22 | )); 23 | 24 | const formattedFrom = from.name || from.email; 25 | const formattedTo = to.name || to.email; 26 | 27 | return ( 28 | 29 |
30 | 31 | {formattedFrom} → {formattedTo} 32 | 33 | {subject} 34 | {format(timestamp, 'MMM Do, YYYY [at] h:mm A')} 35 |
36 | 37 | {formattedBody} 38 |
39 | ); 40 | } 41 | } 42 | 43 | const Wrapper = styled.div` 44 | max-width: 900px; 45 | margin: auto; 46 | padding: 70px 50px; 47 | `; 48 | 49 | const Header = styled.header` 50 | text-align: center; 51 | margin-bottom: 50px; 52 | `; 53 | 54 | const Addresses = styled.div` 55 | color: ${COLORS.gray[400]}; 56 | font-size: 13px; 57 | margin-bottom: 14px; 58 | `; 59 | 60 | const Subject = styled.h1` 61 | font-size: 28px; 62 | font-weight: bold; 63 | -webkit-font-smoothing: antialiased; 64 | margin: auto; 65 | margin-bottom: 16px; 66 | max-width: 600px; 67 | `; 68 | 69 | const Timestamp = styled.div` 70 | color: ${COLORS.gray[400]}; 71 | font-size: 14px; 72 | `; 73 | 74 | const Body = styled.div` 75 | margin-top: 70px; 76 | `; 77 | 78 | const Paragraph = styled.p` 79 | font-size: 18px; 80 | line-height: 1.65; 81 | margin-bottom: 30px; 82 | `; 83 | 84 | export default Email; 85 | -------------------------------------------------------------------------------- /src/components/ComposeEmailAddressInput/ComposeEmailAddressInput.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * This is yet another "fake" component. It doesn't do anything with the values 4 | * typed into it. 5 | */ 6 | import React, { PureComponent } from 'react'; 7 | import styled from 'styled-components'; 8 | 9 | import { COLORS } from '../../constants'; 10 | 11 | type Props = { 12 | id: string, 13 | value: string, 14 | label: string, 15 | height: number, 16 | isVisible: boolean, 17 | onChange: (ev: SyntheticEvent) => void, 18 | }; 19 | 20 | class ComposeEmailAddressInput extends PureComponent { 21 | static defaultProps = { 22 | height: 32, 23 | }; 24 | 25 | render() { 26 | const { 27 | label, 28 | value, 29 | onChange, 30 | height, 31 | isVisible, 32 | ...delegated 33 | } = this.props; 34 | 35 | return ( 36 | 37 | {label}: 38 | 44 | 45 | ); 46 | } 47 | } 48 | 49 | const Wrapper = styled.label` 50 | display: flex; 51 | align-items: center; 52 | height: ${props => props.height}px; 53 | font-size: 14px; 54 | `; 55 | 56 | const TextLabel = styled.div` 57 | width: 50px; 58 | height: 100%; 59 | display: flex; 60 | justify-content: flex-end; 61 | align-items: center; 62 | padding-right: 6px; 63 | color: ${COLORS.gray[500]}; 64 | `; 65 | 66 | const Input = styled.input` 67 | flex: 1; 68 | height: 28px; 69 | background: transparent; 70 | border: none; 71 | border-bottom: 2px solid rgba(0, 0, 0, 0.25); 72 | outline: none; 73 | transform: translateY(1px); 74 | font-size: 14px; 75 | backface-visibility: hidden; 76 | 77 | &:focus { 78 | border-bottom: 2px solid ${COLORS.blue[700]}; 79 | } 80 | 81 | &::placeholder { 82 | opacity: 0.4; 83 | } 84 | `; 85 | 86 | export default ComposeEmailAddressInput; 87 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { COLORS } from '../../constants'; 6 | 7 | import SidebarHeader from '../SidebarHeader'; 8 | import Search from '../Search'; 9 | import EmailList from '../EmailList'; 10 | import Spacer from '../Spacer'; 11 | import { EmailConsumer } from '../EmailProvider'; 12 | 13 | type Props = { 14 | width: number, 15 | itemHeight: number, 16 | headerHeight: number, 17 | }; 18 | 19 | class Sidebar extends Component { 20 | static defaultProps = { 21 | width: 400, 22 | itemHeight: 100, 23 | headerHeight: 50, 24 | }; 25 | 26 | render() { 27 | const { width, itemHeight, headerHeight } = this.props; 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {({ selectedBoxId }) => ( 40 | 44 | )} 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | } 54 | 55 | const Wrapper = styled.div` 56 | position: relative; 57 | width: ${props => props.width}px; 58 | height: 100%; 59 | border-right: 1px solid rgba(0, 0, 0, 0.1); 60 | `; 61 | 62 | const Foreground = styled.div` 63 | position: relative; 64 | z-index: 2; 65 | height: 100%; 66 | `; 67 | 68 | const Background = styled.div` 69 | position: absolute; 70 | top: 0; 71 | left: 0; 72 | width: 100%; 73 | height: 100%; 74 | background: ${COLORS.gray[100]}; 75 | `; 76 | 77 | const EmailListWrapper = styled.div` 78 | height: calc(100% - ${({ headerHeight }) => headerHeight}px); 79 | padding: 24px 0; 80 | overflow: scroll; 81 | `; 82 | 83 | const SearchWrapper = styled.div` 84 | padding: 0 24px; 85 | `; 86 | 87 | export default Sidebar; 88 | -------------------------------------------------------------------------------- /src/components/SidebarHeader/SidebarHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import Scoocher from '../Scoocher'; 6 | import SidebarHeading from '../SidebarHeading'; 7 | import NotificationDot from '../NotificationDot'; 8 | import { NodeConsumer } from '../NodeProvider'; 9 | import { EmailConsumer } from '../EmailProvider'; 10 | 11 | import type { BoxId } from '../../types'; 12 | 13 | type Props = { 14 | height: number, 15 | selectedBoxId: BoxId, 16 | handleSelectBox: (box: BoxId) => void, 17 | }; 18 | 19 | const boxIds: Array = ['inbox', 'outbox', 'drafts']; 20 | 21 | class SidebarHeader extends PureComponent { 22 | render() { 23 | const { height } = this.props; 24 | 25 | return ( 26 | 27 | {({ selectedBoxId, selectBox, notificationOnBoxes }) => ( 28 | 29 | 30 | {boxIds.map(boxId => ( 31 | 32 | selectBox(boxId)} 37 | /> 38 | 39 | {notificationOnBoxes.includes(boxId) && } 40 | 41 | 42 | ))} 43 | 44 | {({ nodes, boundingBoxes }) => { 45 | return ( 46 | 57 | ); 58 | }} 59 | 60 | 61 | 62 | )} 63 | 64 | ); 65 | } 66 | } 67 | 68 | const Wrapper = styled.div` 69 | position: relative; 70 | height: ${props => props.height}px; 71 | line-height: ${props => props.height}px; 72 | background: white; 73 | border-bottom: 1px solid rgba(0, 0, 0, 0.075); 74 | `; 75 | 76 | const InnerWrapper = styled.div` 77 | display: flex; 78 | justify-content: space-between; 79 | max-width: 270px; 80 | padding-left: 24px; 81 | `; 82 | 83 | const SidebarHeadingWrapper = styled.div` 84 | position: relative; 85 | `; 86 | 87 | const NotificationDotWrapper = styled.div` 88 | position: absolute; 89 | left: 0; 90 | right: 0; 91 | bottom: 8px; 92 | margin: auto; 93 | width: 8px; 94 | `; 95 | 96 | export default SidebarHeader; 97 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Mail 10 | 161 | 162 | 163 | 164 | 167 |
168 | 169 | 170 | 171 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import styled from 'styled-components'; 4 | import ReplyIcon from 'react-icons/lib/md/reply'; 5 | import ReplyAllIcon from 'react-icons/lib/md/reply-all'; 6 | import ForwardIcon from 'react-icons/lib/md/forward'; 7 | import DeleteIcon from 'react-icons/lib/md/delete'; 8 | 9 | import { COLORS } from '../../constants'; 10 | 11 | import Button from '../Button'; 12 | import { ModalConsumer } from '../ModalProvider'; 13 | import { NodeConsumer } from '../NodeProvider'; 14 | 15 | type Props = { 16 | height: number, 17 | }; 18 | 19 | class Header extends Component { 20 | render() { 21 | const { height } = this.props; 22 | 23 | return ( 24 | 25 | {({ openModal }) => ( 26 | 27 | {({ refCapturer, nodes }) => ( 28 | 29 | 30 | 31 | 42 | 45 | 48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 58 | 66 | 67 | 68 | )} 69 | 70 | )} 71 | 72 | ); 73 | } 74 | } 75 | 76 | const Wrapper = styled.header` 77 | flex: 1; 78 | display: flex; 79 | justify-content: space-between; 80 | align-items: center; 81 | height: ${props => props.height}px; 82 | padding: 10px; 83 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 84 | `; 85 | 86 | const Side = styled.div` 87 | display: flex; 88 | align-items: center; 89 | `; 90 | 91 | const ButtonGroup = styled.div` 92 | & > button { 93 | margin-right: 6px; 94 | } 95 | & > button:last-of-type { 96 | margin-right: 0; 97 | } 98 | `; 99 | 100 | const Separator = styled.div` 101 | width: 1px; 102 | height: ${props => props.height / 2}px; 103 | background: ${COLORS.gray[200]}; 104 | margin: 0 6px; 105 | `; 106 | 107 | export default Header; 108 | -------------------------------------------------------------------------------- /src/components/EmailPreview/EmailPreview.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import format from 'date-fns/format'; 5 | import isSameDay from 'date-fns/is_same_day'; 6 | 7 | import { COLORS } from '../../constants'; 8 | 9 | import Avatar from '../Avatar'; 10 | import Spacer from '../Spacer'; 11 | 12 | import type { EmailData, BoxId } from '../../types'; 13 | 14 | const formatTime = timestamp => { 15 | if (isSameDay(timestamp, new Date())) { 16 | return format(timestamp, 'h:mm a'); 17 | } 18 | 19 | return format(timestamp, 'MMM Do'); 20 | }; 21 | 22 | type Props = { 23 | data: EmailData, 24 | selectedBoxId: BoxId, 25 | height: number, 26 | isSelected: boolean, 27 | handleClick: () => void, 28 | }; 29 | const EmailPreview = ({ 30 | data, 31 | selectedBoxId, 32 | height, 33 | isSelected, 34 | handleClick, 35 | }: Props) => { 36 | const user = selectedBoxId === 'inbox' ? data.from : data.to; 37 | 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 |
45 | {user.name} 46 | {formatTime(data.timestamp)} 47 |
48 | 49 | {data.subject} 50 | {data.body} 51 |
52 |
53 | ); 54 | }; 55 | 56 | const Wrapper = styled.div` 57 | position: relative; 58 | padding: 18px 24px; 59 | height: ${props => props.height + 'px'}; 60 | background-color: ${props => props.isSelected && COLORS.blue[700]}; 61 | color: ${props => props.isSelected && '#FFF'}; 62 | display: flex; 63 | align-items: center; 64 | line-height: 1.6; 65 | transition: opacity 500ms; 66 | cursor: pointer; 67 | `; 68 | 69 | const UnreadDot = styled.div` 70 | position: absolute; 71 | top: 0; 72 | left: ${props => 12 - props.size / 2}px; 73 | bottom: 0; 74 | width: ${props => props.size}px; 75 | height: ${props => props.size}px; 76 | margin-top: auto; 77 | margin-bottom: auto; 78 | background-color: ${COLORS.pink[500]}; 79 | border-radius: 100%; 80 | opacity: ${props => (props.visible ? 1 : 0)}; 81 | `; 82 | 83 | const Summary = styled.div` 84 | flex: 1; 85 | display: flex; 86 | height: 100%; 87 | flex-direction: column; 88 | justify-content: center; 89 | font-size: 14px; 90 | /** CSS HACK: 91 | * min-width is necessary for the children's overflow ellipsis to work. 92 | * See: https://css-tricks.com/flexbox-truncated-text/ 93 | */ 94 | min-width: 0; 95 | `; 96 | 97 | const Header = styled.div` 98 | display: flex; 99 | justify-content: space-between; 100 | opacity: 0.9; 101 | font-weight: 400; 102 | `; 103 | 104 | const From = styled.div``; 105 | const At = styled.div``; 106 | 107 | const Subject = styled.h4` 108 | font-weight: ${props => (props.unread ? 700 : 500)}; 109 | overflow: hidden; 110 | text-overflow: ellipsis; 111 | white-space: nowrap; 112 | `; 113 | 114 | const Preview = styled.p` 115 | display: block; 116 | overflow: hidden; 117 | text-overflow: ellipsis; 118 | white-space: nowrap; 119 | font-weight: 300; 120 | opacity: 0.85; 121 | `; 122 | 123 | export default EmailPreview; 124 | -------------------------------------------------------------------------------- /src/components/Transport/Transport.stories.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component, Fragment } from 'react'; 3 | import { storiesOf } from '@storybook/react'; 4 | import styled from 'styled-components'; 5 | 6 | import Transport from './Transport'; 7 | import WindowDimensions from '../WindowDimensions'; 8 | 9 | import type { Status } from './Transport'; 10 | 11 | type Quadrant = 1 | 2 | 3 | 4; 12 | 13 | const QUADRANTS: Array = [1, 2, 3, 4]; 14 | 15 | type Props = {}; 16 | 17 | type State = { 18 | from: ?HTMLElement, 19 | to: ?HTMLElement, 20 | status: Status, 21 | }; 22 | 23 | class Wrapper extends Component { 24 | state = { 25 | from: null, 26 | to: null, 27 | status: 'closed', 28 | }; 29 | 30 | nodes: { 31 | [Quadrant]: HTMLElement, 32 | } = {}; 33 | 34 | componentDidMount() { 35 | const from = this.nodes[1]; 36 | const to = this.nodes[2]; 37 | 38 | this.setState({ 39 | from, 40 | to, 41 | }); 42 | } 43 | 44 | handleClick = node => { 45 | if (this.state.status === 'open') { 46 | this.setState({ 47 | to: node, 48 | status: node === this.state.from ? 'retracted' : 'closed', 49 | }); 50 | return; 51 | } 52 | 53 | this.setState({ 54 | from: node, 55 | status: 'open', 56 | }); 57 | }; 58 | 59 | getPositionForQuadrant = quadrant => { 60 | switch (quadrant) { 61 | case 1: 62 | return { top: 30, left: 40 }; 63 | case 2: 64 | return { top: 30, right: 40 }; 65 | case 3: 66 | return { bottom: 30, left: 40 }; 67 | case 4: 68 | return { bottom: 30, right: 40 }; 69 | default: 70 | throw new Error('Unrecognized quadrant'); 71 | } 72 | }; 73 | 74 | render() { 75 | return ( 76 | 77 | {({ windowWidth, windowHeight }) => ( 78 | 79 | {QUADRANTS.map(quadrant => ( 80 | 94 | ))} 95 | 96 | {this.state.from && 97 | this.state.to && ( 98 | 105 |
112 | 113 | )} 114 | 115 | )} 116 | 117 | ); 118 | } 119 | } 120 | 121 | const Button = styled.button` 122 | position: fixed; 123 | padding: 20px; 124 | `; 125 | 126 | storiesOf('Transport', module) 127 | .add('default (top to bottom)', () => ( 128 | 129 | )) 130 | .add('corners', () => ( 131 | 132 | )); 133 | -------------------------------------------------------------------------------- /src/components/EmailProvider/EmailProvider.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react'; 3 | import produce from 'immer'; 4 | 5 | import { parseEmailString } from '../../helpers/email.helpers'; 6 | 7 | import { generateData, getRandomAvatar } from './EmailProvider.data'; 8 | import { AuthenticationConsumer } from '../AuthenticationProvider'; 9 | 10 | import type { UserData, EmailData, BoxId } from '../../types'; 11 | 12 | // $FlowFixMe 13 | const EmailContext = React.createContext('email'); 14 | 15 | type Props = { 16 | userData: UserData, 17 | children: React$Node, 18 | }; 19 | type State = { 20 | emails: Map, 21 | selectedBoxId: BoxId, 22 | selectedEmailId: number, 23 | notificationOnBoxes: Array, 24 | }; 25 | 26 | class EmailProvider extends Component { 27 | state = { 28 | emails: generateData(this.props.userData, 30), 29 | selectedBoxId: 'inbox', 30 | selectedEmailId: 'a', 31 | notificationOnBoxes: [], 32 | }; 33 | 34 | viewEmail = (id: number) => { 35 | const nextState = produce(this.state, draftState => { 36 | draftState.selectedEmailId = id; 37 | 38 | // Selecting a letter automatically marks it as read. 39 | draftState.emails.set(id, { 40 | ...draftState.emails.get(id), 41 | unread: false, 42 | }); 43 | }); 44 | 45 | this.setState(nextState); 46 | }; 47 | 48 | addNewEmailToBox = ({ boxId, toEmail, subject, body }: any) => { 49 | const id = this.state.emails.size + 1; 50 | 51 | const to = parseEmailString(toEmail); 52 | to.avatarSrc = getRandomAvatar(); 53 | 54 | const newEmail = { 55 | id, 56 | boxId, 57 | to, 58 | from: this.props.userData, 59 | subject, 60 | body, 61 | unread: true, 62 | timestamp: Date.now(), 63 | }; 64 | 65 | const addNotification = 66 | !this.state.notificationOnBoxes.includes(boxId) && 67 | this.state.selectedBoxId !== boxId; 68 | 69 | this.setState({ 70 | emails: this.state.emails.set(id, newEmail), 71 | notificationOnBoxes: addNotification 72 | ? [...this.state.notificationOnBoxes, boxId] 73 | : this.state.notificationOnBoxes, 74 | }); 75 | }; 76 | 77 | selectBox = (boxId: BoxId) => { 78 | this.setState({ 79 | selectedBoxId: boxId, 80 | notificationOnBoxes: this.state.notificationOnBoxes.filter( 81 | notificationOnBoxId => notificationOnBoxId !== boxId 82 | ), 83 | }); 84 | }; 85 | 86 | render() { 87 | const { 88 | emails, 89 | selectedBoxId, 90 | selectedEmailId, 91 | notificationOnBoxes, 92 | } = this.state; 93 | 94 | const emailList = Array.from(emails.values()) 95 | .filter(email => email.boxId === selectedBoxId) 96 | .sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1)); 97 | 98 | console.log(emails, emailList); 99 | 100 | return ( 101 | 120 | {this.props.children} 121 | 122 | ); 123 | } 124 | } 125 | 126 | export const EmailConsumer = EmailContext.Consumer; 127 | 128 | const withEnvironmentData = WrappedComponent => (props: any) => ( 129 | 130 | {({ userData }) => } 131 | 132 | ); 133 | 134 | export default withEnvironmentData(EmailProvider); 135 | -------------------------------------------------------------------------------- /src/components/NotificationDot/NotificationDot.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent, Fragment } from 'react'; 3 | import styled, { keyframes } from 'styled-components'; 4 | import { Motion, spring } from 'react-motion'; 5 | import Sound from 'react-sound'; 6 | 7 | import { COLORS } from '../../constants'; 8 | // Flow doesn't believe this wav exists :/ $FlowFixMe 9 | import popSoundSrc from '../../assets/pop.wav'; 10 | 11 | const MAIN_DOT_SPRING = { stiffness: 225, damping: 7 }; 12 | const FIRST_DOT_SPRING = { stiffness: 35, damping: 7 }; 13 | const SECOND_DOT_SPRING = { stiffness: 65, damping: 7 }; 14 | const THIRD_DOT_SPRING = { stiffness: 95, damping: 7 }; 15 | 16 | type Props = { 17 | size: number, 18 | isOpen: boolean, 19 | }; 20 | 21 | class NotificationDot extends PureComponent { 22 | static defaultProps = { 23 | size: 8, 24 | }; 25 | render() { 26 | const { size } = this.props; 27 | 28 | return ( 29 | 30 | 31 | 51 | {({ 52 | mainDotScale, 53 | firstDotPositionX, 54 | firstDotPositionY, 55 | secondDotPositionX, 56 | secondDotPositionY, 57 | thirdDotPositionX, 58 | thirdDotPositionY, 59 | }) => ( 60 | 61 | 66 | 72 | 78 | 84 | 85 | )} 86 | 87 | 88 | ); 89 | } 90 | } 91 | 92 | const fadeOut = keyframes` 93 | from { opacity: 1; } 94 | to { opacity: 0; } 95 | `; 96 | 97 | const Wrapper = styled.div` 98 | position: relative; 99 | width: 10px; 100 | height: 10px; 101 | pointer-events: none; 102 | `; 103 | 104 | const Dot = styled.div` 105 | position: absolute; 106 | top: 0; 107 | left: 0; 108 | right: 0; 109 | bottom: 0; 110 | margin: auto; 111 | border-radius: 50%; 112 | background-color: ${props => props.color}; 113 | `; 114 | 115 | const MainDot = styled(Dot).attrs({ 116 | style: props => ({ 117 | transform: `scale(${props.scale})`, 118 | }), 119 | })` 120 | z-index: 2; 121 | width: ${props => props.size}px; 122 | height: ${props => props.size}px; 123 | `; 124 | 125 | const OtherDot = styled(Dot).attrs({ 126 | style: props => ({ 127 | transform: `translate(${props.x}px, ${props.y}px)`, 128 | }), 129 | })` 130 | z-index: 1; 131 | width: ${props => props.size}px; 132 | height: ${props => props.size}px; 133 | animation: ${fadeOut} 2s 300ms both; 134 | `; 135 | 136 | export default NotificationDot; 137 | -------------------------------------------------------------------------------- /src/components/ComposeEmail/ComposeEmail.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import SendIcon from 'react-icons/lib/md/send'; 5 | 6 | import { COLORS } from '../../constants'; 7 | 8 | import Button from '../Button'; 9 | import Spacer from '../Spacer'; 10 | import ComposeEmailAddressInput from '../ComposeEmailAddressInput'; 11 | 12 | import type { ComposingEmailData } from '../../types'; 13 | 14 | type Props = { 15 | emailData: ComposingEmailData, 16 | updateField: (fieldName: string) => (val: string) => void, 17 | handleSend: () => void, 18 | handleSave: () => void, 19 | handleClear: () => void, 20 | isClearing: boolean, 21 | }; 22 | 23 | const ComposeEmail = ({ 24 | emailData, 25 | updateField, 26 | handleSend, 27 | handleSave, 28 | handleClear, 29 | isClearing, 30 | }: Props) => ( 31 | 32 | 33 |
34 | 39 | 45 |
46 | 47 | 48 | 54 | 60 | 61 | 62 |
63 | 64 | 67 | 68 | 69 | 72 | 73 | 76 | 77 |
78 |
79 |
80 | ); 81 | 82 | const Wrapper = styled.div` 83 | height: 80vh; 84 | width: 55vh; 85 | `; 86 | 87 | const ModalContents = styled.div` 88 | position: relative; 89 | z-index: 1; 90 | display: flex; 91 | flex-direction: column; 92 | width: calc(100% - 16px); 93 | height: calc(100% - 16px); 94 | margin: 6px; 95 | background: white; 96 | `; 97 | 98 | const Header = styled.div` 99 | padding: 15px 25px 20px 15px; 100 | background: ${COLORS.gray[100]}; 101 | display: flex; 102 | flex-direction: column; 103 | justify-content: space-between; 104 | height: 106px; 105 | `; 106 | 107 | const MainContent = styled.section` 108 | flex: 1; 109 | display: flex; 110 | flex-direction: column; 111 | padding: 30px; 112 | `; 113 | 114 | const Footer = styled.div` 115 | display: flex; 116 | justify-content: space-between; 117 | padding-left: 10px; 118 | padding-right: 10px; 119 | border-top: 1px solid ${COLORS.gray[200]}; 120 | `; 121 | 122 | const Side = styled.div` 123 | height: 60px; 124 | display: flex; 125 | justify-content: space-between; 126 | align-items: center; 127 | `; 128 | 129 | const ButtonText = styled.span` 130 | font-size: 16px; 131 | font-weight: bold; 132 | `; 133 | 134 | const ClearCopy = styled(ButtonText)` 135 | color: ${COLORS.red[500]}; 136 | `; 137 | 138 | const DraftCopy = styled(ButtonText)` 139 | color: ${COLORS.purple[700]}; 140 | `; 141 | 142 | const InvisibleTextarea = styled.textarea` 143 | display: block; 144 | width: 100%; 145 | border: none; 146 | resize: none; 147 | outline: none; 148 | opacity: ${props => (props.isVisible ? 1 : 0)}; 149 | transition: ${props => 150 | props.isVisible ? 'opacity 200ms' : 'opacity 750ms ease-out 250ms'}; 151 | 152 | &:focus::placeholder { 153 | color: ${COLORS.blue[700]}; 154 | opacity: 0.6; 155 | } 156 | `; 157 | 158 | const Subject = styled(InvisibleTextarea)` 159 | font-size: 28px; 160 | padding: 15px 20px 20px; 161 | text-align: center; 162 | `; 163 | const Body = styled(InvisibleTextarea)` 164 | flex: 1; 165 | font-size: 18px; 166 | `; 167 | 168 | export default ComposeEmail; 169 | -------------------------------------------------------------------------------- /src/components/Transport/Transport.helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { 3 | AugmentedClientRect, 4 | MinimumFixedPosition, 5 | } from './Transport.types'; 6 | 7 | // Calculate the distance in pixels between two ClientRects 8 | // prettier-ignore 9 | export const getPositionDelta = ( 10 | oldRect: AugmentedClientRect, 11 | newRect: AugmentedClientRect 12 | ) => [ 13 | oldRect.left - newRect.left, 14 | oldRect.top - newRect.top 15 | ]; 16 | 17 | export const createAugmentedClientRect = ( 18 | input: HTMLElement | ClientRect, 19 | windowWidth: number, 20 | windowHeight: number 21 | ): AugmentedClientRect => { 22 | // prettier-ignore 23 | // We support either an HTMLElement or a ClientRect as input. 24 | // This is easy since a ClientRect can easily be derived from an HTML 25 | // element: 26 | const rect = input instanceof HTMLElement 27 | ? input.getBoundingClientRect() 28 | : input; 29 | 30 | return { 31 | width: rect.width, 32 | height: rect.height, 33 | 34 | top: rect.top, 35 | left: rect.left, 36 | right: rect.right, 37 | bottom: rect.bottom, 38 | centerX: rect.left + rect.width / 2, 39 | centerY: rect.top + rect.height / 2, 40 | 41 | fromBottomRight: { 42 | top: windowHeight - rect.top, 43 | left: windowWidth - rect.left, 44 | right: windowWidth - rect.right, 45 | bottom: windowHeight - rect.bottom, 46 | centerX: windowWidth - rect.right + rect.width / 2, 47 | centerY: windowHeight - rect.bottom + rect.height / 2, 48 | }, 49 | }; 50 | }; 51 | 52 | export const createAugmentedClientRectFromMinimumData = ( 53 | data: MinimumFixedPosition, 54 | childWidth: number, 55 | childHeight: number, 56 | windowWidth: number, 57 | windowHeight: number 58 | ) => { 59 | /** 60 | * During the initial position calculation, we figure out where our 61 | * child needs to move to, but for brevity, we only get the minimum 62 | * position necessary (either `top`/`bottom`, either `left`/`right`). 63 | * Later, when trying to apply the inverse translation in the FLIP step, 64 | * we'll need a proper AugmentedClientRect to do the translation calcs. 65 | * This method bridges that gap and derives the needed position data. 66 | */ 67 | 68 | if (typeof data.top !== 'number' && typeof data.bottom !== 'number') { 69 | throw new Error( 70 | 'Cannot calculate AugmentedClientRect without either top or bottom' 71 | ); 72 | } 73 | if (typeof data.left !== 'number' && typeof data.right !== 'number') { 74 | throw new Error( 75 | 'Cannot calculate AugmentedClientRect without either top or bottom' 76 | ); 77 | } 78 | 79 | // TODO: Flow doesn't like that I have these one-of-two-required args for 80 | // top/bottom and left/right. There are some possible solutions: 81 | // - http://tiny.cc/xuwvry 82 | // - http://tiny.cc/7uwvry 83 | // 84 | // For now, I'm just gonna FlowFixMe. But I should come back to this and 85 | // fix it properly. 86 | 87 | const top = 88 | typeof data.top === 'number' 89 | ? data.top 90 | : // $FlowFixMe 91 | windowHeight - data.bottom - childHeight; 92 | const left = 93 | typeof data.left === 'number' 94 | ? data.left 95 | : // $FlowFixMe 96 | windowWidth - data.right - childWidth; 97 | 98 | // The data values are in fixed positioning terms; 99 | // this means that `right` and `bottom` are the distance from that side of 100 | // the viewport. 101 | // We're creating an AugmentedClientRect, which looks at the distance from 102 | // the top/left of the viewport to the bottom/right edge of the element. 103 | const right = 104 | typeof data.right === 'number' 105 | ? windowWidth - data.right 106 | : // $FlowFixMe 107 | windowWidth - data.left - childWidth; 108 | const bottom = 109 | typeof data.bottom === 'number' 110 | ? windowHeight - data.bottom 111 | : // $FlowFixMe 112 | windowHeight - data.top - childHeight; 113 | 114 | const pseudoClientRect = { 115 | top, 116 | left, 117 | right, 118 | bottom, 119 | width: childWidth, 120 | height: childHeight, 121 | }; 122 | 123 | return createAugmentedClientRect( 124 | // Argh, so our method should take a ClientRect, but we can't create one. 125 | // This is a bug in Flow; it _should_ take a DOMRect, and so we could then 126 | // build one. 127 | // 128 | // As it stands, this is duck-typed as a ClientRect, regardless of what 129 | // Flow thinks! 130 | // 131 | // See: https://github.com/facebook/flow/issues/5475 132 | // $FlowFixMe 133 | pseudoClientRect, 134 | windowWidth, 135 | windowHeight 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // TODO: Modernize 2 | /* eslint-disable */ 3 | export const range = function(start, end, step) { 4 | var range = []; 5 | var typeofStart = typeof start; 6 | var typeofEnd = typeof end; 7 | 8 | if (step === 0) { 9 | throw TypeError('Step cannot be zero.'); 10 | } 11 | 12 | if (typeof end === 'undefined' && typeof 'step' === 'undefined') { 13 | end = start; 14 | start = 0; 15 | typeofStart = typeof start; 16 | typeofEnd = typeof end; 17 | } 18 | 19 | if (typeofStart == 'undefined' || typeofEnd == 'undefined') { 20 | throw TypeError('Must pass start and end arguments.'); 21 | } else if (typeofStart != typeofEnd) { 22 | throw TypeError('Start and end arguments must be of same type.'); 23 | } 24 | 25 | typeof step == 'undefined' && (step = 1); 26 | 27 | if (end < start) { 28 | step = -step; 29 | } 30 | 31 | if (typeofStart == 'number') { 32 | while (step > 0 ? end >= start : end <= start) { 33 | range.push(start); 34 | start += step; 35 | } 36 | } else if (typeofStart == 'string') { 37 | if (start.length != 1 || end.length != 1) { 38 | throw TypeError('Only strings with one character are supported.'); 39 | } 40 | 41 | start = start.charCodeAt(0); 42 | end = end.charCodeAt(0); 43 | 44 | while (step > 0 ? end >= start : end <= start) { 45 | range.push(String.fromCharCode(start)); 46 | start += step; 47 | } 48 | } else { 49 | throw TypeError('Only string and number types are supported'); 50 | } 51 | 52 | return range; 53 | }; 54 | /* eslint-enable */ 55 | 56 | export const sample = arr => arr[Math.floor(Math.random() * arr.length)]; 57 | 58 | export const random = (min, max) => 59 | Math.floor(Math.random() * (max - min)) + min; 60 | 61 | export const sum = values => values.reduce((sum, value) => sum + value, 0); 62 | export const mean = values => sum(values) / values.length; 63 | 64 | export const clamp = (val, min = 0, max = 1) => 65 | Math.max(min, Math.min(max, val)); 66 | 67 | export const roundTo = (number, places = 0) => 68 | Math.round(number * 10 ** places) / 10 ** places; 69 | 70 | export const debounce = (callback, wait, timeoutId = null) => (...args) => { 71 | window.clearTimeout(timeoutId); 72 | 73 | timeoutId = setTimeout(() => { 74 | callback.apply(null, args); 75 | }, wait); 76 | }; 77 | 78 | export const isEmpty = obj => Object.keys(obj).length === 0; 79 | 80 | export const pick = (obj, keys) => { 81 | var o = {}; 82 | var i = 0; 83 | var key; 84 | 85 | keys = Array.isArray(keys) ? keys : [keys]; 86 | 87 | while ((key = keys[i++])) { 88 | if (typeof obj[key] !== 'undefined') { 89 | o[key] = obj[key]; 90 | } 91 | } 92 | return o; 93 | }; 94 | 95 | export const omit = function(obj, key) { 96 | var newObj = {}; 97 | 98 | for (var name in obj) { 99 | if (name !== key) { 100 | newObj[name] = obj[name]; 101 | } 102 | } 103 | 104 | return newObj; 105 | }; 106 | 107 | export const convertArrayToMap = list => 108 | list.reduce( 109 | (acc, item) => ({ 110 | ...acc, 111 | [item.id]: item, 112 | }), 113 | {} 114 | ); 115 | 116 | // Either removes or adds an item to an array 117 | // EXAMPLE: toggleInArray([1, 2], 3) -> [1, 2, 3] 118 | // EXAMPLE: toggleInArray([1, 2], 2) -> [1] 119 | export const toggleInArray = (arr, item) => 120 | arr.includes(item) ? arr.filter(i => i !== item) : [...arr, item]; 121 | 122 | // Combines 2 arrays, removing duplicates. 123 | // EXAMPLE: mergeUnique([1, 2], [2, 3]) -> [1, 2, 3] 124 | export const mergeUnique = (arr1, arr2) => 125 | arr1.concat(arr2.filter(item => arr1.indexOf(item) === -1)); 126 | 127 | export const findRight = (arr, predicate) => 128 | arr 129 | .slice() 130 | .reverse() 131 | .find(predicate); 132 | 133 | export function requestAnimationFramePromise() { 134 | return new Promise(resolve => window.requestAnimationFrame(resolve)); 135 | } 136 | 137 | export function setTimeoutPromise(duration) { 138 | return new Promise(resolve => window.setTimeout(resolve, duration)); 139 | } 140 | 141 | export const capitalize = str => str[0].toUpperCase() + str.slice(1); 142 | 143 | export const deleteCookie = key => { 144 | document.cookie = `${encodeURIComponent( 145 | key 146 | )}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`; 147 | }; 148 | 149 | export const convertHexToRGBA = (hex, alpha = 1) => { 150 | const r = parseInt(hex.slice(1, 3), 16); 151 | const g = parseInt(hex.slice(3, 5), 16); 152 | const b = parseInt(hex.slice(5, 7), 16); 153 | 154 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 155 | }; 156 | 157 | export const hyphenate = str => str.replace(/([A-Z])/g, '-$1').toLowerCase(); 158 | 159 | export const delay = duration => 160 | new Promise(resolve => window.setTimeout(resolve, duration)); 161 | -------------------------------------------------------------------------------- /src/components/Scoocher/Scoocher.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent } from 'react'; 3 | import { Motion, spring } from 'react-motion'; 4 | import styled from 'styled-components'; 5 | 6 | import { COLORS } from '../../constants'; 7 | 8 | import type { BoxId } from '../../types'; 9 | 10 | const fastSpring = { stiffness: 120, damping: 11 }; 11 | const slowSpring = { stiffness: 120, damping: 14 }; 12 | 13 | type Props = { 14 | selectedNodeId: string, 15 | headerNodeIds: Array, 16 | boundingBoxes: { [key: string]: ClientRect }, 17 | offsetX: number, 18 | offsetY: number, 19 | }; 20 | 21 | type State = { 22 | direction?: 'left' | 'right', 23 | containerDimensions?: { 24 | top: number, 25 | left: number, 26 | width: number, 27 | height: number, 28 | }, 29 | scoocherCoordinates?: { 30 | x1: number, 31 | y1: number, 32 | x2: number, 33 | y2: number, 34 | }, 35 | }; 36 | 37 | class Scoocher extends PureComponent { 38 | static defaultProps = { 39 | offsetX: 0, 40 | offsetY: 0, 41 | }; 42 | 43 | node: HTMLElement; 44 | 45 | state = {}; 46 | 47 | componentWillReceiveProps(nextProps: Props) { 48 | const { 49 | selectedNodeId, 50 | headerNodeIds, 51 | boundingBoxes, 52 | offsetX, 53 | offsetY, 54 | } = nextProps; 55 | 56 | if ( 57 | this.props.selectedNodeId === nextProps.selectedNodeId && 58 | this.props.boundingBoxes === nextProps.boundingBoxes 59 | ) { 60 | return; 61 | } 62 | 63 | // Figure out the extremities of the supplied node refs. 64 | // Create the minimum rectangle that encompasses all of them. 65 | let top = Infinity; 66 | let left = Infinity; 67 | let right = -Infinity; 68 | let bottom = -Infinity; 69 | 70 | headerNodeIds.forEach(nodeId => { 71 | const box = boundingBoxes[nodeId]; 72 | 73 | if (!box) { 74 | return; 75 | } 76 | 77 | if (box.top < top) { 78 | top = box.top; 79 | } 80 | if (box.left < left) { 81 | left = box.left; 82 | } 83 | if (box.right > right) { 84 | right = box.right; 85 | } 86 | if (box.bottom > bottom) { 87 | bottom = box.bottom; 88 | } 89 | }); 90 | 91 | const containerDimensions = { 92 | top: top + offsetY, 93 | left: left + offsetX, 94 | width: right - left, 95 | height: bottom - top, 96 | }; 97 | 98 | const selectedNodeBox = boundingBoxes[selectedNodeId]; 99 | 100 | if (!selectedNodeBox) { 101 | return null; 102 | } 103 | 104 | const scoocherCoordinates = { 105 | x1: selectedNodeBox.left - containerDimensions.left, 106 | y1: containerDimensions.top + containerDimensions.height, 107 | x2: selectedNodeBox.right - containerDimensions.left, 108 | y2: containerDimensions.top + containerDimensions.height, 109 | }; 110 | 111 | let direction; 112 | if (this.state.scoocherCoordinates) { 113 | direction = 114 | this.state.scoocherCoordinates.x1 > scoocherCoordinates.x1 115 | ? 'left' 116 | : 'right'; 117 | } else { 118 | direction = 'right'; 119 | } 120 | 121 | this.setState({ 122 | containerDimensions, 123 | scoocherCoordinates, 124 | direction, 125 | }); 126 | } 127 | 128 | render() { 129 | const { selectedNodeId } = this.props; 130 | const { containerDimensions, scoocherCoordinates, direction } = this.state; 131 | 132 | if (!selectedNodeId || !containerDimensions || !scoocherCoordinates) { 133 | return null; 134 | } 135 | 136 | return ( 137 | 138 | 159 | {adjustedCoords => } 160 | 161 | 162 | ); 163 | } 164 | } 165 | 166 | const ScoocherContainerSvg = styled.svg` 167 | position: absolute; 168 | z-index: 10; 169 | pointer-events: none; 170 | `; 171 | 172 | const ScoochLine = styled.line` 173 | stroke-width: 5px; 174 | stroke: ${COLORS.pink[500]}; 175 | transition: 500ms; 176 | `; 177 | 178 | export default Scoocher; 179 | -------------------------------------------------------------------------------- /src/components/Foldable/Foldable.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent, Fragment } from 'react'; 3 | import styled, { keyframes } from 'styled-components'; 4 | 5 | type Props = { 6 | isFolded: boolean, 7 | front: React$Element<*>, 8 | back: React$Element<*>, 9 | duration: number, 10 | onCompleteFolding: () => void, 11 | }; 12 | 13 | class Foldable extends PureComponent { 14 | static defaultProps = { 15 | duration: 1000, 16 | }; 17 | 18 | node: ?HTMLElement; 19 | finalFoldNode: ?HTMLElement; 20 | 21 | componentDidUpdate(prevProps: Props) { 22 | const { onCompleteFolding } = this.props; 23 | 24 | if (!prevProps.isFolded && this.props.isFolded && this.finalFoldNode) { 25 | this.finalFoldNode.addEventListener('animationend', onCompleteFolding); 26 | } 27 | } 28 | 29 | componentWillUnmount() { 30 | const { onCompleteFolding } = this.props; 31 | 32 | if (this.finalFoldNode) { 33 | this.finalFoldNode.removeEventListener('animationend', onCompleteFolding); 34 | } 35 | } 36 | 37 | renderOriginal() { 38 | const { front, isFolded } = this.props; 39 | 40 | return ( 41 |
(this.node = node)} 43 | style={{ opacity: isFolded ? 0 : 1 }} 44 | > 45 | {front} 46 |
47 | ); 48 | } 49 | 50 | renderFoldedCopy() { 51 | const { back, duration } = this.props; 52 | const { node } = this; 53 | 54 | // If we weren't able to capture a ref to the node, we can't do any of this 55 | // However, I think that's impossible? This is just for Flow. 56 | if (!node) { 57 | return; 58 | } 59 | 60 | const { width, height } = node.getBoundingClientRect(); 61 | 62 | const foldHeights = [height * 0.35, height * 0.35, height * 0.3]; 63 | 64 | // HACK: using top: 0 and left: 0 because this is mounted within a 65 | // transformed container, which means that position: fixed doesn't work 66 | // properly. If you want to use this in an app, you'll likely wish to use 67 | // the top/left from node.getBoundingClientRect. 68 | return ( 69 | 70 | (this.finalFoldNode = node)} 72 | duration={duration} 73 | foldHeight={foldHeights[0]} 74 | > 75 | 76 | 80 | 81 | {back} 82 | 83 | 84 | 85 | 86 | 90 | 91 | 92 | 93 | 98 | 99 | 103 | 104 | 105 | 106 | 107 | ); 108 | } 109 | 110 | render() { 111 | return ( 112 | 113 | {this.renderOriginal()} 114 | {this.props.isFolded && this.renderFoldedCopy()} 115 | 116 | ); 117 | } 118 | } 119 | 120 | const foldBottomUp = keyframes` 121 | from { 122 | transform-origin: top center; 123 | transform: perspective(1000px) rotateX(0deg); 124 | } 125 | to { 126 | transform-origin: top center; 127 | transform: perspective(1000px) rotateX(180deg); 128 | } 129 | `; 130 | 131 | const foldTopDown = keyframes` 132 | from { 133 | transform-origin: bottom center; 134 | transform: perspective(1000px) rotateX(0deg); 135 | } 136 | to { 137 | transform-origin: bottom center; 138 | transform: perspective(1000px) rotateX(-180deg); 139 | } 140 | `; 141 | 142 | const Wrapper = styled.div` 143 | position: fixed; 144 | z-index: 10000; 145 | `; 146 | 147 | const FoldBase = styled.div` 148 | position: absolute; 149 | left: 0; 150 | right: 0; 151 | `; 152 | 153 | const TopFold = styled(FoldBase)` 154 | z-index: 3; 155 | top: 0; 156 | height: ${props => Math.round(props.foldHeight)}px; 157 | animation: ${foldTopDown} ${props => props.duration * 0.8}ms forwards 158 | ${props => props.duration * 0.33}ms; 159 | transform-style: preserve-3d; 160 | `; 161 | 162 | const MiddleFold = styled(FoldBase)` 163 | z-index: 1; 164 | top: ${props => Math.round(props.offsetTop)}px; 165 | height: ${props => Math.round(props.foldHeight)}px; 166 | `; 167 | 168 | const BottomFold = styled(FoldBase)` 169 | z-index: 2; 170 | top: ${props => Math.round(props.offsetTop)}px; 171 | height: ${props => Math.round(props.foldHeight)}px; 172 | animation: ${foldBottomUp} ${props => props.duration}ms forwards; 173 | transform-style: preserve-3d; 174 | `; 175 | 176 | const HideOverflow = styled.div` 177 | position: relative; 178 | height: 100%; 179 | z-index: 2; 180 | overflow: hidden; 181 | `; 182 | 183 | const TopFoldContents = styled.div` 184 | backface-visibility: hidden; 185 | `; 186 | const MiddleFoldContents = styled.div` 187 | position: relative; 188 | z-index: 2; 189 | height: ${props => props.height}px; 190 | transform: translateY(${props => Math.round(props.offsetTop) * -1}px); 191 | `; 192 | const BottomFoldContents = styled.div` 193 | position: relative; 194 | z-index: 2; 195 | height: ${props => props.height}px; 196 | transform: translateY(${props => Math.round(props.offsetTop) * -1}px); 197 | backface-visibility: hidden; 198 | `; 199 | 200 | const TopFoldBack = styled.div` 201 | position: absolute; 202 | z-index: 1; 203 | top: 0; 204 | left: 0; 205 | width: 100%; 206 | height: 100%; 207 | transform: rotateX(180deg); 208 | background: rgba(255, 255, 255, 0.95); 209 | backface-visibility: hidden; 210 | `; 211 | 212 | const BottomFoldBack = styled.div` 213 | position: absolute; 214 | z-index: 1; 215 | top: 0; 216 | left: 0; 217 | width: 100%; 218 | height: 100%; 219 | transform: rotateX(180deg); 220 | background: rgba(255, 255, 255, 0.95); 221 | backface-visibility: hidden; 222 | box-shadow: 0px -30px 50px -20px rgba(0, 0, 0, 0.2); 223 | `; 224 | 225 | export default Foldable; 226 | -------------------------------------------------------------------------------- /src/components/EmailProvider/EmailProvider.data.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { generators, create, createMany } from 'sharkhorse'; 3 | 4 | import { sample } from '../../utils'; 5 | 6 | import avatar1 from '../../assets/avatars/avatar-1.jpg'; 7 | import avatar2 from '../../assets/avatars/avatar-2.jpg'; 8 | import avatar3 from '../../assets/avatars/avatar-3.jpg'; 9 | import avatar4 from '../../assets/avatars/avatar-4.jpg'; 10 | import avatar5 from '../../assets/avatars/avatar-5.jpg'; 11 | import avatar6 from '../../assets/avatars/avatar-6.jpg'; 12 | import avatar7 from '../../assets/avatars/avatar-7.jpg'; 13 | import avatar8 from '../../assets/avatars/avatar-8.jpg'; 14 | import avatar9 from '../../assets/avatars/avatar-9.jpg'; 15 | import avatar10 from '../../assets/avatars/avatar-10.jpg'; 16 | import avatar11 from '../../assets/avatars/avatar-11.jpg'; 17 | import avatar12 from '../../assets/avatars/avatar-12.jpg'; 18 | import avatar13 from '../../assets/avatars/avatar-13.jpg'; 19 | import avatar14 from '../../assets/avatars/avatar-14.jpg'; 20 | import avatar15 from '../../assets/avatars/avatar-15.jpg'; 21 | import avatar16 from '../../assets/avatars/avatar-16.jpg'; 22 | import avatarWheeler from '../../assets/avatars/wheeler.jpg'; 23 | import avatarDodds from '../../assets/avatars/dodds.jpg'; 24 | import avatarNickyCase from '../../assets/avatars/nickycase.jpg'; 25 | import avatarKermit from '../../assets/avatars/kermit.gif'; 26 | import avatarHydroQuebec from '../../assets/avatars/hydro-quebec.jpg'; 27 | 28 | import type { UserData, EmailData, BoxId } from '../../types'; 29 | 30 | const avatarSrcs = [ 31 | avatar1, 32 | avatar2, 33 | avatar3, 34 | avatar4, 35 | avatar5, 36 | avatar6, 37 | avatar7, 38 | avatar8, 39 | avatar9, 40 | avatar10, 41 | avatar11, 42 | avatar12, 43 | avatar13, 44 | avatar14, 45 | avatar15, 46 | ]; 47 | 48 | const subjects = [ 49 | 'RE: Plans next Saturday?', 50 | '"JS Fatigue Fatigue" Fatigue', 51 | "OMG I'm going to be speaking at React Europe!!", 52 | 'Eggcelent Egg Salad recipe, dont share...', 53 | 'FWD: sick yoyo trick', 54 | 'Carbonated water: delicious or sinister?!', 55 | 'Going rogue: fixing bugs under-the-table', 56 | ]; 57 | 58 | const previews = [ 59 | "Hi Marcy, are we still on for that pool party on Saturday? I know John's already got his swimming trunks on.", 60 | 'Anyone else getting tired of hearing people talk about being tired of hearing people talk about JS fatigue?', 61 | 'Wooo so excited, will be talking about Whimsy at React Europe.', 62 | "Ok Tom, I'm warning you: This Egg Salad recipe will BLOW. YOUR. MIND!! It's a family secret so please NO SOCIAL MEDIA", 63 | 'Check out this SICK yoyo trick. Wow!', 64 | "What's the deal with carbonated water, eh? Is it actually just carbon in water or are those bubbles up to something", 65 | "Hey peeps, keep this underground but I'm GOING ROGUE and fixing bugs outside the sprint!?!!!!", 66 | ]; 67 | 68 | const UserFactory = { 69 | firstName: generators.name().first(), 70 | lastName: generators.name().last(), 71 | email: generators.email(), 72 | }; 73 | 74 | const EmailFactory = { 75 | id: generators.sequence(), 76 | from: UserFactory, 77 | 78 | body: generators.lorem().paragraphs(6), 79 | }; 80 | 81 | const BOX_IDS: Array = ['inbox', 'outbox', 'drafts']; 82 | 83 | export const getRandomAvatar = () => avatar16; 84 | 85 | export const generateUser = (overrides: any = {}) => { 86 | const factoryUser = create(UserFactory); 87 | 88 | return { 89 | name: `${factoryUser.firstName} ${factoryUser.lastName}`, 90 | email: factoryUser.email, 91 | avatarSrc: sample(avatarSrcs), 92 | ...overrides, 93 | }; 94 | }; 95 | 96 | export const generateData = ( 97 | userData: UserData, 98 | num: number 99 | ): Map => { 100 | let time = new Date(); 101 | 102 | const inboxEmails = [ 103 | { 104 | id: 'b', 105 | boxId: 'inbox', 106 | to: userData, 107 | from: { 108 | name: 'Gary Samsonite', 109 | email: 'gary@samsoniteagricultural.com', 110 | avatarSrc: avatar1, 111 | }, 112 | timestamp: time - 4000000, 113 | subject: 'Goat-taming kit MIA', 114 | body: 115 | "Greetings, I ordered one of your goat taming kits last week, and I notice it hasn't been shipped yet. I don't have time for this kind of behavior, please let me know when the transaction will be complete.\n\nThanks,\nGary Sampsonite", 116 | }, 117 | { 118 | id: 'c', 119 | boxId: 'inbox', 120 | to: userData, 121 | from: { 122 | name: 'Hydro Québec', 123 | email: 'no-reply@hydroquebec.qc.ca', 124 | avatarSrc: avatarHydroQuebec, 125 | }, 126 | timestamp: time - 8500000, 127 | subject: 'Your bill is ready', 128 | body: 129 | 'Hello,\n\nYour electricity bill is ready. Please pay $150 by June 2nd.', 130 | }, 131 | { 132 | id: 'd', 133 | boxId: 'inbox', 134 | to: userData, 135 | from: { 136 | name: 'Helen George', 137 | email: 'helen@gmail.com', 138 | avatarSrc: avatar2, 139 | }, 140 | timestamp: time - 12500000, 141 | subject: '12 MILLION USD TO HUMANITARIAN MISSION HELP NEEDED', 142 | body: 143 | 'GOOD DAY.\n\nURGENT - HELP ME DISTRIBUTE MY $12 MILLION TO HUMANITARIAN.\n\nTHIS MAIL MIGHT COME TO YOU AS A SURPRISE AND THE TEMPTATION TO IGNORE IT AS UNSERIOUS COULD COME INTO YOUR MIND BUT PLEASE CONSIDER IT A DIVINE WISH AND ACCEPT IT WITH A DEEP SENSE OF HUMILITY. I AM MRS HELEN GEORGE AND I AM A 61 YEARS OLD WOMAN. I AM A SOUTH AFRICAN MARRIED TO A SIERRA LEONIA.\n\nI WAS THE PRESIDENT/CEO OF OIL COMPANY INTERNATIONAL-AN OIL SERVICING COMPANY IN JOHANNESBURG. I WAS ALSO MARRIED WITH NO CHILD.\n\nMY HUSBAND DIED 3 YEARS AGO. BEFORE THIS HAPPENED MY BUSINESS AND CONCERN FOR MAKING MONEY WAS ALL I WAS LIVING FOR AND I NEVER REALLY CARED ABOUT OTHER PEOPLE. BUT SINCE THE LOSS OF MY HUSBAND AND ALSO BECAUSE I HAD HAVE NO CHILD TO CALL MY OWN, I HAVE FOUND A NEW DESIRE TO ASSIST THE HELPLESS, I HAVE BEEN HELPING ORPHANS IN ORPHANAGES/MOTHERLESS OMES/HUMANITARIANS. I HAVE DONATED SOME MONEY TO ORPHANS IN SUDAN,ETHIOPIA, CAMEROON, SPAIN, AUSTRIA, GERMANY AND SOME ASIAN COUNTRIES.\n\nIN SUMMARY:- I HAVE 12,000,000.00 (TWELVE MILLION) U. S. DOLLARS WHICH I DEPOSITED IN A SECURITY COMPANY IN COTONOU BENIN REPUBLIC AS A FAMILY TREASURE & ARTEFACTS, PLEASE I WANT YOU TO NOTE THAT THE SECURITY COMPANY DOES NOT KNOW THE REAL CONTENT TO BE MONEY AND I WANT YOU TO ASSIST ME IN CLAIMING THE CONSIGNMENT & DISTRIBUTING THE MONEY TO CHARITY ORGANIZATIONS, I AGREE TO REWARD YOU WITH PART OF THE MONEY FOR YOUR ASSISTANCE, KINDNESS AND PARTICIPATION IN THIS GODLY PROJECT. BEFORE I BECAME ILL, I KEPT $12 MILLION IN A LONG-TERM DEPOSIT IN A SECURITY COMPANY WHICH I DECLARED AS A FAMILY TREASURE ARTIFIARTS.I AM IN THE HOSPITAL WHERE I HAVE BEEN UNDERGOING TREATMENT FOR OESOPHAGEAL CANCER AND MY DOCTORS HAVE TOLD ME THAT I HAVE ONLY A FEW MONTHS TO LIVE. IT IS MY LAST WISH TO SEE THIS MONEY DISTRIBUTED TO CHARITY ORGANIZATIONS.', 144 | }, 145 | { 146 | id: 'e', 147 | boxId: 'inbox', 148 | to: userData, 149 | from: { 150 | name: 'Kent C. Dodds', 151 | email: 'kent@email.address', 152 | avatarSrc: avatarDodds, 153 | }, 154 | timestamp: time - 27000000, 155 | subject: 'Mixing Component Patterns', 156 | body: 157 | 'This last week I gave three workshops at Frontend Masters:\n\n-⚛️ 💯 Advanced React Patterns\n-📚 ⚠️ Testing Practices and Principles\n-⚛️ ⚠️ Testing React Applications\n\nIf you’re a Frontend Masters subscriber you can watch the unedited version of these courses now. Edited courses should be available for these soon.', 158 | }, 159 | { 160 | id: 'f', 161 | boxId: 'inbox', 162 | to: userData, 163 | from: { 164 | name: 'Nicky Case', 165 | email: 'ncase@email.address', 166 | avatarSrc: avatarNickyCase, 167 | }, 168 | timestamp: time - 50000000, 169 | subject: 'How do we learn? A zine.', 170 | body: 171 | 'So, you want to understand the world, and/or help others understand the world. Sadly, there are a lot of misconceptions about how people learn. Thankfully, COGNITIVE SCIENCE is showing us what _really_ works! And the first, core idea to get is...', 172 | }, 173 | { 174 | id: 'g', 175 | boxId: 'inbox', 176 | to: userData, 177 | from: { 178 | name: 'Kermit', 179 | email: 'kermit@frog.com', 180 | avatarSrc: avatarKermit, 181 | }, 182 | timestamp: time - 75000000, 183 | subject: 'Ribbit, ribbit, ribbit', 184 | body: 185 | 'Ribbit ribbit, croaaaak yip ribbit ribbit. Riiibit ribit ribbbbbit. Ribbit.', 186 | }, 187 | ]; 188 | 189 | let otherBoxEmails = createMany(EmailFactory, 20).map((data, i) => { 190 | const boxId = i % 2 === 0 ? 'outbox' : 'drafts'; 191 | 192 | const subject = subjects[i % subjects.length]; 193 | const body = previews[i % previews.length] + '\n' + data.body; 194 | const avatarSrc = avatarSrcs[i % avatarSrcs.length]; 195 | 196 | time -= Math.random() * 10000000; 197 | 198 | const generatedContact: UserData = { 199 | name: `${data.from.firstName} ${data.from.lastName}`, 200 | email: data.from.email, 201 | avatarSrc, 202 | }; 203 | 204 | return { 205 | id: data.id, 206 | boxId, 207 | from: boxId === 'inbox' ? generatedContact : userData, 208 | to: boxId === 'inbox' ? userData : generatedContact, 209 | timestamp: time, 210 | subject, 211 | body, 212 | unread: false, 213 | }; 214 | }); 215 | 216 | const emails = [...inboxEmails, ...otherBoxEmails]; 217 | 218 | // Sharkhorse's factories return an array, but I'd like to keep my data in a 219 | // map, to simulate a database. Map constructors take an array of tuples, 220 | // with the ID and the item: [ [1, email1], [2, email2], ...] 221 | return new Map(emails.map((item) => [item.id, item])); 222 | }; 223 | -------------------------------------------------------------------------------- /src/components/ComposeEmailContainer/ComposeEmailContainer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent, Fragment } from 'react'; 3 | import produce from 'immer'; 4 | import styled from 'styled-components'; 5 | import Sound from 'react-sound'; 6 | 7 | import { Z_INDICES } from '../../constants'; 8 | import { delay } from '../../utils'; 9 | // Flow doesn't like MP3s. $FlowFixMe 10 | import wooshSoundSrc from '../../assets/woosh-2.mp3'; 11 | 12 | import { AuthenticationConsumer } from '../AuthenticationProvider'; 13 | import { ModalConsumer } from '../ModalProvider'; 14 | import { NodeConsumer } from '../NodeProvider'; 15 | import { EmailConsumer } from '../EmailProvider'; 16 | import WindowDimensions from '../WindowDimensions'; 17 | import Transport from '../Transport'; 18 | import Foldable from '../Foldable'; 19 | import ComposeEmail from '../ComposeEmail'; 20 | import ComposeEmailEnvelope from '../ComposeEmailEnvelope'; 21 | import EtchASketchShaker from '../EtchASketchShaker'; 22 | 23 | import type { UserData, EmailData, ComposingEmailData } from '../../types'; 24 | 25 | type ComposeEmailStep = 26 | | 'idle' 27 | | 'opening' 28 | | 'open' 29 | | 'folding' 30 | | 'transporting' 31 | | 'clearing'; 32 | 33 | type Props = { 34 | /** 35 | * NOTE: The following props are provided by a higher-order component, 36 | * defined at the base of this file. 37 | */ 38 | handleClose: () => void, 39 | isOpen: boolean, 40 | replyTo: ?EmailData, 41 | openFromNode: HTMLElement, 42 | outboxNode: HTMLElement, 43 | draftsNode: HTMLElement, 44 | windowWidth: number, 45 | windowHeight: any, 46 | userData: UserData, 47 | addNewEmailToBox: (data: any) => void, 48 | }; 49 | 50 | type State = { 51 | status: ComposeEmailStep, 52 | actionBeingPerformed: 'send' | 'save' | 'clear' | 'dismiss' | null, 53 | // `EmailData` is the type for sent email: it includes an ID and timestamp. 54 | // For email we're composing, we just want a subset. 55 | emailData: ComposingEmailData, 56 | }; 57 | 58 | class ComposeEmailContainer extends PureComponent { 59 | state = { 60 | status: 'idle', 61 | actionBeingPerformed: null, 62 | emailData: { 63 | from: this.props.userData, 64 | toEmail: '', 65 | subject: '', 66 | body: '', 67 | }, 68 | }; 69 | 70 | componentWillReceiveProps(nextProps: Props) { 71 | if (!this.props.isOpen && nextProps.isOpen) { 72 | const initialState: $Shape = { 73 | actionBeingPerformed: null, 74 | status: 'opening', 75 | }; 76 | 77 | if (nextProps.replyTo) { 78 | initialState.emailData = { 79 | ...initialState.emailData, 80 | toEmail: nextProps.replyTo.from.email, 81 | subject: `RE: ${nextProps.replyTo.subject}`, 82 | }; 83 | } else { 84 | initialState.emailData = { 85 | ...initialState.emailData, 86 | toEmail: '', 87 | subject: '', 88 | body: '', 89 | }; 90 | } 91 | 92 | this.setState(initialState); 93 | } 94 | } 95 | 96 | setStatePromise = (newState: $Shape) => 97 | new Promise(resolve => this.setState(newState, resolve)); 98 | 99 | updateField = (fieldName: string) => (ev: SyntheticInputEvent<*>) => { 100 | this.setState({ 101 | emailData: { 102 | ...this.state.emailData, 103 | [fieldName]: ev.target.value, 104 | }, 105 | }); 106 | }; 107 | 108 | dismiss = () => { 109 | this.setState({ actionBeingPerformed: 'dismiss' }); 110 | this.props.handleClose(); 111 | }; 112 | 113 | handleOpenOrClose = () => { 114 | const { actionBeingPerformed } = this.state; 115 | 116 | const isCreatingNewEmail = 117 | actionBeingPerformed === 'send' || actionBeingPerformed === 'save'; 118 | 119 | const nextState = produce(this.state, draftState => { 120 | draftState.status = 'idle'; 121 | draftState.actionBeingPerformed = null; 122 | 123 | if (isCreatingNewEmail) { 124 | draftState.emailData.toEmail = ''; 125 | draftState.emailData.subject = ''; 126 | draftState.emailData.body = ''; 127 | } 128 | }); 129 | 130 | if (isCreatingNewEmail) { 131 | const boxId = actionBeingPerformed === 'send' ? 'outbox' : 'drafts'; 132 | this.props.addNewEmailToBox({ boxId, ...this.state.emailData }); 133 | } 134 | 135 | this.setState(nextState); 136 | }; 137 | 138 | sendEmail = () => { 139 | this.setState({ actionBeingPerformed: 'send', status: 'folding' }); 140 | }; 141 | 142 | saveEmail = () => { 143 | this.setState({ actionBeingPerformed: 'save', status: 'folding' }); 144 | }; 145 | 146 | clearEmail = async () => { 147 | // When clearing the email, we do an etch-a-sketch-like shake, with the 148 | // contents disappearing midway through. 149 | // This sequence is not interruptible, and so we'll do it all inline here. 150 | await this.setStatePromise({ 151 | actionBeingPerformed: 'clear', 152 | status: 'clearing', 153 | }); 154 | 155 | await delay(1000); 156 | 157 | this.setState({ 158 | actionBeingPerformed: null, 159 | status: 'idle', 160 | emailData: { 161 | ...this.state.emailData, 162 | subject: '', 163 | body: '', 164 | }, 165 | }); 166 | }; 167 | 168 | finishAction = () => { 169 | // This is triggerd right after the letter is finished folding, for the 170 | // 'send' action. 171 | // In that case, we want to delay by a bit so that the user has time to see 172 | // the envelope. 173 | window.setTimeout(() => { 174 | this.setState({ status: 'transporting' }); 175 | 176 | // This modal's open/close state is actually managed by the parent 177 | // . We can indicate that it should close once our letter 178 | // is "on the way" 179 | this.props.handleClose(); 180 | }, 250); 181 | }; 182 | 183 | renderFront() { 184 | return ( 185 | 186 | 194 | 195 | ); 196 | } 197 | 198 | renderBack() { 199 | if (this.state.actionBeingPerformed === 'save') { 200 | return null; 201 | } 202 | return ; 203 | } 204 | 205 | render() { 206 | const { 207 | isOpen, 208 | openFromNode, 209 | outboxNode, 210 | draftsNode, 211 | windowWidth, 212 | windowHeight, 213 | } = this.props; 214 | const { status, actionBeingPerformed } = this.state; 215 | 216 | const toNode = actionBeingPerformed === 'send' ? outboxNode : draftsNode; 217 | 218 | let TransporterStatus = isOpen ? 'open' : 'closed'; 219 | if (actionBeingPerformed === 'dismiss') { 220 | TransporterStatus = 'retracted'; 221 | } 222 | 223 | return ( 224 | 225 | 226 | 227 | 238 | 239 | 247 | 253 | 254 | 255 | ); 256 | } 257 | } 258 | 259 | const Backdrop = styled.div` 260 | position: absolute; 261 | z-index: ${Z_INDICES.modalBackdrop}; 262 | top: 0; 263 | left: 0; 264 | right: 0; 265 | bottom: 0; 266 | background: black; 267 | opacity: ${props => (props.isOpen ? 0.25 : 0)}; 268 | pointer-events: ${props => (props.isOpen ? 'auto' : 'none')}; 269 | transition: opacity 1000ms; 270 | `; 271 | 272 | // Thin wrapper which aggregates a bunch of different render-prop data 273 | // providers. This is not a very nice-looking solution, but at the time of 274 | // writing, no native `adopt` solution exists, and libraries like react-adopt 275 | // aren't compelling enough to be worth it for a demo. 276 | const withEnvironmentData = WrappedComponent => (props: any) => ( 277 | 278 | {({ userData }) => ( 279 | 280 | {({ currentModal, openFromNode, closeModal, isReply }) => ( 281 | 282 | {({ nodes }) => ( 283 | 284 | {({ selectedEmailId, emails, addNewEmailToBox }) => ( 285 | 286 | {({ windowWidth, windowHeight }) => ( 287 | 301 | )} 302 | 303 | )} 304 | 305 | )} 306 | 307 | )} 308 | 309 | )} 310 | 311 | ); 312 | export default withEnvironmentData(ComposeEmailContainer); 313 | -------------------------------------------------------------------------------- /src/components/Transport/Transport.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * This utility component can make its children appear from (or disappear to) 4 | * a given target HTMLElement. 5 | */ 6 | import React, { Component } from 'react'; 7 | import { Motion, spring } from 'react-motion'; 8 | import styled from 'styled-components'; 9 | 10 | import { 11 | getPositionDelta, 12 | createAugmentedClientRect, 13 | createAugmentedClientRectFromMinimumData, 14 | } from './Transport.helpers'; 15 | import type { 16 | AugmentedClientRect, 17 | MinimumFixedPosition, 18 | } from './Transport.types'; 19 | 20 | type Quadrant = 1 | 2 | 3 | 4; 21 | 22 | export type Status = 'open' | 'closed' | 'retracted'; 23 | 24 | type SpringSettings = { 25 | stiffness?: number, 26 | damping?: number, 27 | precision?: number, 28 | }; 29 | 30 | type Props = { 31 | children: React$Node, 32 | from: HTMLElement, 33 | to: HTMLElement, 34 | status: Status, 35 | springOpenHorizontal: SpringSettings, 36 | springOpenVertical: SpringSettings, 37 | springCloseHorizontal: SpringSettings, 38 | springCloseVertical: SpringSettings, 39 | windowWidth: number, 40 | windowHeight: number, 41 | handleFinishTransportation?: () => any, 42 | }; 43 | 44 | type State = { 45 | inTransit: boolean, 46 | position: { 47 | top: ?number, 48 | left: ?number, 49 | right: ?number, 50 | bottom: ?number, 51 | translateX: number, 52 | translateY: number, 53 | scaleX: number, 54 | scaleY: number, 55 | transformOrigin: ?string, 56 | }, 57 | }; 58 | 59 | class Transport extends Component { 60 | static defaultProps = { 61 | springOpenHorizontal: { stiffness: 150, damping: 20 }, 62 | springOpenVertical: { stiffness: 200, damping: 20 }, 63 | springCloseHorizontal: { stiffness: 150, damping: 22 }, 64 | springCloseVertical: { stiffness: 150, damping: 25 }, 65 | }; 66 | 67 | childWrapperNode: HTMLElement; 68 | fromRect: ?AugmentedClientRect; 69 | toRect: ?AugmentedClientRect; 70 | childRect: ?AugmentedClientRect; 71 | 72 | state = { 73 | inTransit: false, 74 | position: { 75 | top: null, 76 | left: null, 77 | right: null, 78 | bottom: null, 79 | scaleX: 0, 80 | scaleY: 0, 81 | translateX: 0, 82 | translateY: 0, 83 | transformOrigin: null, 84 | }, 85 | }; 86 | 87 | componentWillReceiveProps(nextProps: Props) { 88 | const { from, to, windowWidth, windowHeight } = nextProps; 89 | 90 | if (!nextProps.from || !nextProps.to || !this.childWrapperNode) { 91 | return; 92 | } 93 | 94 | const wasJustToggled = this.props.status !== nextProps.status; 95 | 96 | // HACK: So, it's currently possible for the parent to have the status 97 | // change from 'retracted' to 'closed'. While this is technically a new 98 | // state, it should not affect the Transport. 99 | // A PROPER fix would be to add some sort of FSM to control the changes 100 | // allowed between statuses, but for now I'm tackling it here, by just 101 | // ignoring any updates where neither status is `open`. 102 | if (this.props.status !== 'open' && nextProps.status !== 'open') { 103 | return; 104 | } 105 | 106 | if (wasJustToggled) { 107 | this.fromRect = createAugmentedClientRect( 108 | from, 109 | windowWidth, 110 | windowHeight 111 | ); 112 | this.toRect = createAugmentedClientRect(to, windowWidth, windowHeight); 113 | this.childRect = createAugmentedClientRect( 114 | this.childWrapperNode, 115 | windowWidth, 116 | windowHeight 117 | ); 118 | 119 | const initialPositionState = this.getInitialPositionState( 120 | nextProps.status 121 | ); 122 | 123 | this.setState( 124 | { 125 | position: initialPositionState, 126 | }, 127 | this.playAnimation 128 | ); 129 | } 130 | } 131 | 132 | getInitialPositionState(status: Status) { 133 | const { fromRect, toRect, childRect } = this; 134 | 135 | if (!fromRect || !toRect || !childRect) { 136 | throw new Error('Tried to get position without necessary rects!'); 137 | } 138 | 139 | // We want to position the element relative to the relevant node. 140 | // For opening, this is the "from" node. For closing, this is the "to" node. 141 | const relativeRect = status === 'closed' ? toRect : fromRect; 142 | 143 | // Figure out which of the 4 quarters of the screen our child is moving 144 | // to or from. 145 | const quadrant: Quadrant = this.getQuadrant(relativeRect); 146 | 147 | // The `transform-origin` of our child during transit. 148 | const transformOrigin = this.getTransformOrigin(quadrant, status); 149 | 150 | // The "minimum position" is what we need to know for our child's new home. 151 | // Consists of either a `top` or a `down`, and a `left` or a `right`. 152 | // Unlike ClientRect, these are the values in `position: fixed` terms, and 153 | // so the `right` value is the number of pixels between the element and the 154 | // right edge of the viewport. 155 | const minimumPositionData = this.getChildPosition( 156 | quadrant, 157 | relativeRect, 158 | status 159 | ); 160 | 161 | // Because our animations use CSS transforms, we need to convert our 162 | // fixed-position coords into an AugmentedClientRect 163 | const pendingChildRect = createAugmentedClientRectFromMinimumData( 164 | minimumPositionData, 165 | childRect.width, 166 | childRect.height, 167 | this.props.windowWidth, 168 | this.props.windowHeight 169 | ); 170 | 171 | const { translateX, translateY } = this.getTranslate( 172 | status, 173 | pendingChildRect 174 | ); 175 | 176 | return { 177 | ...minimumPositionData, 178 | translateX, 179 | translateY, 180 | scaleX: this.state.position.scaleX, 181 | scaleY: this.state.position.scaleY, 182 | transformOrigin, 183 | }; 184 | } 185 | 186 | playAnimation = () => { 187 | const { status } = this.props; 188 | 189 | this.setState({ 190 | inTransit: true, 191 | position: { 192 | ...this.state.position, 193 | translateX: 0, 194 | translateY: 0, 195 | scaleX: status === 'open' ? 1 : 0, 196 | scaleY: status === 'open' ? 1 : 0, 197 | }, 198 | }); 199 | }; 200 | 201 | finishPlaying = () => { 202 | this.setState({ inTransit: false }); 203 | 204 | if (typeof this.props.handleFinishTransportation === 'function') { 205 | this.props.handleFinishTransportation(); 206 | } 207 | }; 208 | 209 | getQuadrant(targetRect: ?AugmentedClientRect): Quadrant { 210 | const { windowWidth, windowHeight } = this.props; 211 | 212 | // When expanding from something, we want to use its "opposite" corner. 213 | // Imagine we divide the screen into quadrants: 214 | // ___________ 215 | // | 1 | 2 | 216 | // |-----|-----| 217 | // | 3 | 4 | 218 | // ------------ 219 | // 220 | // If the target element is in the top-left quadrant (#2), we want to open 221 | // the children from its bottom-right corner. This way, the expande item is 222 | // most likely to fit comfortably on the screen: 223 | // 224 | // ------------------------------| 225 | // | target | | 226 | // /-------- | 227 | // ----------/ | 228 | // | children | | 229 | // ---------- | 230 | // ______________________________| 231 | 232 | if (!targetRect) { 233 | throw new Error('Could not calculate quadrant, no targetRect given'); 234 | } 235 | 236 | const windowCenter = { 237 | x: windowWidth / 2, 238 | y: windowHeight / 2, 239 | }; 240 | 241 | if (targetRect.centerY < windowCenter.y) { 242 | // top half, left or right 243 | return targetRect.centerX < windowCenter.x ? 1 : 2; 244 | } else { 245 | // bottom half, left or right 246 | return targetRect.centerX < windowCenter.x ? 3 : 4; 247 | } 248 | } 249 | 250 | getTranslate(status: Status, pendingChildRect: AugmentedClientRect) { 251 | /** 252 | * This component uses the FLIP technique. 253 | * 254 | * When our open status changes, we move the node using fixed positioning 255 | * to the `to` node, and then we "invert" that effect by applying an 256 | * immediate, opposite translation. 257 | * 258 | * This method calculates that by comparing the child rect held in state 259 | * with the "pending" childRect, which is about to be applied. 260 | */ 261 | const { childRect: currentChildRect } = this; 262 | 263 | if (!currentChildRect) { 264 | throw new Error('Animation started without necessary childRect!'); 265 | } 266 | 267 | // We don't have any translation on-open. 268 | // Might change this later, if we add spacing support. 269 | if (status === 'open' || status === 'retracted') { 270 | return { translateX: 0, translateY: 0 }; 271 | } 272 | 273 | const [x, y] = getPositionDelta(currentChildRect, pendingChildRect); 274 | return { translateX: x, translateY: y }; 275 | } 276 | 277 | getTransformOrigin(quadrant: Quadrant, status: Status) { 278 | // If we're going "to" the target, we want to disappear into its center. 279 | // For this reason, the transform-origin will always be the middle. 280 | if (status === 'closed') { 281 | return 'center center'; 282 | } 283 | 284 | // If we're coming "from" the target, the transform-origin depends on the 285 | // quadrant. We want to expand outward from the element, after all. 286 | switch (quadrant) { 287 | case 1: 288 | return 'top left'; 289 | case 2: 290 | return 'top right'; 291 | case 3: 292 | return 'bottom left'; 293 | case 4: 294 | return 'bottom right'; 295 | default: 296 | throw new Error(`Unrecognized quadrant: ${quadrant}`); 297 | } 298 | } 299 | 300 | getChildPosition( 301 | quadrant: Quadrant, 302 | targetRect: AugmentedClientRect, 303 | status: Status 304 | ): MinimumFixedPosition { 305 | /** 306 | * Get the fixed position for the child, calculated using the target rect 307 | * for reference. 308 | * 309 | * This depends on two factors: 310 | * 311 | * 1. QUADRANT 312 | * The quadrant affects how the child will be positioned relative to the 313 | * target. In the first quadrant (top-left), the box opens from the 314 | * target's bottom-right corner: 315 | * _____ 316 | * | T | 317 | * |_____| _____ T = target 318 | * | C | C = child 319 | * |_____| 320 | * 321 | * When we're in the second quadrant, though, the child opens to the 322 | * _left_ of the target: 323 | * _____ 324 | * | T | 325 | * _____ ----- 326 | * | C | 327 | * ----- 328 | * Effectively, each quadrant causes the child to open from the target's 329 | * _opposite corner_. This is to ensure that the child opens on-screen 330 | * (if it always opened to the top-right, and the target was also in 331 | * the top-right corner, it would render outside of the viewport). 332 | * 333 | * 2. STATUS 334 | * When about to 'open' the child, we want to align the child with the 335 | * target's opposite corner (as shown in 1. QUADRANT). 336 | * When the direction is `to`, though, we want to align the target's 337 | * center-point to the child's center-point: 338 | * 339 | * `from`: 340 | * _______ 341 | * | | 342 | * | T | 343 | * | | T = target 344 | * ------- ___ C = child 345 | * | C | 346 | * --- 347 | * 348 | * `to`: 349 | * _______ 350 | * | ___ | 351 | * | | C | | 352 | * | --- | 353 | * ------- 354 | * 355 | * This has to do with the intended effect: the child should grow from 356 | * the target's corner, but it should shrink into the target's center. 357 | */ 358 | const { childRect } = this; 359 | 360 | if (!childRect) { 361 | throw new Error("childRect doesn't exist"); 362 | } 363 | 364 | const orientRelativeToCorner = status === 'open' || status === 'retracted'; 365 | 366 | switch (quadrant) { 367 | case 1: 368 | return { 369 | top: orientRelativeToCorner 370 | ? targetRect.bottom 371 | : targetRect.centerY - childRect.height / 2, 372 | left: orientRelativeToCorner 373 | ? targetRect.right 374 | : targetRect.centerX - childRect.width / 2, 375 | }; 376 | case 2: 377 | return { 378 | top: orientRelativeToCorner 379 | ? targetRect.bottom 380 | : targetRect.centerY - childRect.height / 2, 381 | right: orientRelativeToCorner 382 | ? targetRect.fromBottomRight.left 383 | : targetRect.fromBottomRight.centerX - childRect.width / 2, 384 | }; 385 | case 3: 386 | return { 387 | bottom: orientRelativeToCorner 388 | ? targetRect.fromBottomRight.top 389 | : targetRect.fromBottomRight.centerY - childRect.height / 2, 390 | left: orientRelativeToCorner 391 | ? targetRect.right 392 | : targetRect.centerX - childRect.width / 2, 393 | }; 394 | case 4: 395 | return { 396 | bottom: orientRelativeToCorner 397 | ? targetRect.fromBottomRight.top 398 | : targetRect.fromBottomRight.centerY - childRect.height / 2, 399 | right: orientRelativeToCorner 400 | ? targetRect.fromBottomRight.left 401 | : targetRect.fromBottomRight.centerX - childRect.width / 2, 402 | }; 403 | default: 404 | throw new Error(`Unrecognized quadrant: ${quadrant}`); 405 | } 406 | } 407 | 408 | render() { 409 | const { 410 | status, 411 | children, 412 | springOpenHorizontal, 413 | springOpenVertical, 414 | springCloseHorizontal, 415 | springCloseVertical, 416 | } = this.props; 417 | const { position, inTransit } = this.state; 418 | 419 | const { 420 | top, 421 | left, 422 | right, 423 | bottom, 424 | scaleX, 425 | scaleY, 426 | translateX, 427 | translateY, 428 | transformOrigin, 429 | } = position; 430 | 431 | const springHorizontal = 432 | status === 'closed' ? springCloseHorizontal : springOpenHorizontal; 433 | const springVertical = 434 | status === 'closed' ? springCloseVertical : springOpenVertical; 435 | 436 | return ( 437 | 454 | {({ scaleX, scaleY, translateX, translateY }) => ( 455 | { 457 | this.childWrapperNode = node; 458 | }} 459 | style={{ 460 | top, 461 | left, 462 | bottom, 463 | right, 464 | transform: ` 465 | translate(${translateX}px, ${translateY}px) 466 | scale(${Math.max(scaleX, 0)}, ${Math.max(scaleY, 0)}) 467 | `, 468 | transformOrigin, 469 | }} 470 | > 471 | {children} 472 | 473 | )} 474 | 475 | ); 476 | } 477 | } 478 | 479 | const Wrapper = styled.div` 480 | position: fixed; 481 | z-index: 10000; 482 | `; 483 | 484 | export default Transport; 485 | --------------------------------------------------------------------------------