feat(tutorial): Overlay bubbles that guide you through initial features

Summary:
Add header to show how many trial days remain

More rendering out of Store, consolidate registry and store and expose via new serviceRegistry

WIP

Merge branch 'master' into hallamoore/feature-walkthrough-tutorial

Switch to using observable instead of AbortablePromise

Update submodule

WIP

WIP

Remove annotations

Remove changes

WIP

Test Plan: No tests

Reviewers: evan, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D3260
This commit is contained in:
Ben Gotow 2016-09-13 02:23:39 -04:00
parent 78a999feb7
commit 7533c7ae81
17 changed files with 529 additions and 23 deletions

View file

@ -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']

View file

@ -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);
}

View file

@ -21,7 +21,8 @@ class ModeToggle extends React.Component
@_unsubscriber?()
render: =>
<button className="btn btn-toolbar mode-toggle mode-#{@state.hidden}"
<button
className="btn btn-toolbar mode-toggle mode-#{@state.hidden}"
style={order:500}
title={if @state.hidden then "Show sidebar" else "Hide sidebar"}
onClick={@_onToggleMode}>

View file

@ -0,0 +1,89 @@
import {shell} from 'electron';
import {React, IdentityStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
let NUM_TRIAL_DAYS = 30;
const HANDLE_WIDTH = 100;
export default class TrialRemainingHeader extends React.Component {
static displayName = "TrialRemainingHeader";
constructor(props) {
super(props)
this.state = this.getStateFromStores();
}
componentDidMount() {
this._unlisten = IdentityStore.listen(() =>
this.setState(this.getStateFromStores())
);
}
componentWillUnmount() {
if (this._unlisten) {
this._unlisten();
}
}
getStateFromStores = () => {
const daysRemaining = IdentityStore.daysUntilSubscriptionRequired();
if (daysRemaining > NUM_TRIAL_DAYS) {
NUM_TRIAL_DAYS = daysRemaining;
console.error("Unexpected number of days remaining in trial");
}
const inTrial = IdentityStore.subscriptionState() === IdentityStore.State.Trialing;
const daysIntoTrial = NUM_TRIAL_DAYS - daysRemaining;
const percentageIntoTrial = (NUM_TRIAL_DAYS - daysRemaining) / NUM_TRIAL_DAYS * 100;
return {
inTrial,
daysRemaining,
daysIntoTrial,
percentageIntoTrial,
handleStyle: {
left: `calc(${percentageIntoTrial}% - ${HANDLE_WIDTH / 2}px)`,
},
};
}
_onUpgrade = () => {
this.setState({buildingUpgradeURL: true});
const utm = {
source: "UpgradeBanner",
campaign: "TrialStillActive",
}
IdentityStore.fetchSingleSignOnURL('/payment', utm).then((url) => {
this.setState({buildingUpgradeURL: false});
shell.openExternal(url);
});
}
render() {
if (this.state.inTrial && this.state.daysRemaining !== 0) {
return (
<div className="trial-remaining-header notifications-sticky">
<div className="notifications-sticky-item">
<RetinaImg
className="icon"
name="nylas-identity-seafoam.png"
mode={RetinaImg.Mode.ContentPreserve}
stype={{height: "20px"}}
/>
Nylas N1 is in Trial Mode
<div className="trial-timer-wrapper">
<div className="trial-timer-progress" style={{width: `${this.state.percentageIntoTrial}%`}}></div>
<div className="trial-timer-handle" style={this.state.handleStyle}>
{NUM_TRIAL_DAYS - this.state.daysIntoTrial} Days Left
</div>
</div>
{this.state.daysIntoTrial}/{NUM_TRIAL_DAYS} Trial Days
<button className="upgrade-to-pro" onClick={this._onUpgrade}>
{this.state.buildingUpgradeURL ? "Please Wait..." : "Upgrade to Nylas Pro"}
</button>
</div>
</div>
)
}
return <span />;
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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'});
}

View file

@ -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 = <p dangerouslySetInnerHTML={{__html: this.props.instructions}} />;
} else {
content = <p>{this.props.instructions}</p>
}
return (
<div style={{width: 250, padding: 20, paddingTop: 0}}>
<h2>{this.props.title}</h2>
{content}
<button className="btn" onClick={this.onDone}>Got it!</button>
</div>
);
}
}
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((
<TipPopoverContents
tipKey={TipKey}
title={TipConfig.title}
instructions={TipConfig.instructions}
onDismissed={() => {
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();
}
}
}

View file

@ -116,7 +116,12 @@ export default class MetadataComposerToggleButton extends React.Component {
}
return (
<button className={className} onClick={this._onClick} title={title} tabIndex={-1}>
<button
className={className}
onClick={this._onClick}
title={title}
tabIndex={-1}
>
<RetinaImg {...attrs} mode={RetinaImg.Mode.ContentIsMask} />
</button>
);

View file

@ -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()

View file

@ -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'

@ -1 +1 @@
Subproject commit 736c1ad8017e934ad01736888e9892f05ddb2268
Subproject commit b47405f99f77fe3149ddb55453013ab1058031a8

31
src/service-registry.es6 Normal file
View file

@ -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()

View file

@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -40,3 +40,4 @@
@import "components/table";
@import "components/editable-table";
@import "components/multiselect-dropdown";
@import "components/tutorial-overlay";