Replace participant sidebar data source, introduce free version query limit

This commit is contained in:
Ben Gotow 2017-10-05 10:54:52 -07:00
parent 170b05120d
commit ec9b771c30
12 changed files with 270 additions and 220 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,93 +0,0 @@
import { MailspringAPIRequest } from 'mailspring-exports';
const { makeRequest } = MailspringAPIRequest;
const MAX_RETRY = 10;
export default class ClearbitDataSource {
async find({ email, tryCount = 0 }) {
if (tryCount >= MAX_RETRY) {
return null;
}
let body = null;
try {
body = await makeRequest({
server: 'identity',
method: 'GET',
path: `/api/info-for-email/${email}`,
});
} catch (err) {
// we don't care about errors returned by this clearbit proxy
}
return await this.parseResponse(body, email, tryCount);
}
parseResponse(body = {}, requestedEmail, tryCount = 0) {
// This means it's in the process of fetching. Return null so we don't
// cache and try again.
return new Promise((resolve, reject) => {
if (body.error) {
if (body.error.type === 'queued') {
setTimeout(() => {
this.find({
email: requestedEmail,
tryCount: tryCount + 1,
})
.then(resolve)
.catch(reject);
}, 1000);
} else {
resolve(null);
}
return;
}
let person = body.person;
// This means there was no data about the person available. Return a
// valid, but empty object for us to cache. This can happen when we
// have company data, but no personal data.
if (!person) {
person = { email: requestedEmail };
}
resolve({
cacheDate: Date.now(),
email: requestedEmail, // Used as checksum
bio:
person.bio ||
(person.twitter && person.twitter.bio) ||
(person.aboutme && person.aboutme.bio),
location: person.location || (person.geo && person.geo.city) || null,
currentTitle: person.employment && person.employment.title,
currentEmployer: person.employment && person.employment.name,
profilePhotoUrl: person.avatar,
rawClearbitData: body,
socialProfiles: this._socialProfiles(person),
});
});
}
_socialProfiles(person = {}) {
const profiles = {};
if (((person.twitter && person.twitter.handle) || '').length > 0) {
profiles.twitter = {
handle: person.twitter.handle,
url: `https://twitter.com/${person.twitter.handle}`,
};
}
if (((person.facebook && person.facebook.handle) || '').length > 0) {
profiles.facebook = {
handle: person.facebook.handle,
url: `https://facebook.com/${person.facebook.handle}`,
};
}
if (((person.linkedin && person.linkedin.handle) || '').length > 0) {
profiles.linkedin = {
handle: person.linkedin.handle,
url: `https://linkedin.com/${person.linkedin.handle}`,
};
}
return profiles;
}
}

View file

@ -1,10 +1,8 @@
import { ComponentRegistry } from 'mailspring-exports';
import ParticipantProfileStore from './participant-profile-store';
import SidebarParticipantProfile from './sidebar-participant-profile';
import SidebarRelatedThreads from './sidebar-related-threads';
export function activate() {
ParticipantProfileStore.activate();
ComponentRegistry.register(SidebarParticipantProfile, { role: 'MessageListSidebar:ContactCard' });
ComponentRegistry.register(SidebarRelatedThreads, { role: 'MessageListSidebar:ContactCard' });
}
@ -12,7 +10,6 @@ export function activate() {
export function deactivate() {
ComponentRegistry.unregister(SidebarParticipantProfile);
ComponentRegistry.unregister(SidebarRelatedThreads);
ParticipantProfileStore.deactivate();
}
export function serialize() {}

View file

@ -0,0 +1,125 @@
import { MailspringAPIRequest, Utils } from 'mailspring-exports';
const { makeRequest } = MailspringAPIRequest;
const CACHE_SIZE = 200;
const CACHE_INDEX_KEY = 'pp-cache-keys';
const CACHE_KEY_PREFIX = 'pp-cache-';
class ParticipantProfileDataSource {
constructor() {
try {
this._cacheIndex = JSON.parse(window.localStorage.getItem(CACHE_INDEX_KEY) || `[]`);
} catch (err) {
this._cacheIndex = [];
}
}
async find(email) {
if (!email || Utils.likelyNonHumanEmail(email)) {
return {};
}
const data = this.getCache(email);
if (data) {
return data;
}
let body = null;
try {
body = await makeRequest({
server: 'identity',
method: 'GET',
path: `/api/info-for-email-v2/${email}`,
});
} catch (err) {
// we don't care about errors returned by this clearbit proxy
return {};
}
let person = (body || {}).person;
// This means there was no data about the person available. Return a
// valid, but empty object for us to cache. This can happen when we
// have company data, but no personal data.
if (!person) {
person = { email };
}
const result = {
cacheDate: Date.now(),
email: email, // Used as checksum
bio:
person.bio ||
(person.twitter && person.twitter.bio) ||
(person.aboutme && person.aboutme.bio),
location: person.location || (person.geo && person.geo.city) || null,
currentTitle: person.employment && person.employment.title,
currentEmployer: person.employment && person.employment.name,
profilePhotoUrl: person.avatar,
rawClearbitData: body,
socialProfiles: this._socialProfiles(person),
};
this.setCache(email, result);
return result;
}
_socialProfiles(person = {}) {
const profiles = {};
if (((person.twitter && person.twitter.handle) || '').length > 0) {
profiles.twitter = {
handle: person.twitter.handle,
url: `https://twitter.com/${person.twitter.handle}`,
};
}
if (((person.facebook && person.facebook.handle) || '').length > 0) {
profiles.facebook = {
handle: person.facebook.handle,
url: `https://facebook.com/${person.facebook.handle}`,
};
}
if (((person.linkedin && person.linkedin.handle) || '').length > 0) {
profiles.linkedin = {
handle: person.linkedin.handle,
url: `https://linkedin.com/${person.linkedin.handle}`,
};
}
return profiles;
}
// LocalStorage Retrieval / Saving
hasCache(email) {
return localStorage.getItem(`${CACHE_KEY_PREFIX}${email}`) !== null;
}
getCache(email) {
const raw = localStorage.getItem(`${CACHE_KEY_PREFIX}${email}`);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch (err) {
return null;
}
}
setCache(email, value) {
localStorage.setItem(`${CACHE_KEY_PREFIX}${email}`, JSON.stringify(value));
const updatedIndex = this._cacheIndex.filter(e => e !== email);
updatedIndex.push(email);
if (updatedIndex.length > CACHE_SIZE) {
const oldestKey = updatedIndex.shift();
localStorage.removeItem(`${CACHE_KEY_PREFIX}${oldestKey}`);
}
localStorage.setItem(CACHE_INDEX_KEY, JSON.stringify(updatedIndex));
this._cacheIndex = updatedIndex;
}
}
export default new ParticipantProfileDataSource();

View file

@ -1,82 +0,0 @@
import { Utils } from 'mailspring-exports';
import MailspringStore from 'mailspring-store';
import ClearbitDataSource from './clearbit-data-source';
const contactCache = {};
const CACHE_SIZE = 100;
const contactCacheKeyIndex = [];
// TODO: Put cache into localstorage
class ParticipantProfileStore extends MailspringStore {
constructor() {
super();
this.cacheExpiry = 1000 * 60 * 60 * 24; // 1 day
this.dataSource = new ClearbitDataSource();
}
activate() {}
deactivate() {
// no op
}
dataForContact(contact) {
if (!contact) {
return {};
}
if (Utils.likelyNonHumanEmail(contact.email)) {
return {};
}
if (this.inCache(contact)) {
const data = this.getCache(contact);
if (data.cacheDate) {
return data;
}
return {};
}
this.dataSource
.find({ email: contact.email })
.then(data => {
if (data && data.email === contact.email) {
this.setCache(contact, data);
this.trigger();
}
})
.catch((err = {}) => {
if (err.statusCode !== 404) {
throw err;
}
});
return {};
}
getCache(contact) {
return contactCache[contact.email];
}
inCache(contact) {
const cache = contactCache[contact.email];
if (!cache) {
return false;
}
if (!cache.cacheDate || Date.now() - cache.cacheDate > this.cacheExpiry) {
return false;
}
return true;
}
setCache(contact, value) {
contactCache[contact.email] = value;
contactCacheKeyIndex.push(contact.email);
if (contactCacheKeyIndex.length > CACHE_SIZE) {
delete contactCache[contactCacheKeyIndex.shift()];
}
return value;
}
}
export default new ParticipantProfileStore();

View file

@ -1,6 +1,29 @@
import { React, PropTypes, DOMUtils, RegExpUtils, Utils } from 'mailspring-exports';
import {
IdentityStore,
FeatureUsageStore,
React,
PropTypes,
DOMUtils,
RegExpUtils,
Utils,
} from 'mailspring-exports';
import { RetinaImg } from 'mailspring-component-kit';
import ParticipantProfileStore from './participant-profile-store';
import ParticipantProfileDataSource from './participant-profile-data-source';
/* We expect ParticipantProfileDataSource.find to return the
* following schema:
* {
* profilePhotoUrl: string
* bio: string
* location: string
* currentTitle: string
* currentEmployer: string
* socialProfiles: hash keyed by type: ('twitter', 'facebook' etc)
* url: string
* handle: string
* }
*/
export default class SidebarParticipantProfile extends React.Component {
static displayName = 'SidebarParticipantProfile';
@ -17,30 +40,50 @@ export default class SidebarParticipantProfile extends React.Component {
constructor(props) {
super(props);
/* We expect ParticipantProfileStore.dataForContact to return the
* following schema:
* {
* profilePhotoUrl: string
* bio: string
* location: string
* currentTitle: string
* currentEmployer: string
* socialProfiles: hash keyed by type: ('twitter', 'facebook' etc)
* url: string
* handle: string
* }
*/
this.state = ParticipantProfileStore.dataForContact(props.contact);
this.state = {
loaded: false,
loading: false,
trialing: !IdentityStore.hasProFeatures(),
};
const contactState = ParticipantProfileDataSource.getCache(props.contact.email);
if (contactState) {
this.state = Object.assign(this.state, { loaded: true }, contactState);
}
}
componentDidMount() {
this.usub = ParticipantProfileStore.listen(() => {
this.setState(ParticipantProfileStore.dataForContact(this.props.contact));
});
this._mounted = true;
if (!this.state.loaded && !this.state.trialing) {
this._findContact();
}
}
componentWillUnmount() {
this.usub();
this._mounted = false;
}
_onClickedToTry = async () => {
try {
await FeatureUsageStore.asyncUseFeature('contact-profiles', {
usedUpHeader: 'All Contact Previews Used',
usagePhrase: 'view contact profiles for',
iconUrl: 'mailspring://participant-profile/assets/ic-contact-profile-modal@2x.png',
});
} catch (err) {
// user does not have access to this feature
return;
}
this._findContact();
};
async _findContact() {
this.setState({ loading: true });
ParticipantProfileDataSource.find(this.props.contact.email).then(result => {
if (!this._mounted) {
return;
}
this.setState(Object.assign({ loading: false, loaded: true }, result));
});
}
_renderProfilePhoto() {
@ -199,12 +242,35 @@ export default class SidebarParticipantProfile extends React.Component {
}
}
_renderFindCTA() {
if (!this.state.trialing || this.state.loaded) {
return;
}
if (!this.props.contact.email || Utils.likelyNonHumanEmail(this.props.contact.email)) {
return;
}
return (
<div style={{ textAlign: 'center' }}>
<p>
The contact sidebar in Mailspring Pro shows information about the people and companies
you're emailing with.
</p>
<div className="btn" onClick={!this.state.loading ? this._onClickedToTry : null}>
{!this.state.loading ? `Try it Now` : `Loading...`}
</div>
</div>
);
}
render() {
return (
<div className="participant-profile">
{this._renderProfilePhoto()}
{this._renderCorePersonalInfo()}
{this._renderAdditionalInfo()}
{this._renderFindCTA()}
</div>
);
}

View file

@ -1,10 +1,10 @@
@import 'ui-variables';
.related-threads {
width: calc(~"100% + 30px");
width: calc(~'100% + 30px');
position: relative;
left: -15px;
border-top: 1px solid rgba(0,0,0,0.15);
border-top: 1px solid rgba(0, 0, 0, 0.15);
transition: height 150ms ease-in-out;
top: 15px;
margin-top: -15px;
@ -17,7 +17,7 @@
color: @text-color-very-subtle;
width: 100%;
padding: 0.5em 15px;
border-top: 1px solid rgba(0,0,0,0.08);
border-top: 1px solid rgba(0, 0, 0, 0.08);
&:hover {
background: @list-hover-bg;
@ -40,7 +40,7 @@
font-size: 12px;
text-align: center;
padding: 0.5em 15px;
border-top: 1px solid rgba(0,0,0,0.08);
border-top: 1px solid rgba(0, 0, 0, 0.08);
color: @text-color-link;
}
}
@ -53,9 +53,9 @@
height: 50px;
border-radius: @border-radius-base;
padding: 3px;
box-shadow: 0 0 1px rgba(0,0,0,0.5);
box-shadow: 0 0 1px rgba(0, 0, 0, 0.5);
position: absolute;
left: calc(~"50% - 25px");
left: calc(~'50% - 25px');
top: -31px;
background: @background-primary;
@ -66,7 +66,8 @@
width: 44px;
height: 44px;
img, .default-profile-image {
img,
.default-profile-image {
width: 44px;
height: 44px;
}
@ -76,8 +77,12 @@
font-size: 18px;
font-weight: 500;
color: white;
box-shadow: inset 0 0 1px rgba(0,0,0,0.18);
background-image: linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 100%);
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.18);
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0) 100%
);
}
}
}
@ -87,7 +92,8 @@
text-align: center;
margin-bottom: @spacing-standard;
.full-name, .email {
.full-name,
.email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -129,3 +135,22 @@ body.platform-win32 {
border-radius: 0;
}
}
.feature-usage-modal.contact-profiles {
@send-later-color: #777ff0;
.feature-header {
@from: @send-later-color;
@to: lighten(@send-later-color, 10%);
background: linear-gradient(to top, @from, @to);
}
.feature-name {
color: @send-later-color;
}
.pro-description {
li {
&:before {
color: @send-later-color;
}
}
}
}

View file

@ -525,6 +525,7 @@ Utils =
"notification[s]?#{at}"
"support#{at}"
"alert[s]?#{at}"
"notify",
"news#{at}"
"info#{at}"
"automated#{at}"

View file

@ -8,6 +8,8 @@ import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task';
class NoProAccessError extends Error {}
const UsageRecordedServerSide = ['contact-profiles'];
/**
* FeatureUsageStore is backed by the IdentityStore
*
@ -158,7 +160,9 @@ class FeatureUsageStore extends MailspringStore {
next.featureUsage[feature].usedInPeriod += 1;
IdentityStore.saveIdentity(next);
}
Actions.queueTask(new SendFeatureUsageEventTask({ feature }));
if (!UsageRecordedServerSide.includes(feature)) {
Actions.queueTask(new SendFeatureUsageEventTask({ feature }));
}
}
}

View file

@ -50,6 +50,10 @@ class IdentityStore extends MailspringStore {
return this._identity.id;
}
hasProFeatures() {
return this._identity && this._identity.stripePlanEffective !== 'Basic';
}
_fetchAndPollRemoteIdentity() {
if (!AppEnv.isMainWindow()) return;
setTimeout(() => {

View file

@ -1,4 +1,4 @@
import {ComponentRegistry} from 'mailspring-exports';
import { ComponentRegistry } from 'mailspring-exports';
import MyComposerButton from './my-composer-button';
import MyMessageSidebar from './my-message-sidebar';
@ -19,8 +19,7 @@ export function activate() {
// You can return a state object that will be passed back to your package
// when it is re-activated.
//
export function serialize() {
}
export function serialize() {}
// This **optional** method is called when the window is shutting down,
// or when your package is being updated or disabled. If your package is

View file

@ -1,20 +1,24 @@
import {ComponentRegistry} from 'mailspring-exports';
import {activate, deactivate} from '../lib/main';
import { ComponentRegistry } from 'mailspring-exports';
import { activate, deactivate } from '../lib/main';
import MyMessageSidebar from '../lib/my-message-sidebar';
import MyComposerButton from '../lib/my-composer-button';
describe("activate", () => {
it("should register the composer button and sidebar", () => {
describe('activate', () => {
it('should register the composer button and sidebar', () => {
spyOn(ComponentRegistry, 'register');
activate();
expect(ComponentRegistry.register).toHaveBeenCalledWith(MyComposerButton, {role: 'Composer:ActionButton'});
expect(ComponentRegistry.register).toHaveBeenCalledWith(MyMessageSidebar, {role: 'MessageListSidebar:ContactCard'});
expect(ComponentRegistry.register).toHaveBeenCalledWith(MyComposerButton, {
role: 'Composer:ActionButton',
});
expect(ComponentRegistry.register).toHaveBeenCalledWith(MyMessageSidebar, {
role: 'MessageListSidebar:ContactCard',
});
});
});
describe("deactivate", () => {
it("should unregister the composer button and sidebar", () => {
describe('deactivate', () => {
it('should unregister the composer button and sidebar', () => {
spyOn(ComponentRegistry, 'unregister');
deactivate();
expect(ComponentRegistry.unregister).toHaveBeenCalledWith(MyComposerButton);