diff --git a/internal_packages/mode-switch/lib/main.coffee b/internal_packages/mode-switch/lib/main.coffee deleted file mode 100644 index c9d59bfbc..000000000 --- a/internal_packages/mode-switch/lib/main.coffee +++ /dev/null @@ -1,19 +0,0 @@ -{ComponentRegistry, WorkspaceStore} = require 'nylas-exports' -ModeToggle = require './mode-toggle' - -# NOTE: this is a hack to allow ComponentRegistry -# to register the same component multiple times in -# different areas. if we do this more than once, let's -# dry this out. -class ModeToggleList extends ModeToggle - @displayName: 'ModeToggleList' - -module.exports = - activate: (state) -> - ComponentRegistry.register ModeToggleList, - location: WorkspaceStore.Sheet.Thread.Toolbar.Right - modes: ['list'] - - ComponentRegistry.register ModeToggle, - location: WorkspaceStore.Sheet.Threads.Toolbar.Right - modes: ['split'] diff --git a/internal_packages/mode-switch/lib/main.es6 b/internal_packages/mode-switch/lib/main.es6 new file mode 100644 index 000000000..54aa418de --- /dev/null +++ b/internal_packages/mode-switch/lib/main.es6 @@ -0,0 +1,34 @@ +import {ComponentRegistry, WorkspaceStore} from 'nylas-exports'; +import {HasTutorialTip} from 'nylas-component-kit'; + +import ModeToggle from './mode-toggle'; + +const ToggleWithTutorialTip = HasTutorialTip(ModeToggle, { + title: 'Compose with Context', + instructions: "N1 shows you everything about your contacts right inside your inbox. See LinkedIn profiles, Twitter bios, message history, and more.", +}); + +// NOTE: this is a hack to allow ComponentRegistry +// to register the same component multiple times in +// different areas. if we do this more than once, let's +// dry this out. +class ToggleWithTutorialTipList extends ToggleWithTutorialTip { + static displayName = 'ModeToggleList' +} + +export function activate() { + ComponentRegistry.register(ToggleWithTutorialTipList, { + location: WorkspaceStore.Sheet.Thread.Toolbar.Right, + modes: ['list'], + }); + + ComponentRegistry.register(ToggleWithTutorialTip, { + location: WorkspaceStore.Sheet.Threads.Toolbar.Right, + modes: ['split'], + }); +} + +export function deactivate() { + ComponentRegistry.unregister(ToggleWithTutorialTip); + ComponentRegistry.unregister(ToggleWithTutorialTipList); +} diff --git a/internal_packages/mode-switch/lib/mode-toggle.cjsx b/internal_packages/mode-switch/lib/mode-toggle.cjsx index 59cf9868a..a99a3ff77 100644 --- a/internal_packages/mode-switch/lib/mode-toggle.cjsx +++ b/internal_packages/mode-switch/lib/mode-toggle.cjsx @@ -21,7 +21,8 @@ class ModeToggle extends React.Component @_unsubscriber?() render: => - + + + ) + } + return ; + } +} diff --git a/internal_packages/notifications/lib/main.es6 b/internal_packages/notifications/lib/main.es6 index 4c1e59b9e..bf3dd8f8c 100644 --- a/internal_packages/notifications/lib/main.es6 +++ b/internal_packages/notifications/lib/main.es6 @@ -6,12 +6,14 @@ import NotificationStore from './notifications-store'; import ConnectionStatusHeader from './headers/connection-status-header'; import AccountErrorHeader from './headers/account-error-header'; import NotificationsHeader from "./headers/notifications-header"; +import TrialRemainingHeader from "./headers/trial-remaining-header"; export function activate() { ComponentRegistry.register(ActivitySidebar, {location: WorkspaceStore.Location.RootSidebar}); ComponentRegistry.register(NotificationsHeader, {location: WorkspaceStore.Sheet.Global.Header}); ComponentRegistry.register(ConnectionStatusHeader, {location: WorkspaceStore.Sheet.Global.Header}); ComponentRegistry.register(AccountErrorHeader, {location: WorkspaceStore.Sheet.Threads.Header}); + ComponentRegistry.register(TrialRemainingHeader, {location: WorkspaceStore.Sheet.Global.Header}); } export function serialize() {} @@ -21,4 +23,5 @@ export function deactivate() { ComponentRegistry.unregister(NotificationsHeader); ComponentRegistry.unregister(ConnectionStatusHeader); ComponentRegistry.unregister(AccountErrorHeader); + ComponentRegistry.unregister(TrialRemainingHeader); } diff --git a/internal_packages/notifications/stylesheets/trial-remaining-header.less b/internal_packages/notifications/stylesheets/trial-remaining-header.less new file mode 100644 index 000000000..adc904e00 --- /dev/null +++ b/internal_packages/notifications/stylesheets/trial-remaining-header.less @@ -0,0 +1,59 @@ +.trial-remaining-header { + background-color: #f6f7f8; + box-shadow: 0px 0px 3px #999; + + .notifications-sticky-item { + color: black; + align-items: center; + padding: 5px; + font-size: 12px; + } + + .icon { + border: solid #4b8e79 1px; + border-radius: 3px; + } + + .upgrade-to-pro { + margin: 0 10px; + border-radius: 5px; + color: white; + background-color: #30A797; + border: solid #30A797 1px; + padding: 0 12px; + cursor: pointer; + } +} + +@handleWidth: 100px; +@handleHeight: 30px; + +.trial-timer-wrapper { + position: relative; + flex-grow: 1; + height: 2px; + background-color: #ccc; + border-radius: 2px; + margin: 0 ~"calc(5px + @{handleWidth}/2)"; +} + +.trial-timer-progress { + position: relative; + background-color: #30A797; + width: 50%; + height: 100%; +} + +.trial-timer-handle { + background-color: #f6f7f8; + box-shadow: 0px 1px 3px #ccc; + border: solid #ccc 1px; + border-radius: 15px; + text-align: center; + padding: 0 15px; + position: absolute; + top: ~"calc(50% - @{handleHeight}/2)"; + color: #30A797; + width: @handleWidth; + height: @handleHeight; +} diff --git a/internal_packages/thread-snooze/lib/main.es6 b/internal_packages/thread-snooze/lib/main.es6 index 74055d8b6..cc85981b5 100644 --- a/internal_packages/thread-snooze/lib/main.es6 +++ b/internal_packages/thread-snooze/lib/main.es6 @@ -1,4 +1,6 @@ import {ComponentRegistry} from 'nylas-exports'; +import {HasTutorialTip} from 'nylas-component-kit'; + import {ToolbarSnooze, QuickActionSnooze} from './snooze-buttons'; import SnoozeMailLabel from './snooze-mail-label' import SnoozeStore from './snooze-store' @@ -7,8 +9,13 @@ import SnoozeStore from './snooze-store' export function activate() { this.snoozeStore = new SnoozeStore() + const ToolbarSnoozeWithTutorialTip = HasTutorialTip(ToolbarSnooze, { + title: "Handle it later!", + instructions: "Snooze this email and it'll return to your inbox later. Click here or swipe across the thread in your inbox to snooze.", + }); + this.snoozeStore.activate() - ComponentRegistry.register(ToolbarSnooze, {role: 'ThreadActionsToolbarButton'}); + ComponentRegistry.register(ToolbarSnoozeWithTutorialTip, {role: 'ThreadActionsToolbarButton'}); ComponentRegistry.register(QuickActionSnooze, {role: 'ThreadListQuickAction'}); ComponentRegistry.register(SnoozeMailLabel, {role: 'Thread:MailLabel'}); } diff --git a/src/components/decorators/has-tutorial-tip.es6 b/src/components/decorators/has-tutorial-tip.es6 new file mode 100644 index 000000000..8fa963493 --- /dev/null +++ b/src/components/decorators/has-tutorial-tip.es6 @@ -0,0 +1,214 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import _ from 'underscore'; + +import {Actions} from 'nylas-exports'; +import NylasStore from 'nylas-store'; + +const TipsBackgroundEl = document.createElement('tutorial-tip-background'); +document.body.appendChild(TipsBackgroundEl); + + +class TipsStore extends NylasStore { + constructor() { + super(); + + this._tipKeys = []; + } + + isTipVisible(key) { + const seen = NylasEnv.config.get('core.tutorial.seen') || []; + return this._tipKeys.find(t => !seen.includes(t)) === key; + } + + hasSeenTip(key) { + return (NylasEnv.config.get('core.tutorial.seen') || []).includes(key); + } + + // Actions: Since this is a private store just inside this file, we call + // these methods directly for now. + + mountedTip = (key) => { + if (!this._tipKeys.includes(key)) { + this._tipKeys.push(key); + } + this.trigger(); + } + + seenTip = (key) => { + this._tipKeys = this._tipKeys.filter(t => t !== key); + NylasEnv.config.pushAtKeyPath('core.tutorial.seen', key); + this.trigger(); + } + + unmountedTip = (key) => { + this._tipKeys = this._tipKeys.filter(t => t !== key); + this.trigger(); + } +} + +TipsStore = new TipsStore(); + +class TipPopoverContents extends React.Component { + static propTypes = { + title: React.PropTypes.string, + tipKey: React.PropTypes.string, + instructions: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element]), + onDismissed: React.PropTypes.func, + } + + componentDidMount() { + TipsBackgroundEl.classList.add('visible'); + } + + componentWillUnmount() { + TipsBackgroundEl.classList.remove('visible'); + if (this.props.onDismissed) { + this.props.onDismissed(); + } + } + + onDone = () => { + TipsStore.seenTip(this.props.tipKey); + Actions.closePopover(); + } + + render() { + let content = null; + + if (typeof(this.props.instructions) === 'string') { + content =

; + } else { + content =

{this.props.instructions}

+ } + + return ( +
+

{this.props.title}

+ {content} + +
+ ); + } +} + +export default function HasTutorialTip(ComposedComponent, TipConfig) { + const TipKey = ComposedComponent.displayName; + + if (TipsStore.hasSeenTip(TipKey)) { + return ComposedComponent; + } + + return class extends ComposedComponent { + static displayName = ComposedComponent.displayName; + + constructor(props) { + super(props); + this.state = {visible: false}; + } + + componentDidMount() { + TipsStore.mountedTip(TipKey); + + this._unlisten = TipsStore.listen(this._onTooltipStateChanged); + window.addEventListener('resize', this._onRecomputeTooltipPosition); + + // unfortunately, we can't render() a container around ComposedComponent + // without modifying the DOM tree and messing with things like flexbox. + // Instead, we leave render() unchanged and attach the bubble and hover + // listeners to the DOM manually. + + this.tipNode = document.createElement('div'); + this.tipNode.classList.add('tutorial-tip'); + document.body.appendChild(this.tipNode); + + const el = ReactDOM.findDOMNode(this); + el.addEventListener('mouseover', this._onMouseOver); + this._onTooltipStateChanged(); + } + + componentDidUpdate() { + if (this.state.visible) { + this._onRecomputeTooltipPosition(); + } + } + + componentWillUnmount() { + this._unlisten(); + + window.removeEventListener('resize', this._onRecomputeTooltipPosition); + document.body.removeChild(this.tipNode); + + TipsStore.unmountedTip(TipKey); + } + + _onTooltipStateChanged = () => { + const visible = TipsStore.isTipVisible(TipKey); + + if (this.state.visible !== visible) { + this.setState({visible}); + if (visible) { + this.tipNode.classList.add('visible'); + this._onRecomputeTooltipPosition(); + } else { + this.tipNode.classList.remove('visible'); + } + } + } + + _onMouseOver = () => { + if (!this.state.visible) { + return; + } + + const el = ReactDOM.findDOMNode(this); + el.removeEventListener('mouseover', this._onMouseOver); + + const tipRect = this.tipNode.getBoundingClientRect(); + const rect = ReactDOM.findDOMNode(this).getBoundingClientRect(); + const rectCX = rect.left + rect.width / 2; + const rectCY = rect.top + rect.height / 2; + TipsBackgroundEl.style.background = ` + -webkit-radial-gradient( + ${Math.round(rectCX / window.innerWidth * 100)}% + ${Math.round(rectCY / window.innerHeight * 100)}%, + circle, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 3%, rgba(0, 0, 0, 0.2) 5%) + `; + Actions.openPopover(( + { + el.addEventListener('mouseover', this._onMouseOver); + }} + /> + ), { + originRect: tipRect, + direction: 'down', + fallbackDirection: 'up', + }) + } + + _onRecomputeTooltipPosition = () => { + const el = ReactDOM.findDOMNode(this); + let settled = 0; + let last = {}; + const attempt = () => { + const {left, top} = el.getBoundingClientRect(); + this.tipNode.style.left = `${left + 5}px`; + this.tipNode.style.top = `${top + 5}px`; + + if (!_.isEqual(last, {left, top})) { + settled = 0; + last = {left, top}; + } + settled += 1; + if (settled < 5) { + window.requestAnimationFrame(this._onRecomputeTooltipPosition); + } + } + attempt(); + } + } +} diff --git a/src/components/metadata-composer-toggle-button.jsx b/src/components/metadata-composer-toggle-button.jsx index 9788fd166..59e128df9 100644 --- a/src/components/metadata-composer-toggle-button.jsx +++ b/src/components/metadata-composer-toggle-button.jsx @@ -116,7 +116,12 @@ export default class MetadataComposerToggleButton extends React.Component { } return ( - ); diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index 0a2eb3cd8..ccfd5a18c 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -93,5 +93,6 @@ class NylasComponentKit @load "ListensToObservable", 'decorators/listens-to-observable' @load "ListensToFluxStore", 'decorators/listens-to-flux-store' @load "ListensToMovementKeys", 'decorators/listens-to-movement-keys' + @load "HasTutorialTip", 'decorators/has-tutorial-tip' module.exports = new NylasComponentKit() diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index b56cf501d..749cc6888 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -143,6 +143,8 @@ class NylasExports @lazyLoadAndRegisterStore "SearchableComponentStore", 'searchable-component-store' @lazyLoad "CustomContenteditableComponents", 'components/overlaid-components/custom-contenteditable-components' + @lazyLoad "ServiceRegistry", "service-registry" + # Decorators @lazyLoad "InflatesDraftClientId", 'decorators/inflates-draft-client-id' diff --git a/src/pro b/src/pro index 736c1ad80..b47405f99 160000 --- a/src/pro +++ b/src/pro @@ -1 +1 @@ -Subproject commit 736c1ad8017e934ad01736888e9892f05ddb2268 +Subproject commit b47405f99f77fe3149ddb55453013ab1058031a8 diff --git a/src/service-registry.es6 b/src/service-registry.es6 new file mode 100644 index 000000000..6275aaec1 --- /dev/null +++ b/src/service-registry.es6 @@ -0,0 +1,31 @@ +class ServiceRegistry { + constructor() { + this._waitingForServices = {}; + this._services = {}; + } + + withService(name, callback) { + if (this._services[name]) { + process.nextTick(() => callback(this._services[name])); + } else { + this._waitingForServices[name] = this._waitingForServices[name] || []; + this._waitingForServices[name].push(callback); + } + } + + registerService(name, obj) { + this._services[name] = obj; + if (this._waitingForServices[name]) { + for (const callback of this._waitingForServices[name]) { + callback(obj); + } + delete this._waitingForServices[name]; + } + } + + unregisterService(name) { + delete this._services[name]; + } +} + +export default new ServiceRegistry() diff --git a/static/components/tutorial-overlay.less b/static/components/tutorial-overlay.less new file mode 100644 index 000000000..a3c68b6e3 --- /dev/null +++ b/static/components/tutorial-overlay.less @@ -0,0 +1,78 @@ +tutorial-tip-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: -webkit-radial-gradient(50% 50%, circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 4%, rgba(0,0,0,0.2) 7%); + z-index: 20; + pointer-events: none; + opacity: 0; + transition: opacity ease-in-out 250ms; +} + +tutorial-tip-background.visible { + pointer-events: inherit; + opacity: 1; +} + +.tutorial-tip { + position: absolute; + width: 13px; + height: 13px; + border-radius: 50%; + display: inline-block; + border: 2px solid white; + cursor: pointer; + pointer-events: none; + z-index: 100; + transform: translate3d(-50%,-50%,0); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgb(241, 170, 211)), to(rgb(185, 59, 255))); + opacity: 0; + transition: opacity ease-out 100ms; +} + +.tutorial-tip.visible { + opacity: 1; +} + +body { + background: #000; +} + +.tutorial-tip.visible:after { + pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + content: ''; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + + box-shadow: 0 0 0 2px rgb(241, 170, 211); + + animation: sonarEffect 2s ease-out 75ms; + animation-iteration-count: infinite; +} + +@-webkit-keyframes sonarEffect { + 0% { + opacity: 0.3; + } + 40% { + opacity: 0.7; + box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5); + } + 80% { + box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5); + -webkit-transform: scale(1.4); + opacity: 0.5; + } + 100% { + box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5); + -webkit-transform: scale(1.5); + opacity: 0; + } +} diff --git a/static/images/notification/nylas-identity-seafoam@1x.png b/static/images/notification/nylas-identity-seafoam@1x.png new file mode 100644 index 000000000..226e7dca1 Binary files /dev/null and b/static/images/notification/nylas-identity-seafoam@1x.png differ diff --git a/static/images/notification/nylas-identity-seafoam@2x.png b/static/images/notification/nylas-identity-seafoam@2x.png new file mode 100644 index 000000000..82fefc46b Binary files /dev/null and b/static/images/notification/nylas-identity-seafoam@2x.png differ diff --git a/static/index.less b/static/index.less index f30dbf2a4..79be91825 100644 --- a/static/index.less +++ b/static/index.less @@ -40,3 +40,4 @@ @import "components/table"; @import "components/editable-table"; @import "components/multiselect-dropdown"; +@import "components/tutorial-overlay";