Fix: properly implement Basic limits on read receipts / tracking

This commit is contained in:
Ben Gotow 2017-12-01 14:44:51 -08:00
parent 24fac237cf
commit e6fe78cf61
16 changed files with 112 additions and 108 deletions

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -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 (
<MetadataComposerToggleButton
title={this._title}
iconName="icon-composer-linktracking.png"
pluginId={PLUGIN_ID}
pluginName={PLUGIN_NAME}
metadataEnabledValue={{ tracked: true }}
stickyToggle
errorMessage={this._errorMessage}
draft={this.props.draft}
session={this.props.session}

View file

@ -467,22 +467,7 @@
flex-direction: column;
.footer {
background-image: linear-gradient(
to right,
rgba(167, 214, 134, 1) 0%,
rgba(122, 201, 201, 1) 100%
);
}
@-webkit-keyframes slideIn {
from {
transform: translate3d(20, 0, 0);
opacity: 0;
}
to {
transform: translate3d(0, 0, 0);
opacity: 1;
}
background-image: linear-gradient(to right, #6061c7 0%, #4782ad 100%)
}
a {
@ -498,11 +483,7 @@
.steps-container {
position: relative;
flex: 1;
background-image: linear-gradient(
to right,
rgba(149, 205, 107, 1) 0%,
rgba(60, 176, 176, 1) 100%
);
background-image: linear-gradient(to right, #696AE8 0%, #4c9ad2 100%);
color: white;
overflow: hidden;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -17,11 +17,6 @@ export default class OpenTrackingButton extends React.Component {
);
}
_title(enabled) {
const dir = enabled ? 'Disable' : 'Enable';
return `${dir} open tracking`;
}
_errorMessage(error) {
if (
error instanceof APIError &&
@ -40,12 +35,10 @@ export default class OpenTrackingButton extends React.Component {
return (
<MetadataComposerToggleButton
title={this._title}
iconUrl="mailspring://open-tracking/assets/icon-composer-eye@2x.png"
pluginId={PLUGIN_ID}
pluginName={PLUGIN_NAME}
metadataEnabledValue={enabledValue}
stickyToggle
errorMessage={this._errorMessage}
draft={this.props.draft}
session={this.props.session}

View file

@ -216,7 +216,7 @@ export default class SidebarParticipantProfile extends React.Component {
_onClickedToTry = async () => {
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',

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<button className={className} onClick={this._onClick} title={title} tabIndex={-1}>
<button
className={className}
onClick={this._onClick}
title={`${enabled ? 'Disable' : 'Enable'} ${this.props.pluginName}`}
tabIndex={-1}
>
{this.state.onByDefaultButUsedUp ? (
<div style={{ position: 'absolute', zIndex: 2, transform: 'translate(14px, -4px)' }}>
<RetinaImg name="tiny-warning-sign.png" mode={RetinaImg.Mode.ContentPreserve} />
</div>
) : null}
<RetinaImg {...attrs} mode={RetinaImg.Mode.ContentIsMask} />
</button>
);

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -1 +1 @@
Subproject commit eaf7c846dd4caf81e399ed1073df961711eaba23
Subproject commit 4c6e9e557f98ae7c950ac651f153b329ac2b7225