mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-10-07 19:57:56 +08:00
Fix: properly implement Basic limits on read receipts / tracking
This commit is contained in:
parent
24fac237cf
commit
e6fe78cf61
16 changed files with 112 additions and 108 deletions
|
@ -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,
|
||||
|
|
BIN
app/internal_packages/link-tracking/assets/ic-modal-image@2x.png
Normal file
BIN
app/internal_packages/link-tracking/assets/ic-modal-image@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
BIN
app/internal_packages/open-tracking/assets/ic-modal-image@2x.png
Normal file
BIN
app/internal_packages/open-tracking/assets/ic-modal-image@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
BIN
app/static/images/toolbar/tiny-warning-sign@2x.png
Normal file
BIN
app/static/images/toolbar/tiny-warning-sign@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
2
mailsync
2
mailsync
|
@ -1 +1 @@
|
|||
Subproject commit eaf7c846dd4caf81e399ed1073df961711eaba23
|
||||
Subproject commit 4c6e9e557f98ae7c950ac651f153b329ac2b7225
|
Loading…
Add table
Reference in a new issue