diff --git a/app/internal_packages/activity/specs/activity-list-spec.jsx b/app/internal_packages/activity/specs/activity-list-spec.jsx index 112087fb6..a93ef3438 100644 --- a/app/internal_packages/activity/specs/activity-list-spec.jsx +++ b/app/internal_packages/activity/specs/activity-list-spec.jsx @@ -103,10 +103,7 @@ pluginValue = { ], }; messages[1].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue); -pluginValue = { - links: [], - tracked: false, -}; +pluginValue = {}; messages[1].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue); pluginValue = { open_count: 0, diff --git a/app/internal_packages/link-tracking/assets/ic-modal-image@2x.png b/app/internal_packages/link-tracking/assets/ic-modal-image@2x.png new file mode 100644 index 000000000..670d0f913 Binary files /dev/null and b/app/internal_packages/link-tracking/assets/ic-modal-image@2x.png differ diff --git a/app/internal_packages/link-tracking/lib/link-tracking-button.jsx b/app/internal_packages/link-tracking/lib/link-tracking-button.jsx index c6cea9771..5909dde41 100644 --- a/app/internal_packages/link-tracking/lib/link-tracking-button.jsx +++ b/app/internal_packages/link-tracking/lib/link-tracking-button.jsx @@ -17,11 +17,6 @@ export default class LinkTrackingButton extends React.Component { ); } - _title(enabled) { - const dir = enabled ? 'Disable' : 'Enable'; - return `${dir} link tracking`; - } - _errorMessage(error) { if ( error instanceof APIError && @@ -35,12 +30,10 @@ export default class LinkTrackingButton extends React.Component { render() { return ( { try { - await FeatureUsageStore.asyncUseFeature('contact-profiles', { + await FeatureUsageStore.markUsedOrUpgrade('contact-profiles', { usedUpHeader: 'All Contact Previews Used', usagePhrase: 'view contact profiles for', iconUrl: 'mailspring://participant-profile/assets/ic-contact-profile-modal@2x.png', diff --git a/app/internal_packages/send-later/lib/send-later-button.jsx b/app/internal_packages/send-later/lib/send-later-button.jsx index dadb32d2f..893488a08 100644 --- a/app/internal_packages/send-later/lib/send-later-button.jsx +++ b/app/internal_packages/send-later/lib/send-later-button.jsx @@ -63,7 +63,7 @@ class SendLaterButton extends Component { // already set to send later. if (!currentSendLaterDate) { try { - await FeatureUsageStore.asyncUseFeature('send-later', { + await FeatureUsageStore.markUsedOrUpgrade('send-later', { usedUpHeader: 'All Scheduled Sends Used', usagePhrase: 'schedule sending of', iconUrl: 'mailspring://send-later/assets/ic-send-later-modal@2x.png', diff --git a/app/internal_packages/send-reminders/lib/send-reminders-utils.es6 b/app/internal_packages/send-reminders/lib/send-reminders-utils.es6 index a5d9d251d..7a08da4c6 100644 --- a/app/internal_packages/send-reminders/lib/send-reminders-utils.es6 +++ b/app/internal_packages/send-reminders/lib/send-reminders-utils.es6 @@ -24,7 +24,7 @@ async function incrementMetadataUse(model, expiration) { return true; } try { - await FeatureUsageStore.asyncUseFeature(PLUGIN_ID, FEATURE_LEXICON); + await FeatureUsageStore.markUsedOrUpgrade(PLUGIN_ID, FEATURE_LEXICON); } catch (error) { if (error instanceof FeatureUsageStore.NoProAccessError) { return false; diff --git a/app/internal_packages/thread-snooze/lib/snooze-store.es6 b/app/internal_packages/thread-snooze/lib/snooze-store.es6 index 2c55694b3..eaafc2c4c 100644 --- a/app/internal_packages/thread-snooze/lib/snooze-store.es6 +++ b/app/internal_packages/thread-snooze/lib/snooze-store.es6 @@ -46,7 +46,7 @@ class SnoozeStore extends MailspringStore { _onSnoozeThreads = async (threads, snoozeDate, label) => { try { // ensure the user is authorized to use this feature - await FeatureUsageStore.asyncUseFeature('snooze', { + await FeatureUsageStore.markUsedOrUpgrade('snooze', { usedUpHeader: 'All Snoozes Used', usagePhrase: 'snooze', iconUrl: 'mailspring://thread-snooze/assets/ic-snooze-modal@2x.png', diff --git a/app/spec/stores/feature-usage-store-spec.es6 b/app/spec/stores/feature-usage-store-spec.es6 index 94338b12e..6f3f83a30 100644 --- a/app/spec/stores/feature-usage-store-spec.es6 +++ b/app/spec/stores/feature-usage-store-spec.es6 @@ -26,18 +26,18 @@ describe('FeatureUsageStore', function featureUsageStoreSpec() { IdentityStore._identity = this.oldIdent; }); - describe('_isUsable', () => { + describe('isUsable', () => { it("returns true if a feature hasn't met it's quota", () => { - expect(FeatureUsageStore._isUsable('is-usable')).toBe(true); + expect(FeatureUsageStore.isUsable('is-usable')).toBe(true); }); it('returns false if a feature is at its quota', () => { - expect(FeatureUsageStore._isUsable('not-usable')).toBe(false); + expect(FeatureUsageStore.isUsable('not-usable')).toBe(false); }); it('returns true if no quota is present for the feature', () => { spyOn(AppEnv, 'reportError'); - expect(FeatureUsageStore._isUsable('unsupported')).toBe(true); + expect(FeatureUsageStore.isUsable('unsupported')).toBe(true); expect(AppEnv.reportError).toHaveBeenCalled(); }); }); @@ -74,7 +74,7 @@ describe('FeatureUsageStore', function featureUsageStoreSpec() { }); it("marks the feature used if it's usable", async () => { - await FeatureUsageStore.asyncUseFeature('is-usable'); + await FeatureUsageStore.markUsedOrUpgrade('is-usable'); expect(FeatureUsageStore._markFeatureUsed).toHaveBeenCalled(); expect(FeatureUsageStore._markFeatureUsed.callCount).toBe(1); }); @@ -93,7 +93,7 @@ describe('FeatureUsageStore', function featureUsageStoreSpec() { IdentityStore._identity.featureUsage['not-usable'].quota = 10000; FeatureUsageStore._onModalClose(); }); - await FeatureUsageStore.asyncUseFeature('not-usable', this.lexicon); + await FeatureUsageStore.markUsedOrUpgrade('not-usable', this.lexicon); expect(Actions.openModal).toHaveBeenCalled(); expect(Actions.openModal.calls.length).toBe(1); }); @@ -103,7 +103,7 @@ describe('FeatureUsageStore', function featureUsageStoreSpec() { IdentityStore._identity.featureUsage['not-usable'].quota = 10000; FeatureUsageStore._onModalClose(); }); - await FeatureUsageStore.asyncUseFeature('not-usable', this.lexicon); + await FeatureUsageStore.markUsedOrUpgrade('not-usable', this.lexicon); expect(Actions.openModal).toHaveBeenCalled(); expect(Actions.openModal.calls.length).toBe(1); const component = Actions.openModal.calls[0].args[0].component; @@ -122,7 +122,7 @@ describe('FeatureUsageStore', function featureUsageStoreSpec() { FeatureUsageStore._onModalClose(); }); try { - await FeatureUsageStore.asyncUseFeature('not-usable', this.lexicon); + await FeatureUsageStore.markUsedOrUpgrade('not-usable', this.lexicon); } catch (err) { expect(err instanceof FeatureUsageStore.NoProAccessError).toBe(true); caughtError = true; diff --git a/app/src/components/metadata-composer-toggle-button.jsx b/app/src/components/metadata-composer-toggle-button.jsx index 605152c14..a27a487a5 100644 --- a/app/src/components/metadata-composer-toggle-button.jsx +++ b/app/src/components/metadata-composer-toggle-button.jsx @@ -1,4 +1,11 @@ -import { React, PropTypes, Actions, MailspringAPIRequest, APIError } from 'mailspring-exports'; +import { + React, + PropTypes, + Actions, + MailspringAPIRequest, + APIError, + FeatureUsageStore, +} from 'mailspring-exports'; import { RetinaImg } from 'mailspring-component-kit'; import classnames from 'classnames'; import _ from 'underscore'; @@ -7,34 +14,33 @@ export default class MetadataComposerToggleButton extends React.Component { static displayName = 'MetadataComposerToggleButton'; static propTypes = { - title: PropTypes.func.isRequired, iconUrl: PropTypes.string, iconName: PropTypes.string, pluginId: PropTypes.string.isRequired, pluginName: PropTypes.string.isRequired, metadataEnabledValue: PropTypes.object.isRequired, - stickyToggle: PropTypes.bool, errorMessage: PropTypes.func.isRequired, draft: PropTypes.object.isRequired, session: PropTypes.object.isRequired, }; - static defaultProps = { - stickyToggle: false, - }; - constructor(props) { super(props); this.state = { pending: false, + onByDefaultButUsedUp: false, }; } componentWillMount() { if (this._isEnabledByDefault() && !this._isEnabled()) { - this._setEnabled(true); + if (FeatureUsageStore.isUsable(this.props.pluginId)) { + this._setEnabled(true); + } else { + this.setState({ onByDefaultButUsedUp: true }); + } } } @@ -61,11 +67,9 @@ export default class MetadataComposerToggleButton extends React.Component { try { session.changes.addPluginMetadata(pluginId, metadataValue); } catch (error) { - const { stickyToggle, errorMessage } = this.props; + const { errorMessage } = this.props; - if (stickyToggle) { - AppEnv.config.set(this._configKey(), false); - } + AppEnv.config.set(this._configKey(), false); let title = 'Error'; if (!(error instanceof APIError)) { @@ -82,23 +86,41 @@ export default class MetadataComposerToggleButton extends React.Component { this.setState({ pending: false }); } - _onClick = () => { + _onClick = async () => { + const { pluginName, pluginId } = this.props; + let nextEnabled = !this._isEnabled(); + const dir = nextEnabled ? 'Enabled' : 'Disabled'; + if (this.state.pending) { return; } - const enabled = this._isEnabled(); - const dir = enabled ? 'Disabled' : 'Enabled'; - Actions.recordUserEvent(`${this.props.pluginName} ${dir}`); - if (this.props.stickyToggle) { - AppEnv.config.set(this._configKey(), !enabled); + // note: we don't actually increment the usage counters until you /send/ + // the message with link and open tracking, we just display the notice + + if (nextEnabled && !FeatureUsageStore.isUsable(pluginId)) { + try { + await FeatureUsageStore.displayUpgradeModal(pluginId, { + usedUpHeader: `All used up!`, + usagePhrase: 'get open and click notifications for', + iconUrl: `mailspring://${pluginId}/assets/ic-modal-image@2x.png`, + }); + } catch (err) { + // user does not have access to this feature + if (this.state.onByDefaultButUsedUp) { + this.setState({ onByDefaultButUsedUp: false }); + } + nextEnabled = false; + } } - this._setEnabled(!enabled); + + Actions.recordUserEvent(`${pluginName} ${dir}`); + AppEnv.config.set(this._configKey(), nextEnabled); + this._setEnabled(nextEnabled); }; render() { const enabled = this._isEnabled(); - const title = this.props.title(enabled); const className = classnames({ btn: true, @@ -115,7 +137,17 @@ export default class MetadataComposerToggleButton extends React.Component { } return ( - ); diff --git a/app/src/flux/stores/feature-usage-store.jsx b/app/src/flux/stores/feature-usage-store.jsx index c15c42149..5879b7fdc 100644 --- a/app/src/flux/stores/feature-usage-store.jsx +++ b/app/src/flux/stores/feature-usage-store.jsx @@ -68,7 +68,7 @@ class FeatureUsageStore extends MailspringStore { this._usub(); } - displayUpgradeModal(feature, { lexicon }) { + displayUpgradeModal(feature, lexicon) { const { headerText, rechargeText } = this._modalText(feature, lexicon); Actions.openModal({ @@ -83,25 +83,45 @@ class FeatureUsageStore extends MailspringStore { /> ), }); - } - - async asyncUseFeature(feature, lexicon = {}) { - if (this._isUsable(feature)) { - this._markFeatureUsed(feature); - return true; - } - - this.displayUpgradeModal(feature, { lexicon }); return new Promise((resolve, reject) => { this._waitForModalClose.push({ resolve, reject, feature }); }); } + isUsable(feature) { + const { usedInPeriod, quota } = this._dataForFeature(feature); + if (!quota) { + return true; + } + return usedInPeriod < quota; + } + + async markUsedOrUpgrade(feature, lexicon = {}) { + if (!this.isUsable(feature)) { + // throws if the user declines + await this.displayUpgradeModal(feature, lexicon); + } + this.markUsed(feature); + } + + markUsed(feature) { + const next = JSON.parse(JSON.stringify(IdentityStore.identity())); + console.log('Next:'); + console.log(JSON.stringify(next)); + + if (next.featureUsage[feature]) { + next.featureUsage[feature].usedInPeriod += 1; + IdentityStore.saveIdentity(next); + } + if (!UsageRecordedServerSide.includes(feature)) { + Actions.queueTask(new SendFeatureUsageEventTask({ feature })); + } + } + _onModalClose = async () => { for (const { feature, resolve, reject } of this._waitForModalClose) { - if (this._isUsable(feature)) { - this._markFeatureUsed(feature); + if (this.isUsable(feature)) { resolve(); } else { reject(new NoProAccessError(feature)); @@ -145,25 +165,6 @@ class FeatureUsageStore extends MailspringStore { } return usage[feature]; } - - _isUsable(feature) { - const { usedInPeriod, quota } = this._dataForFeature(feature); - if (!quota) { - return true; - } - return usedInPeriod < quota; - } - - _markFeatureUsed(feature) { - const next = JSON.parse(JSON.stringify(IdentityStore.identity())); - if (next.featureUsage[feature]) { - next.featureUsage[feature].usedInPeriod += 1; - IdentityStore.saveIdentity(next); - } - if (!UsageRecordedServerSide.includes(feature)) { - Actions.queueTask(new SendFeatureUsageEventTask({ feature })); - } - } } export default new FeatureUsageStore(); diff --git a/app/src/flux/tasks/send-draft-task.es6 b/app/src/flux/tasks/send-draft-task.es6 index 0688bf56a..b9876cf39 100644 --- a/app/src/flux/tasks/send-draft-task.es6 +++ b/app/src/flux/tasks/send-draft-task.es6 @@ -1,6 +1,7 @@ /* eslint global-require: 0 */ import url from 'url'; import AccountStore from '../stores/account-store'; +import FeatureUsageStore from '../stores/feature-usage-store'; import Task from './task'; import Actions from '../actions'; import Attributes from '../attributes'; @@ -57,13 +58,11 @@ export default class SendDraftTask extends Task { } isOpenTrackingEnabled() { - const metadata = this.draft.metadataForPluginId('open-tracking'); - return metadata && Object.keys(metadata).length > 0; + return !!this.draft.metadataForPluginId('open-tracking'); } isLinkTrackingEnabled() { - const metadata = this.draft.metadataForPluginId('link-tracking'); - return metadata && Object.keys(metadata).length > 0; + return !!this.draft.metadataForPluginId('link-tracking'); } label() { @@ -97,6 +96,14 @@ export default class SendDraftTask extends Task { if (AppEnv.config.get('core.sending.sounds') && !this.silent) { SoundRegistry.playSound('send'); } + + // Fire off events to record the usage of open and link tracking + if (this.isOpenTrackingEnabled()) { + FeatureUsageStore.markUsed('open-tracking'); + } + if (this.isLinkTrackingEnabled()) { + FeatureUsageStore.markUsed('link-tracking'); + } } onError({ key, debuginfo }) { diff --git a/app/static/images/toolbar/tiny-warning-sign@2x.png b/app/static/images/toolbar/tiny-warning-sign@2x.png new file mode 100644 index 000000000..de6ee6acd Binary files /dev/null and b/app/static/images/toolbar/tiny-warning-sign@2x.png differ diff --git a/mailsync b/mailsync index eaf7c846d..4c6e9e557 160000 --- a/mailsync +++ b/mailsync @@ -1 +1 @@ -Subproject commit eaf7c846dd4caf81e399ed1073df961711eaba23 +Subproject commit 4c6e9e557f98ae7c950ac651f153b329ac2b7225