├── www
├── CNAME
├── js
│ ├── privacy.js
│ └── index.js
└── css
│ └── privacy.css
├── Procfile
├── browserslist
├── .npmignore
├── .env.default
├── assets
├── images
│ ├── logo.png
│ ├── cursor.png
│ ├── djkhaled.jpg
│ ├── loading.gif
│ ├── logo-128.png
│ ├── logo-16.png
│ ├── logo-19.png
│ ├── logo-38.png
│ ├── rrhoover.jpg
│ ├── browser-one.jpg
│ ├── browser-two.jpg
│ ├── carlitowhite.png
│ ├── facebookshare.png
│ ├── missing-image.png
│ ├── twittershare.png
│ ├── carlito-corner.png
│ ├── facebeefbanner.jpg
│ ├── logo-16-active.png
│ ├── logo-19-active.png
│ ├── logo-38-active.png
│ ├── 2B8CECF41AECD732.png
│ ├── blue-browser-hc1.gif
│ ├── hovercards-error.png
│ ├── inject-the-binary.jpg
│ ├── purp-browser-hc1.png
│ ├── purp-browser-hc22.gif
│ ├── purp-browser-hc3.png
│ ├── blue-browser-hc1-bg.png
│ ├── Imgur-icon-full_color.png
│ ├── Reddit-icon-full_color.png
│ ├── Twitter-icon-full_color.png
│ ├── YouTube-icon-full_color.png
│ ├── blue-browser-lightbox.gif
│ ├── imgur-icon-full_color.png
│ ├── purp-browser-hc-shadow.png
│ ├── reddit-icon-full_color.png
│ ├── twitter-icon-full_color.png
│ ├── youtube-icon-full_color.png
│ ├── blue-browser-lightbox-bg.png
│ ├── facebook-icon-full_color.png
│ ├── instagram-icon-full_color.png
│ ├── SoundCloud-icon-full_color.png
│ ├── soundcloud-icon-full_color.png
│ ├── fullscreen.svg
│ ├── twitter-retweet.svg
│ ├── close-button.svg
│ ├── twitter.svg
│ ├── twitter-black.svg
│ ├── instagram.svg
│ ├── youtube.svg
│ ├── facebook-black.svg
│ ├── circles.svg
│ ├── right-arrow.svg
│ ├── left-arrow.svg
│ ├── verified.svg
│ ├── imgur.svg
│ ├── soundcloud.svg
│ └── reddit.svg
└── fonts
│ ├── avant-garde.eot
│ ├── avant-garde.ttf
│ ├── NeuzeGroTReg.eot
│ ├── NeuzeGroTReg.woff
│ └── avant-garde.woff
├── redux
├── actions.options.js
├── actions.top-frame.js
├── actions.background.js
├── createStore.options.js
├── authentication.actions.top-frame.js
├── createStore.background.js
├── createStore.top-frame.js
├── options.actions.js
├── analytics.actions.top-frame.js
├── entities.reducer.js
├── entities.actions.top-frame.js
├── createStore.common.js
├── authentication.reducer.js
├── storelistener.js
├── authentication.actions.background.js
├── options.reducer.js
├── analytics.actions.background.js
└── entities.actions.background.js
├── components
├── ContentHovercard
│ ├── ContentHovercard.styles.css
│ └── ContentHovercard.js
├── Gif
│ ├── Gif.styles.css
│ └── Gif.js
├── Image
│ ├── Image.styles.css
│ └── Image.js
├── Video
│ ├── Video.styles.css
│ └── Video.js
├── YoutubeVideo
│ ├── YoutubeVideo.styles.css
│ └── YoutubeVideo.js
├── SoundCloudPlayer
│ ├── SoundCloudPlayer.styles.css
│ └── SoundCloudPlayer.js
├── OEmbed
│ ├── OEmbed.styles.css
│ └── OEmbed.js
├── ContentDescription
│ ├── ContentDescription.styles.css
│ └── ContentDescription.js
├── flex.styles.css
├── AccountHeader
│ ├── AccountHeader.styles.css
│ └── AccountHeader.js
├── Loading
│ ├── Loading.styles.css
│ └── Loading.js
├── Hovercard
│ └── Hovercard.styles.css
├── Media
│ ├── Media.styles.css
│ └── Media.js
├── Discussions
│ └── Discussions.styles.css
├── meta.styles.css
├── IntegrationOptions
│ ├── IntegrationOptions.styles.css
│ └── IntegrationOptions.js
├── Carousel
│ ├── Carousel.styles.css
│ └── Carousel.js
├── ContentHeader
│ ├── ContentHeader.styles.css
│ └── ContentHeader.js
├── Options
│ ├── Options.styles.css
│ └── Options.js
├── AccountFooter
│ ├── AccountFooter.styles.css
│ └── AccountFooter.js
├── Collapsable
│ ├── Collapsable.styles.css
│ └── Collapsable.js
├── AccountHovercard
│ ├── AccountHovercard.styles.css
│ └── AccountHovercard.js
├── DiscussionComment
│ ├── DiscussionComment.styles.css
│ └── DiscussionComment.js
├── ContentFooter
│ └── ContentFooter.js
├── TimeSince
│ └── TimeSince.js
├── Err
│ └── Err.js
└── no-formatting.styles.css
├── .babelrc
├── .gitignore
├── AUTHORS
├── server
├── redis.js
├── index.js
└── v2.js
├── Procfile.dev
├── report
├── index.js
├── index.www.js
└── index.browser.js
├── extension
├── index.background.js
├── index.options.js
├── index.top-frame.js
├── config.js
├── set-uninstall-url.js
├── content-security-policy.js
├── manifest.json
├── browser-action.js
├── browser.js
└── copy.json
├── .travis.yml
├── integrations
├── mixins.js
├── twitter
│ ├── config.json
│ └── urls.js
├── reddit
│ ├── config.json
│ └── urls.js
├── config.js
├── imgur
│ ├── config.json
│ └── urls.js
├── soundcloud
│ ├── config.json
│ └── urls.js
├── youtube
│ ├── config.json
│ └── urls.js
├── instagram
│ ├── config.js
│ ├── urls.js
│ └── urls.test.js
├── urls
│ ├── index.js
│ └── index.test.js
├── index.js
└── index.background.js
├── utils
├── entity-label.js
├── format.js
└── dom.js
├── .editorconfig
├── .github
├── ISSUE_TEMPLATE.md
└── CONTRIBUTING.md
├── bower.json
├── .stylelintrc
├── .eslintrc
├── app.json
├── webpack.config.www.js
├── webpack.config.extension.js
└── README.md
/www/CNAME:
--------------------------------------------------------------------------------
1 | hovercards.com
2 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: node server
2 |
--------------------------------------------------------------------------------
/browserslist:
--------------------------------------------------------------------------------
1 | Chrome >= 43
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *
2 | !dist-*
3 | !dist/*
4 |
--------------------------------------------------------------------------------
/www/js/privacy.js:
--------------------------------------------------------------------------------
1 | require('../../report');
2 | require('../css/privacy.css');
3 |
--------------------------------------------------------------------------------
/.env.default:
--------------------------------------------------------------------------------
1 | INSTAGRAM_CLIENT_ID=
2 | REDDIT_CLIENT_ID=
3 | SOUNDCLOUD_CLIENT_ID=
4 |
--------------------------------------------------------------------------------
/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo.png
--------------------------------------------------------------------------------
/assets/images/cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/cursor.png
--------------------------------------------------------------------------------
/assets/images/djkhaled.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/djkhaled.jpg
--------------------------------------------------------------------------------
/assets/images/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/loading.gif
--------------------------------------------------------------------------------
/assets/images/logo-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-128.png
--------------------------------------------------------------------------------
/assets/images/logo-16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-16.png
--------------------------------------------------------------------------------
/assets/images/logo-19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-19.png
--------------------------------------------------------------------------------
/assets/images/logo-38.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-38.png
--------------------------------------------------------------------------------
/assets/images/rrhoover.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/rrhoover.jpg
--------------------------------------------------------------------------------
/assets/fonts/avant-garde.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/fonts/avant-garde.eot
--------------------------------------------------------------------------------
/assets/fonts/avant-garde.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/fonts/avant-garde.ttf
--------------------------------------------------------------------------------
/assets/fonts/NeuzeGroTReg.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/fonts/NeuzeGroTReg.eot
--------------------------------------------------------------------------------
/assets/fonts/NeuzeGroTReg.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/fonts/NeuzeGroTReg.woff
--------------------------------------------------------------------------------
/assets/fonts/avant-garde.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/fonts/avant-garde.woff
--------------------------------------------------------------------------------
/assets/images/browser-one.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/browser-one.jpg
--------------------------------------------------------------------------------
/assets/images/browser-two.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/browser-two.jpg
--------------------------------------------------------------------------------
/assets/images/carlitowhite.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/carlitowhite.png
--------------------------------------------------------------------------------
/assets/images/facebookshare.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/facebookshare.png
--------------------------------------------------------------------------------
/assets/images/missing-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/missing-image.png
--------------------------------------------------------------------------------
/assets/images/twittershare.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/twittershare.png
--------------------------------------------------------------------------------
/redux/actions.options.js:
--------------------------------------------------------------------------------
1 | module.exports = Object.assign(
2 | {},
3 | require('./options.actions')
4 | );
5 |
--------------------------------------------------------------------------------
/assets/images/carlito-corner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/carlito-corner.png
--------------------------------------------------------------------------------
/assets/images/facebeefbanner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/facebeefbanner.jpg
--------------------------------------------------------------------------------
/assets/images/logo-16-active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-16-active.png
--------------------------------------------------------------------------------
/assets/images/logo-19-active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-19-active.png
--------------------------------------------------------------------------------
/assets/images/logo-38-active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/logo-38-active.png
--------------------------------------------------------------------------------
/assets/images/2B8CECF41AECD732.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/2B8CECF41AECD732.png
--------------------------------------------------------------------------------
/assets/images/blue-browser-hc1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/blue-browser-hc1.gif
--------------------------------------------------------------------------------
/assets/images/hovercards-error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/hovercards-error.png
--------------------------------------------------------------------------------
/assets/images/inject-the-binary.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/inject-the-binary.jpg
--------------------------------------------------------------------------------
/assets/images/purp-browser-hc1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/purp-browser-hc1.png
--------------------------------------------------------------------------------
/assets/images/purp-browser-hc22.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/purp-browser-hc22.gif
--------------------------------------------------------------------------------
/assets/images/purp-browser-hc3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/purp-browser-hc3.png
--------------------------------------------------------------------------------
/assets/images/blue-browser-hc1-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/blue-browser-hc1-bg.png
--------------------------------------------------------------------------------
/components/ContentHovercard/ContentHovercard.styles.css:
--------------------------------------------------------------------------------
1 | .content {
2 | min-height: 140px;
3 | min-width: 620px;
4 | }
5 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react"],
3 | "plugins": ["transform-es2015-computed-properties", "transform-object-assign"]
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | coverage/*
3 | dist-*
4 | dist/*
5 | dump.rdb
6 | bower_components/*
7 | node_modules/*
8 | npm-debug.log*
9 |
--------------------------------------------------------------------------------
/assets/images/Imgur-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/Imgur-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/Reddit-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/Reddit-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/Twitter-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/Twitter-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/YouTube-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/YouTube-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/blue-browser-lightbox.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/blue-browser-lightbox.gif
--------------------------------------------------------------------------------
/assets/images/imgur-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/imgur-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/purp-browser-hc-shadow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/purp-browser-hc-shadow.png
--------------------------------------------------------------------------------
/assets/images/reddit-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/reddit-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/twitter-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/twitter-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/youtube-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/youtube-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/blue-browser-lightbox-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/blue-browser-lightbox-bg.png
--------------------------------------------------------------------------------
/assets/images/facebook-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/facebook-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/instagram-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/instagram-icon-full_color.png
--------------------------------------------------------------------------------
/components/Gif/Gif.styles.css:
--------------------------------------------------------------------------------
1 | .gif {
2 | display: block;
3 | margin: auto;
4 | max-width: 100%;
5 | user-select: none;
6 | }
7 |
--------------------------------------------------------------------------------
/assets/images/SoundCloud-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/SoundCloud-icon-full_color.png
--------------------------------------------------------------------------------
/assets/images/soundcloud-icon-full_color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kogg/hovercards/HEAD/assets/images/soundcloud-icon-full_color.png
--------------------------------------------------------------------------------
/components/Image/Image.styles.css:
--------------------------------------------------------------------------------
1 | .image {
2 | display: block;
3 | margin: auto;
4 | max-width: 100%;
5 | user-select: none;
6 | }
7 |
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Cameron Rohani (https://twitter.com/cameronrohani)
2 | Marco Marandiz (http://marcomarandiz.com/)
3 | Saiichi Hashimoto (http://saiichihashimoto.com/)
4 |
--------------------------------------------------------------------------------
/components/Video/Video.styles.css:
--------------------------------------------------------------------------------
1 | .video {
2 | cursor: pointer;
3 | display: block;
4 | margin: auto;
5 | max-width: 100%;
6 | user-select: none;
7 | }
8 |
--------------------------------------------------------------------------------
/components/YoutubeVideo/YoutubeVideo.styles.css:
--------------------------------------------------------------------------------
1 | .video {
2 | background-position: center;
3 | background-size: cover;
4 | height: 321px;
5 | width: 100%;
6 | }
7 |
--------------------------------------------------------------------------------
/components/SoundCloudPlayer/SoundCloudPlayer.styles.css:
--------------------------------------------------------------------------------
1 | .player {
2 | background-position: center;
3 | background-size: cover;
4 | height: 293px;
5 | width: 100%;
6 | }
7 |
--------------------------------------------------------------------------------
/redux/actions.top-frame.js:
--------------------------------------------------------------------------------
1 | module.exports = Object.assign(
2 | {},
3 | require('./analytics.actions.top-frame'),
4 | require('./authentication.actions.top-frame'),
5 | require('./entities.actions.top-frame')
6 | );
7 |
--------------------------------------------------------------------------------
/redux/actions.background.js:
--------------------------------------------------------------------------------
1 | module.exports = Object.assign(
2 | {},
3 | require('./analytics.actions.background'),
4 | require('./authentication.actions.background'),
5 | require('./entities.actions.background')
6 | );
7 |
--------------------------------------------------------------------------------
/server/redis.js:
--------------------------------------------------------------------------------
1 | var redis = require('redis');
2 |
3 | var report = require('../report');
4 |
5 | module.exports = redis.createClient({ url: process.env.REDISCLOUD_URL });
6 |
7 | module.exports.on('error', report.captureException);
8 |
--------------------------------------------------------------------------------
/Procfile.dev:
--------------------------------------------------------------------------------
1 | www: webpack-dev-server --inline --hot --config webpack.config.www.js
2 | web: nodemon --ignore dist/ -e js,jsx server
3 | extension: webpack-dev-server --config webpack.config.extension.js
4 | redis: redis-server
5 |
--------------------------------------------------------------------------------
/report/index.js:
--------------------------------------------------------------------------------
1 | var raven = require('raven');
2 |
3 | module.exports = new raven.Client(process.env.SENTRY_DSN, {
4 | environment: process.env.NODE_ENV,
5 | release: process.env.npm_package_version
6 | });
7 |
8 | module.exports.patchGlobal();
9 |
--------------------------------------------------------------------------------
/report/index.www.js:
--------------------------------------------------------------------------------
1 | var Raven = require('raven-js');
2 |
3 | Raven
4 | .config(process.env.SENTRY_DSN_CLIENT, {
5 | environment: process.env.NODE_ENV,
6 | release: process.env.npm_package_version
7 | })
8 | .install();
9 |
10 | module.exports = Raven;
11 |
--------------------------------------------------------------------------------
/extension/index.background.js:
--------------------------------------------------------------------------------
1 | require('../report');
2 | require('string.prototype.endswith');
3 | var createStore = require('../redux/createStore.background');
4 |
5 | require('./browser-action');
6 | require('./content-security-policy');
7 | require('./set-uninstall-url');
8 |
9 | createStore();
10 |
--------------------------------------------------------------------------------
/components/OEmbed/OEmbed.styles.css:
--------------------------------------------------------------------------------
1 | .oembed-container {
2 | background-position: center;
3 | background-size: cover;
4 | max-width: 100%;
5 | width: 100%;
6 | }
7 |
8 | .oembed {
9 | display: block;
10 | margin: auto;
11 | max-width: 100%;
12 | user-select: none;
13 | width: 100%;
14 | }
15 |
--------------------------------------------------------------------------------
/redux/createStore.options.js:
--------------------------------------------------------------------------------
1 | var actions = require('./actions.options');
2 | var createStore = require('./createStore.common');
3 |
4 | module.exports = function(initialState) {
5 | return createStore(
6 | actions,
7 | {
8 | options: require('./options.reducer')
9 | },
10 | initialState
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "6.1"
4 | before_install:
5 | - npm prune
6 | script:
7 | - npm test
8 | - npm run coverage:check
9 | after_success:
10 | - npm run coverage:report
11 | - npm run release
12 | cache:
13 | directories:
14 | - node_modules
15 | notifications:
16 | email: false
17 |
--------------------------------------------------------------------------------
/integrations/mixins.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | _.mixin({
4 | somePredicate: function() {
5 | var predicates = arguments;
6 | return function() {
7 | var args = arguments;
8 | return _.some(predicates, function(predicate) {
9 | return predicate.apply(_, args);
10 | });
11 | };
12 | }
13 | });
14 |
--------------------------------------------------------------------------------
/components/ContentDescription/ContentDescription.styles.css:
--------------------------------------------------------------------------------
1 | .name {
2 | font-size: 14px;
3 | color: rgba(0, 0, 0, .8);
4 | font-weight: 600;
5 | display: block;
6 | margin-bottom: 5px;
7 | }
8 |
9 | .description {
10 | margin-top: 0;
11 | max-height: 90px;
12 | padding: 12px 24px 0 24px;
13 | width: 100%;
14 | }
15 |
--------------------------------------------------------------------------------
/redux/authentication.actions.top-frame.js:
--------------------------------------------------------------------------------
1 | var createAction = require('redux-actions').createAction;
2 |
3 | var browser = require('../extension/browser');
4 |
5 | module.exports.authenticate = function(request) {
6 | return function() {
7 | return browser.runtime.sendMessage(createAction('authenticate')(request));
8 | };
9 | };
10 |
--------------------------------------------------------------------------------
/utils/entity-label.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | module.exports = function(entity, general) {
4 | return _.chain([entity.api, entity.type])
5 | .compact()
6 | .union(entity.as && ['as', entity.as])
7 | .union(general ? [] : ((entity.for && ['for', module.exports(entity.for)]) || [entity.id]))
8 | .join(' ')
9 | .value();
10 | };
11 |
--------------------------------------------------------------------------------
/integrations/twitter/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "authenticatable": true,
3 | "account": {
4 | "stats": ["content", "followers", "following"]
5 | },
6 | "content": {
7 | "stats": ["resposts", "likes"]
8 | },
9 | "discussion": {
10 | "comments": {
11 | "stats": ["likes", "reposts"]
12 | },
13 | "integrations": ["reddit"]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/components/flex.styles.css:
--------------------------------------------------------------------------------
1 | .flex-row,
2 | .flex-center,
3 | .flex-spread {
4 | display: flex;
5 | align-items: center;
6 | }
7 |
8 | .flex-row {
9 | justify-content: center;
10 | }
11 |
12 | .flex-center {
13 | justify-content: left;
14 | }
15 |
16 | .flex-spread {
17 | justify-content: space-between;
18 | width: 100%;
19 | }
20 |
21 | .flex-grow {
22 | flex-grow: 1;
23 | }
24 |
--------------------------------------------------------------------------------
/integrations/reddit/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "environment": "client",
3 | "account": {
4 | "noImage": true,
5 | "stats": ["link_karma", "comment_karma", "date"]
6 | },
7 | "content": {
8 | "stats": ["score", "score_ratio", "comments"]
9 | },
10 | "discussion": {
11 | "comments": {
12 | "stats": ["score"]
13 | },
14 | "integrations": ["reddit"]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/redux/createStore.background.js:
--------------------------------------------------------------------------------
1 | var actions = require('./actions.background');
2 | var createStore = require('./createStore.common');
3 |
4 | module.exports = function(initialState) {
5 | return createStore(
6 | actions,
7 | {
8 | authentication: require('./authentication.reducer'),
9 | entities: require('./entities.reducer')
10 | },
11 | initialState
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/integrations/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | counts: {
3 | grid: 21,
4 | listed: 30
5 | },
6 | integrations: {
7 | imgur: require('./imgur/config'),
8 | instagram: require('./instagram/config'),
9 | reddit: require('./reddit/config'),
10 | soundcloud: require('./soundcloud/config'),
11 | twitter: require('./twitter/config'),
12 | youtube: require('./youtube/config')
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/redux/createStore.top-frame.js:
--------------------------------------------------------------------------------
1 | var actions = require('./actions.top-frame');
2 | var createStore = require('./createStore.common');
3 |
4 | module.exports = function(initialState) {
5 | return createStore(
6 | actions,
7 | {
8 | authentication: require('./authentication.reducer'),
9 | entities: require('./entities.reducer'),
10 | options: require('./options.reducer')
11 | },
12 | initialState
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/integrations/imgur/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "content_security_policy": {
3 | "img-src": ["i.imgur.com"],
4 | "media-src": ["i.imgur.com"]
5 | },
6 | "account": {
7 | "noImage": true,
8 | "stats": ["score", "date"]
9 | },
10 | "content": {
11 | "stats": ["views", "score"]
12 | },
13 | "discussion": {
14 | "comments": {
15 | "stats": ["score"]
16 | },
17 | "integrations": ["reddit", "imgur"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/integrations/soundcloud/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "environment": "client",
3 | "content_security_policy": {
4 | "img-src": ["i1.sndcdn.com"],
5 | "frame-src": ["w.soundcloud.com"]
6 | },
7 | "account": {
8 | "stats": ["content", "followers", "following"]
9 | },
10 | "content": {
11 | "stats": ["content", "likes", "views", "comments"]
12 | },
13 | "discussion": {
14 | "integrations": ["reddit", "soundcloud"]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | trim_trailing_whitespace = true
6 |
7 | [*.json]
8 | charset = utf-8
9 | end_of_line = lf
10 | indent_size = 2
11 | indent_style = space
12 | insert_final_newline = true
13 |
14 | [*.*{rc,config}]
15 | charset = utf-8
16 | end_of_line = lf
17 | indent_size = 2
18 | indent_style = space
19 | insert_final_newline = true
20 |
21 | [*.{css,md,yml}]
22 | indent_size = 2
23 | indent_style = space
24 |
--------------------------------------------------------------------------------
/redux/options.actions.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var createAction = require('redux-actions').createAction;
3 |
4 | var config = require('../extension/config');
5 |
6 | var keys = config.options.keys();
7 |
8 | module.exports.setOption = function(request) {
9 | return function(dispatch) {
10 | return (_.indexOf(keys, request.option, true) !== -1) && dispatch(createAction('SET_OPTION')(request)) && Promise.resolve();
11 | };
12 | };
13 |
--------------------------------------------------------------------------------
/components/AccountHeader/AccountHeader.styles.css:
--------------------------------------------------------------------------------
1 | .banner {
2 | height: 126.66666667px;
3 | background-position: center;
4 | position: relative;
5 | background-size: cover;
6 | width: 100%;
7 |
8 | @raw {
9 | composes: flex-row from '../flex.styles';
10 | }
11 |
12 | &:empty {
13 | display: block;
14 | }
15 |
16 | &-image {
17 | width: 33.33%;
18 | height: 126.66666667px;
19 | background-position: center;
20 | background-size: cover;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/components/Loading/Loading.styles.css:
--------------------------------------------------------------------------------
1 | /* TODO https://github.com/kogg/hovercards/issues/33 */
2 | .loading-container {
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | }
7 |
8 | .loading {
9 | background-image: url('../../assets/images/loading.gif');
10 | background-position: center;
11 | background-repeat: no-repeat;
12 | background-size: 100%;
13 | display: block;
14 | height: 32px;
15 | margin: 16px auto;
16 | opacity: .7;
17 | width: 32px;
18 | }
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | All the text should be purple. **(The ask)**
2 | Everyone in the community demands it. **(The reasoning)**
3 |
4 | - [ ] All text in the extension is purple
5 | - [ ] All text in the website is purple
6 | - [ ] **(Tasks for completion)**
7 |
8 | This will require a change in all the css **(Implementation Details)**
9 |
10 | https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
11 | http://www.w3schools.com/cssref/css_colors.asp
12 | **(Links to necessary documentation)**
13 |
--------------------------------------------------------------------------------
/integrations/youtube/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "cache_length": 360000,
3 | "content_security_policy": {
4 | "img-src": ["yt3.ggpht.com", "*.googleusercontent.com"],
5 | "script-src": ["s.ytimg.com"]
6 | },
7 | "account": {
8 | "stats": ["content", "followers", "views"]
9 | },
10 | "content": {
11 | "stats": ["views", "likes", "dislikes"]
12 | },
13 | "discussion": {
14 | "comments": {
15 | "stats": ["likes", "replies"]
16 | },
17 | "integrations": ["reddit", "youtube"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/components/Loading/Loading.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var styles = require('./Loading.styles');
5 |
6 | // TODO https://github.com/kogg/hovercards/issues/33
7 | module.exports = React.createClass({
8 | displayName: 'Loading',
9 | propTypes: {
10 | className: React.PropTypes.string
11 | },
12 | render: function() {
13 | return
;
14 | }
15 | });
16 |
--------------------------------------------------------------------------------
/extension/index.options.js:
--------------------------------------------------------------------------------
1 | require('../report');
2 | require('string.prototype.endswith');
3 | var Provider = require('react-redux').Provider;
4 | var React = require('react');
5 | var ReactDOM = require('react-dom');
6 |
7 | var Options = require('../components/Options/Options');
8 | var createStore = require('../redux/createStore.options');
9 |
10 | global.document.getElementById('mount').className = 'hovercards-root';
11 |
12 | ReactDOM.render(
13 |
14 |
15 | ,
16 | global.document.getElementById('mount')
17 | );
18 |
--------------------------------------------------------------------------------
/assets/images/fullscreen.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
--------------------------------------------------------------------------------
/extension/index.top-frame.js:
--------------------------------------------------------------------------------
1 | require('../report');
2 | require('string.prototype.endswith');
3 | var Provider = require('react-redux').Provider;
4 | var React = require('react');
5 | var ReactDOM = require('react-dom');
6 |
7 | var Hovercards = require('../components/Hovercards/Hovercards');
8 | var createStore = require('../redux/createStore.top-frame');
9 |
10 | var element = document.documentElement.insertBefore(document.createElement('div'), null);
11 | element.className = 'hovercards-root';
12 |
13 | ReactDOM.render(
14 |
15 |
16 | ,
17 | element
18 | );
19 |
--------------------------------------------------------------------------------
/components/Image/Image.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var styles = require('./Image.styles');
5 |
6 | module.exports = React.createClass({
7 | displayName: 'Image',
8 | propTypes: {
9 | className: React.PropTypes.string,
10 | image: React.PropTypes.object.isRequired,
11 | onLoad: React.PropTypes.func.isRequired
12 | },
13 | render: function() {
14 | return (
15 |
17 | );
18 | }
19 | });
20 |
--------------------------------------------------------------------------------
/redux/analytics.actions.top-frame.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var browser = require('../extension/browser');
4 |
5 | module.exports.analytics = function(request) {
6 | return function() {
7 | var promise = browser.runtime.sendMessage({ type: 'analytics', payload: request });
8 |
9 | if (!process.env.GOOGLE_ANALYTICS_ID) {
10 | promise = promise
11 | .then(function() {
12 | if (_.chain(request).first(2).isEqual(['send', 'exception']).value()) {
13 | console.error('google analytics', request);
14 | } else {
15 | console.debug('google analytics', request);
16 | }
17 | });
18 | }
19 | return promise;
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/integrations/instagram/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | authenticatable: true,
3 | authentication_url: 'https://instagram.com/oauth/authorize/?scope=basic+public_content&client_id=' + process.env.INSTAGRAM_CLIENT_ID + '&redirect_uri=https://EXTENSION_ID.chromiumapp.org/callback&response_type=token',
4 | environment: 'client',
5 | content_security_policy: {
6 | 'img-src': ['scontent.cdninstagram.com'],
7 | 'media-src': ['scontent.cdninstagram.com']
8 | },
9 | account: {
10 | stats: ['content', 'followers', 'following']
11 | },
12 | content: {
13 | stats: ['likes', 'comments']
14 | },
15 | discussion: {
16 | integrations: ['instagram', 'reddit']
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/components/Hovercard/Hovercard.styles.css:
--------------------------------------------------------------------------------
1 | .hovercard {
2 | align-items: flex-start;
3 | background: white;
4 | border-radius: 3px;
5 | box-shadow: rgba(0, 0, 0, 0.15) 0 2px 5px 0, rgba(0, 0, 0, 0.12) 0 2px 10px 0;
6 | box-sizing: border-box;
7 | justify-content: center;
8 | margin: 0;
9 | max-height: 600px;
10 | max-width: 620px;
11 | overflow: auto;
12 | position: absolute;
13 | z-index: 2147483647;
14 |
15 | &:empty {
16 | display: block;
17 | }
18 | }
19 |
20 | @raw {
21 | .lock-document::-webkit-scrollbar {
22 | display: none;
23 | }
24 |
25 | .lock-body {
26 | overflow: hidden;
27 |
28 | &::-webkit-scrollbar {
29 | display: none;
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/www/css/privacy.css:
--------------------------------------------------------------------------------
1 | @import './index.css';
2 |
3 | .sub-header {
4 | text-align: center;
5 | color: white;
6 | padding: 50px 20px;
7 | background: #b4e6f7;
8 | }
9 |
10 | .sub-header h1 {
11 | font-size: 60px;
12 | }
13 |
14 | .center-container {
15 | max-width: 780px;
16 | width: 100%;
17 | margin: auto;
18 | padding: 50px 20px;
19 | }
20 |
21 | .login-text a {
22 | text-decoration: none;
23 | color: white;
24 | }
25 |
26 | p {
27 | margin-bottom: 20px;
28 | }
29 |
30 | a {
31 | color: blue;
32 | }
33 |
34 | @media (max-width: 760px) {
35 | .sub-header h1 {
36 | font-size: 30px;
37 | }
38 | }
39 |
40 | @media (max-width: 460px) {
41 | .sub-header h1 {
42 | font-size: 25px;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/assets/images/twitter-retweet.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | ## Issues
4 | We encourage you to create issues. We've put an [issue template](ISSUE_TEMPLATE.md) in place to get you started. We use [robinpowered's github labels](https://robinpowered.com/blog/best-practice-system-for-organizing-and-tagging-github-issues/) for our issues, loosely.
5 |
6 | ## [Development](../README.md#development)
7 |
8 | ## Join us!
9 | We just opened up HoverCards to the world, so we're looking for this to be a community driven project. Our documentation is very spotty and looking for love. Feel free to create issues, chat with us in [our gitter](https://gitter.im/kogg/hovercards), and throw us some pull requests of your own!
10 |
11 | 
12 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hovercards",
3 | "description": "Get more content. Open fewer tabs.",
4 | "main": [
5 | "dist/background.js",
6 | "dist/options.js",
7 | "dist/top-frame.js"
8 | ],
9 | "license": "MIT",
10 | "ignore": [
11 | ".env",
12 | "coverage/*",
13 | "dist-*",
14 | "dist/*",
15 | "dump.rdb",
16 | "bower_components/*",
17 | "node_modules/*",
18 | "npm-debug.log*"
19 | ],
20 | "authors": [
21 | "Cameron Rohani (https://twitter.com/cameronrohani)",
22 | "Marco Marandiz (http://marcomarandiz.com/)",
23 | "Saiichi Hashimoto (http://saiichihashimoto.com/)"
24 | ],
25 | "homepage": "http://hovercards.com",
26 | "repository": {
27 | "type": "git",
28 | "url": "git://github.com/kogg/hovercards.git"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/assets/images/close-button.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
--------------------------------------------------------------------------------
/components/Media/Media.styles.css:
--------------------------------------------------------------------------------
1 | .media {
2 | height: inherit;
3 | max-width: 100%;
4 | min-height: inherit;
5 | min-width: inherit;
6 | padding: 18px 24px 0 24px;
7 | position: relative;
8 | text-align: left;
9 | width: 9999px;
10 |
11 | @raw {
12 | composes: flex-row from '../flex.styles';
13 | }
14 |
15 | &:empty {
16 | display: none;
17 | }
18 | }
19 |
20 | .media + .meta {
21 | display: none;
22 | }
23 |
24 | .description {
25 | padding: 12px 0;
26 | text-align: left;
27 | width: 100%;
28 | }
29 |
30 | .text,
31 | .name {
32 | color: rgba(0, 0, 0, .8);
33 | font-size: 14px;
34 | }
35 |
36 | .text {
37 | color: rgba(0, 0, 0, .6);
38 | }
39 |
40 | .name {
41 | font-weight: bold;
42 | margin-right: 6px;
43 | }
44 |
45 | .name:empty {
46 | display: none;
47 | }
48 |
--------------------------------------------------------------------------------
/utils/format.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var browser = require('../extension/browser');
4 |
5 | var units = ['', 'x_thousand', 'x_million', 'x_billion', 'x_trillion'];
6 |
7 | module.exports.number = function(number) {
8 | if (!_.isNumber(number) || _.isNaN(number)) {
9 | return number;
10 | }
11 | if (number < 10000) {
12 | return number.toLocaleString();
13 | }
14 | var log1000 = Math.floor(Math.log10(number) / 3);
15 | var multiple1000 = number / Math.pow(1000, log1000);
16 | var roundto = Math.pow(10, 2 - Math.floor(Math.log10(multiple1000)));
17 | multiple1000 = Math.round(multiple1000 * roundto) / roundto;
18 |
19 | if (!log1000) {
20 | return multiple1000.toLocaleString();
21 | }
22 | return browser.i18n.getMessage(units[log1000], [multiple1000.toLocaleString()]);
23 | };
24 |
--------------------------------------------------------------------------------
/components/Discussions/Discussions.styles.css:
--------------------------------------------------------------------------------
1 | .discussions-container {
2 | height: inherit;
3 | max-width: 100%;
4 | overflow: hidden;
5 | text-align: left;
6 | width: 9999px;
7 | z-index: 999;
8 | }
9 |
10 | .discussions {
11 | overflow: hidden;
12 | height: calc(100% - 49px);
13 | }
14 |
15 | .tabs-container {
16 | background: #f0f0f0;
17 | padding: 12px 24px;
18 |
19 | @raw {
20 | composes: flex-spread from '../flex.styles';
21 | }
22 | }
23 |
24 | .tabs {
25 | display: static;
26 | }
27 |
28 | .tab {
29 | color: rgba(0, 0, 0, .66);
30 | cursor: pointer;
31 | display: inline-block;
32 | font-weight: 400;
33 | margin: 0 4px;
34 | font-size: 15px;
35 |
36 | &:first-of-type {
37 | margin-left: 0;
38 | }
39 |
40 | &.selected {
41 | color: rgba(0, 0, 0, .8);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "rules": {
4 | "at-rule-name-space-after": "always",
5 | "block-opening-brace-space-before": ["always", { "ignoreAtRules": "raw" }],
6 | "color-hex-length": null,
7 | "comment-word-blacklist": [['TODO', 'FIXME', 'HACK'], { "severity": "warning" }],
8 | "declaration-block-no-duplicate-properties": [true, { "ignoreProperties": ["composes"] }],
9 | "declaration-block-no-ignored-properties": [true, { "severity": "warning" }],
10 | "number-leading-zero": null,
11 | "property-no-unknown": [true, { "severity": "warning", "ignoreProperties": ["composes", "font-smooth", "text-size-adjust"] }],
12 | "selector-pseudo-element-colon-notation": "single",
13 | "shorthand-property-no-redundant-values": [true, { "severity": "warning" }],
14 | "string-quotes": "single"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/redux/entities.reducer.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var handleActions = require('redux-actions').handleActions;
3 |
4 | var entityLabel = require('../utils/entity-label');
5 |
6 | module.exports = handleActions(
7 | {
8 | CLEAR_ENTITIES: {
9 | next: function(state, action) {
10 | return _.omit(state, function(value, key) {
11 | return key.startsWith(action.payload);
12 | });
13 | }
14 | },
15 | SET_ENTITY: {
16 | next: function(state, action) {
17 | return Object.assign({}, state, { [(action.meta || {}).label || entityLabel(action.payload)]: action.payload });
18 | },
19 | throw: function(state, action) {
20 | return Object.assign({}, state, {
21 | [entityLabel(action.payload.request)]: Object.assign({}, state[entityLabel(action.payload.request)], { err: action.payload })
22 | });
23 | }
24 | }
25 | },
26 | {}
27 | );
28 |
--------------------------------------------------------------------------------
/components/meta.styles.css:
--------------------------------------------------------------------------------
1 | .meta {
2 | margin-top: 12px;
3 | padding: 12px 24px;
4 | border-top: 1px solid rgba(0, 0, 0, .1);
5 |
6 | @raw {
7 | composes: flex-row from './flex.styles';
8 | composes: flex-spread from './flex.styles';
9 | }
10 | }
11 |
12 | .mediameta .meta {
13 | border-top: none;
14 | margin-top: 4px;
15 | }
16 |
17 | .meta-main-container {
18 | position: relative;
19 | width: 100%;
20 | height: 20px;
21 | }
22 |
23 | .meta-main {
24 | transition: all .3s ease-in-out;
25 |
26 | .meta-item {
27 | margin: 0 8px 0 0;
28 | }
29 | }
30 |
31 | .meta-item span,
32 | .meta-item {
33 | line-height: 20px;
34 | font-size: 14px;
35 | color: rgba(0, 0, 0, .6);
36 | }
37 |
38 | .meta-number {
39 | font-size: 14px;
40 | font-weight: 500;
41 | color: rgba(0, 0, 0, .8);
42 |
43 | span {
44 | font-weight: 500;
45 | color: rgba(0, 0, 0, .8);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/report/index.browser.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var Raven = require('raven-js');
3 |
4 | var browser = require('../extension/browser');
5 |
6 | Raven
7 | .config(process.env.SENTRY_DSN_CLIENT, {
8 | environment: process.env.NODE_ENV,
9 | release: process.env.npm_package_version
10 | })
11 | .install();
12 |
13 | browser.storage.onChanged.addListener(function(changes, areaName) {
14 | if (areaName !== 'sync') {
15 | return;
16 | }
17 | _.pairs(changes).forEach(function(entry) {
18 | if (entry[0] !== 'user_id') {
19 | return;
20 | }
21 | Raven.setUserContext({ id: entry[1].newValue });
22 | });
23 | });
24 |
25 | browser.storage.sync.get('user_id')
26 | .then(_.property('user_id'))
27 | .then(function(user_id) {
28 | if (!user_id) {
29 | return;
30 | }
31 |
32 | Raven.setUserContext({ id: user_id });
33 | })
34 | .catch(Raven.captureException);
35 |
36 | module.exports = Raven;
37 |
--------------------------------------------------------------------------------
/components/Gif/Gif.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var styles = require('./Gif.styles');
5 |
6 | module.exports = React.createClass({
7 | displayName: 'Gif',
8 | propTypes: {
9 | className: React.PropTypes.string,
10 | gif: React.PropTypes.string.isRequired,
11 | image: React.PropTypes.object,
12 | onLoad: React.PropTypes.func.isRequired
13 | },
14 | render: function() {
15 | return (
16 |
27 | );
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "xo",
3 | "plugins": ["react"],
4 | "env": {
5 | "browser": true,
6 | "mocha": true,
7 | "node": true
8 | },
9 | "rules": {
10 | "camelcase": 0,
11 | "key-spacing": [2, {
12 | "align": "value",
13 | "beforeColon": false,
14 | "afterColon": true
15 | }],
16 | "new-cap": 0,
17 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"],
18 | "no-warning-comments": [1, { "terms": ["todo", "fixme", "hack"], "location": "anywhere" }],
19 | "no-multi-spaces": [2, { "exceptions": { "VariableDeclarator": true } }],
20 | "object-curly-spacing": [2, "always"],
21 | "react/jsx-uses-react": 2,
22 | "react/jsx-uses-vars": 2,
23 | "react/sort-comp": [2, {
24 | "order": [
25 | "static-methods",
26 | "lifecycle",
27 | "everything-else",
28 | "/^on.+$/",
29 | "render"
30 | ]
31 | }],
32 | "space-before-function-paren": [2, "never"]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/assets/images/twitter.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
--------------------------------------------------------------------------------
/assets/images/twitter-black.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
--------------------------------------------------------------------------------
/components/IntegrationOptions/IntegrationOptions.styles.css:
--------------------------------------------------------------------------------
1 | .row {
2 | margin: auto;
3 | max-width: 420px;
4 | overflow: auto;
5 | padding: 5px;
6 | }
7 |
8 | .col-6 {
9 | float: left;
10 | width: 50%;
11 |
12 | &:first-of-type {
13 | padding-right: 5px;
14 | }
15 |
16 | &:last-of-type {
17 | padding-left: 5px;
18 | }
19 | }
20 |
21 | .option {
22 | background: white;
23 | border-radius: 3px;
24 | border: 1px solid rgba(0, 0, 0, .15);
25 | cursor: pointer;
26 | display: block;
27 | overflow: auto;
28 | padding: 12px;
29 | transition: all .1s ease-in-out;
30 |
31 | &:hover {
32 | box-shadow: 0 1px 7px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.15);
33 | }
34 | }
35 |
36 | .integration-image {
37 | float: left;
38 | width: 28px;
39 | }
40 |
41 | .option-title {
42 | display: block;
43 | font-weight: bold;
44 | padding-bottom: 1px;
45 | padding-left: 38px;
46 | }
47 |
48 | .input-container {
49 | color: rgba(0, 0, 0, .4);
50 | cursor: pointer;
51 | padding-left: 38px;
52 | }
53 |
--------------------------------------------------------------------------------
/integrations/instagram/urls.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var urls = {};
4 |
5 | urls.hostnames_parsed = ['instagram.com', 'www.instagram.com', 'instagr.am'];
6 |
7 | urls.parse = function(url_obj) {
8 | var path_parts = url_obj.pathname.replace(/^\//, '').replace(/\/$/, '').split('/') || [];
9 | if (path_parts[0] === 'p') {
10 | return !_.isEmpty(path_parts[1]) && { api: 'instagram', type: 'content', id: path_parts[1].replace(/[?&].*/, '') };
11 | }
12 | return !_.isEmpty(path_parts[0]) && !path_parts[0].match(/^(?:about|developer|explore|legal|press)$/) && { api: 'instagram', type: 'account', id: path_parts[0].replace(/[?&].*/, '') };
13 | };
14 |
15 | urls.represent = function(identity) {
16 | switch (identity.type) {
17 | case 'content':
18 | return ['https://instagram.com/p/' + identity.id + '/', 'https://instagr.am/p/' + identity.id + '/'];
19 | case 'account':
20 | return ['https://instagram.com/' + identity.id + '/', 'https://instagr.am/' + identity.id + '/'];
21 | default:
22 | return null;
23 | }
24 | };
25 |
26 | module.exports = urls;
27 |
--------------------------------------------------------------------------------
/assets/images/instagram.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
--------------------------------------------------------------------------------
/assets/images/youtube.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/redux/entities.actions.top-frame.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var createAction = require('redux-actions').createAction;
3 |
4 | var browser = require('../extension/browser');
5 | var entityLabel = require('../utils/entity-label');
6 |
7 | var setEntity = createAction('SET_ENTITY', null, function(entity, label) {
8 | return label && { label: label };
9 | });
10 |
11 | module.exports.getEntity = function(request) {
12 | return function(dispatch, getState) {
13 | var state = getState();
14 | var label = entityLabel(request);
15 | if (state.entities[label] && state.entities[label].loaded && Date.now() - state.entities[label].loaded <= 5 * 60 * 1000) {
16 | return Promise.resolve();
17 | }
18 |
19 | return browser.runtime.sendMessage(createAction('getEntity')(request))
20 | .then(_.property('payload'))
21 | .then(setEntity)
22 | .then(dispatch);
23 | };
24 | };
25 |
26 | module.exports.setEntity = function(request, meta) {
27 | return function(dispatch) {
28 | dispatch(setEntity(request, (meta || {}).label));
29 | return Promise.resolve();
30 | };
31 | };
32 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hovercards",
3 | "env": {
4 | "CHROMIUM_IDS": { "required": true },
5 | "GOOGLE_ANALYTICS_ID": { "required": false },
6 | "GOOGLE_SERVER_KEY": { "required": true },
7 | "IMGUR_CLIENT_ID": { "required": true },
8 | "INSTAGRAM_CLIENT_ID": { "required": true },
9 | "INSTAGRAM_CLIENT_SECRET": { "required": true },
10 | "MASHAPE_KEY": { "required": false },
11 | "REDDIT_CLIENT_ID": { "required": true },
12 | "SENTRY_DSN": { "required": true },
13 | "SENTRY_DSN_CLIENT": { "required": true },
14 | "SOUNDCLOUD_CLIENT_ID": { "required": true },
15 | "TWITTER_APP_ACCESS_TOKEN": { "required": true },
16 | "TWITTER_APP_ACCESS_TOKEN_SECRET": { "required": true },
17 | "TWITTER_CONSUMER_KEY": { "required": true },
18 | "TWITTER_CONSUMER_SECRET": { "required": true }
19 | },
20 | "addons": [
21 | "papertrail",
22 | "rediscloud",
23 | "securekey"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/components/Carousel/Carousel.styles.css:
--------------------------------------------------------------------------------
1 | .carousel {
2 | cursor: pointer;
3 | margin-bottom: 12px;
4 | min-width: 100%;
5 | position: relative;
6 | user-select: none;
7 | }
8 |
9 | .arrows {
10 | width: 100%;
11 | height: 100%;
12 | z-index: 99999;
13 | position: absolute;
14 | display: block;
15 | }
16 |
17 | .right-arrow,
18 | .left-arrow {
19 | width: 42px;
20 | height: 100%;
21 | position: absolute;
22 | top: 0;
23 | left: 0;
24 | opacity: 0;
25 | cursor: pointer;
26 | transition: all .2s ease-in-out;
27 |
28 | .carousel:hover & {
29 | opacity: .2;
30 | }
31 |
32 | .carousel &:hover {
33 | opacity: .5;
34 | }
35 | }
36 |
37 | .left-arrow {
38 | background-image: url('../../assets/images/left-arrow.svg');
39 | background-position: center;
40 | background-repeat: no-repeat;
41 | background-size: 20px;
42 | }
43 |
44 | .right-arrow {
45 | right: 0;
46 | left: inherit;
47 | background-image: url('../../assets/images/right-arrow.svg');
48 | background-position: right 11px center;
49 | background-repeat: no-repeat;
50 | background-size: 20px;
51 | width: calc(100% - 70px);
52 | }
53 |
--------------------------------------------------------------------------------
/assets/images/facebook-black.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
24 |
--------------------------------------------------------------------------------
/components/ContentHeader/ContentHeader.styles.css:
--------------------------------------------------------------------------------
1 | .header {
2 | padding: 12px 24px;
3 | border-bottom: 1px solid rgba(0, 0, 0, .1);
4 |
5 | @raw {
6 | composes: flex-spread from '../flex.styles';
7 | }
8 | }
9 |
10 | .image {
11 | background-position: center;
12 | background-size: cover;
13 | border-radius: 50%;
14 | display: inline-block;
15 | height: 28px;
16 | margin-right: 6px;
17 | width: 28px;
18 | }
19 |
20 | .name-container {
21 | @raw {
22 | composes: flex-grow from '../flex.styles';
23 | }
24 | }
25 |
26 | .name {
27 | font-size: 14px;
28 | }
29 |
30 | .share {
31 | background-position: center;
32 | background-repeat: no-repeat;
33 | background-size: 100%;
34 | height: 21px;
35 | margin-left: 8px;
36 | opacity: .7;
37 | width: 21px;
38 |
39 | &:hover {
40 | opacity: 1;
41 | }
42 | }
43 |
44 | .share-on-facebook {
45 | background-image: url('../../assets/images/facebookshare.png');
46 |
47 | @raw {
48 | composes: share;
49 | }
50 | }
51 |
52 | .share-on-twitter {
53 | background-image: url('../../assets/images/twittershare.png');
54 |
55 | @raw {
56 | composes: share;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/assets/images/circles.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/extension/config.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | module.exports = {
4 | options: {
5 | imgur: {
6 | account: { enabled: true },
7 | content: { enabled: true }
8 | },
9 | instagram: {
10 | account: { enabled: true },
11 | content: { enabled: true }
12 | },
13 | reddit: {
14 | account: { enabled: true },
15 | content: { enabled: true }
16 | },
17 | soundcloud: {
18 | account: { enabled: true },
19 | content: { enabled: true }
20 | },
21 | twitter: {
22 | account: { enabled: true },
23 | content: { enabled: true }
24 | },
25 | youtube: {
26 | account: { enabled: true },
27 | content: { enabled: true }
28 | }
29 | }
30 | };
31 |
32 | module.exports.options.keys = function optionKeys(object, prefix) {
33 | object = object || _.omit(module.exports.options, 'keys');
34 | prefix = prefix || '';
35 | return _.chain(object)
36 | .pairs()
37 | .reduce(
38 | function(keys, entry) {
39 | if (_.isObject(entry[1]) && !_.isArray(entry[1])) {
40 | return keys.concat(optionKeys(entry[1], prefix + entry[0] + '.'));
41 | }
42 | keys.push(prefix + entry[0]);
43 | return keys;
44 | },
45 | []
46 | )
47 | .value();
48 | };
49 |
--------------------------------------------------------------------------------
/assets/images/right-arrow.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/components/Options/Options.styles.css:
--------------------------------------------------------------------------------
1 | /* HACK Global styles are bad */
2 | * {
3 | box-sizing: border-box;
4 | margin: 0;
5 | }
6 |
7 | html {
8 | background-color: #eceef1;
9 | }
10 |
11 | .options {
12 | background-color: #eceef1;
13 | padding: 5px;
14 | }
15 |
16 | .save {
17 | background: #349cef;
18 | border-radius: 30px;
19 | border: 0;
20 | box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(0, 0, 0, 0);
21 | color: white;
22 | cursor: pointer;
23 | display: block;
24 | font-weight: bold;
25 | margin: 8px auto;
26 | outline: 0;
27 | padding: 12px;
28 | text-align: center;
29 | text-shadow: none;
30 | transition: all .1s ease-in-out;
31 | width: 150px;
32 |
33 | &:hover {
34 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
35 | transform: scale(1.05);
36 | }
37 |
38 | &.options-saved {
39 | background: #4cbb91;
40 | box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(0, 0, 0, 0);
41 | cursor: default;
42 | transform: scale(1);
43 | }
44 |
45 | &.options-error {
46 | background: #e94639;
47 | box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(0, 0, 0, 0);
48 | cursor: default;
49 | transform: scale(1);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/assets/images/left-arrow.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/redux/createStore.common.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var applyMiddleware = require('redux').applyMiddleware;
3 | var combineReducers = require('redux').combineReducers;
4 | var createStore = require('redux').createStore;
5 | var thunkMiddlware = require('redux-thunk').default;
6 |
7 | var browser = require('../extension/browser');
8 | var report = require('../report');
9 |
10 | createStore = applyMiddleware(thunkMiddlware)(createStore);
11 |
12 | module.exports = function(actions, reducers, initialState) {
13 | var store = createStore(combineReducers(reducers), initialState);
14 |
15 | if (!process.env.NODE_ENV) {
16 | require('./storelistener')(store);
17 | }
18 |
19 | _.each(reducers, function(reducer) {
20 | if (!_.isFunction(reducer.attachStore)) {
21 | return;
22 | }
23 | reducer.attachStore(store);
24 | });
25 |
26 | browser.runtime.onMessage.addListener(function(actionAsPromise, sender, sendResponse) {
27 | actionAsPromise
28 | .then(function(action) {
29 | return store.dispatch(actions[action.type](action.payload, action.meta, sender));
30 | })
31 | .then(sendResponse)
32 | .catch(report.captureException);
33 |
34 | return true;
35 | });
36 |
37 | return store;
38 | };
39 |
--------------------------------------------------------------------------------
/components/AccountFooter/AccountFooter.styles.css:
--------------------------------------------------------------------------------
1 | .meta-main {
2 | @raw {
3 | composes: meta-main from '../meta.styles';
4 | }
5 | }
6 |
7 | .network {
8 | background-position: center;
9 | background-repeat: no-repeat;
10 | background-size: 18px;
11 | border-radius: 50%;
12 | display: inline-block;
13 | height: 20px;
14 | vertical-align: top;
15 | width: 20px;
16 |
17 | &.imgur {
18 | background-color: #000000;
19 | background-size: 80%;
20 | background-image: url('../../assets/images/imgur.svg');
21 | }
22 |
23 | &.instagram {
24 | background-color: #3f729b;
25 | background-size: 80%;
26 | background-image: url('../../assets/images/instagram.svg');
27 | }
28 |
29 | &.reddit {
30 | background-color: #d0e2f9;
31 | background-size: 80%;
32 | background-image: url('../../assets/images/reddit.svg');
33 | }
34 |
35 | &.soundcloud {
36 | background-color: #ff3300;
37 | background-image: url('../../assets/images/soundcloud.svg');
38 | }
39 |
40 | &.twitter {
41 | background-color: #55acee;
42 | background-image: url('../../assets/images/twitter.svg');
43 | }
44 |
45 | &.youtube {
46 | background-color: #cd201f;
47 | background-image: url('../../assets/images/youtube.svg');
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/assets/images/verified.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
24 |
--------------------------------------------------------------------------------
/components/Collapsable/Collapsable.styles.css:
--------------------------------------------------------------------------------
1 | .collapsable {
2 | position: relative;
3 | overflow: hidden;
4 |
5 | @raw {
6 | composes: format from '../no-formatting.styles';
7 | }
8 |
9 | &.collapsed {
10 | cursor: pointer;
11 |
12 | &:after {
13 | background: #e8e8e8;
14 | border-radius: 20px;
15 | line-height: 10px;
16 | color: rgba(0, 0, 0, .6);
17 | content: '...';
18 | cursor: pointer;
19 | font-weight: bolder;
20 | display: block;
21 | font-family: -apple-system, BlinkMacSystemFont, 'Roboto', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
22 | font-size: 18px;
23 | height: 20px;
24 | padding: 0 6px;
25 | position: absolute;
26 | right: 24px;
27 | text-align: right;
28 | top: calc(100% - 23px);
29 | z-index: 20;
30 | }
31 |
32 | &:before {
33 | content: '';
34 | position: absolute;
35 | display: block;
36 | background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 28%, rgba(255, 255, 255, 1) 100%);
37 | right: 24px;
38 | top: calc(100% - 23px);
39 | height: 20px;
40 | width: 60px;
41 | }
42 | }
43 |
44 | &.expanded {
45 | max-height: 999999px;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/components/Video/Video.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var styles = require('./Video.styles');
5 |
6 | module.exports = React.createClass({
7 | displayName: 'Video',
8 | propTypes: {
9 | className: React.PropTypes.string,
10 | image: React.PropTypes.object,
11 | muted: React.PropTypes.bool.isRequired,
12 | onLoad: React.PropTypes.func.isRequired,
13 | video: React.PropTypes.string.isRequired
14 | },
15 | getInitialState: function() {
16 | return { playing: true };
17 | },
18 | togglePlaying: function() {
19 | this.refs.video[this.state.playing ? 'pause' : 'play']();
20 | this.setState({ playing: !this.state.playing });
21 | },
22 | render: function() {
23 | return (
24 |
37 | );
38 | }
39 | });
40 |
--------------------------------------------------------------------------------
/assets/images/imgur.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
19 |
--------------------------------------------------------------------------------
/extension/set-uninstall-url.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var browser = require('./browser');
4 | var report = require('../report');
5 |
6 | var ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
7 |
8 | browser.storage.onChanged.addListener(function(changes, areaName) {
9 | if (areaName !== 'sync') {
10 | return;
11 | }
12 | _.pairs(changes).forEach(function(entry) {
13 | if (entry[0] !== 'user_id') {
14 | return;
15 | }
16 | browser.runtime.setUninstallURL('http://' + (process.env.NODE_ENV === 'production' ? 'hover.cards' : 'localhost:5100') + '/track_uninstall?user_id=' + entry[1].newValue)
17 | .catch(report.captureException);
18 | });
19 | });
20 |
21 | browser.runtime.setUninstallURL('http://' + (process.env.NODE_ENV === 'production' ? 'hover.cards' : 'localhost:5100') + '/track_uninstall')
22 | .then(function() {
23 | return browser.storage.sync.get('user_id');
24 | })
25 | .then(_.property('user_id'))
26 | .then(function(user_id) {
27 | if (user_id) {
28 | return user_id;
29 | }
30 |
31 | user_id = _.times(25, _.partial(_.sample, ALPHANUMERIC, null)).join('');
32 | return browser.storage.sync.set({ user_id: user_id }).then(_.constant(user_id));
33 | })
34 | .then(function(user_id) {
35 | return browser.runtime.setUninstallURL('http://' + (process.env.NODE_ENV === 'production' ? 'hover.cards' : 'localhost:5100') + '/track_uninstall?user_id=' + user_id);
36 | })
37 | .catch(report.captureException);
38 |
--------------------------------------------------------------------------------
/extension/content-security-policy.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var browser = require('./browser');
4 | var integrationsConfig = require('../integrations/config');
5 |
6 | var csp_append = _.chain(integrationsConfig)
7 | .result('integrations')
8 | .pluck('content_security_policy')
9 | .compact()
10 | .reduce(function(memo, csp_object) {
11 | _.each(csp_object, function(urls, key) {
12 | memo[key] = _.union(memo[key], urls);
13 | });
14 | return memo;
15 | }, {})
16 | .mapObject(function(urls) {
17 | return urls.join(' ');
18 | })
19 | .value();
20 |
21 | csp_append['font-src'] = 'fonts.gstatic.com';
22 |
23 | browser.webRequest.onHeadersReceived.addListener(
24 | function(details) {
25 | var responseHeaders = _.result(details, 'responseHeaders');
26 | var csp = _.find(responseHeaders, function(responseHeader) {
27 | return responseHeader.name.match(/content-security-policy/i);
28 | });
29 | if (csp) {
30 | var csp_object = _.indexBy(csp.value.trim().replace(/;$/, '').split(/\s*;\s*/), function(string) {
31 | return string.substring(0, string.indexOf(' '));
32 | });
33 |
34 | _.each(csp_append, function(urls, key) {
35 | if (csp_object[key]) {
36 | csp_object[key] += ' ' + urls;
37 | }
38 | });
39 |
40 | csp.value = _.values(csp_object).join('; ') + ';';
41 | }
42 | return { responseHeaders: responseHeaders };
43 | },
44 | {
45 | urls: ['*://*/*'],
46 | types: ['main_frame', 'sub_frame']
47 | },
48 | ['blocking', 'responseHeaders']
49 | );
50 |
--------------------------------------------------------------------------------
/components/OEmbed/OEmbed.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var dom = require('../../utils/dom');
5 | var styles = require('./OEmbed.styles');
6 |
7 | module.exports = React.createClass({
8 | displayName: 'OEmbed',
9 | propTypes: {
10 | className: React.PropTypes.string,
11 | image: React.PropTypes.object,
12 | oembed: React.PropTypes.string.isRequired,
13 | onLoad: React.PropTypes.func.isRequired
14 | },
15 | componentDidMount: function() {
16 | this.massageIframe();
17 | },
18 | componentDidUpdate: function(prevProps) {
19 | if (this.props.oembed === prevProps.oembed) {
20 | return;
21 | }
22 | this.massageIframe();
23 | },
24 | componentWillUnmount: function() {
25 | var iframe = this.refs.container.children[0];
26 | iframe.removeEventListener('onload', this.onLoad);
27 | },
28 | massageIframe: function() {
29 | var iframe = this.refs.container.children[0];
30 | dom.addClass(iframe, classnames(styles.oembed, this.props.className));
31 | iframe.style.height = (iframe.height / iframe.width * this.refs.container.offsetWidth) + 'px';
32 | iframe.addEventListener('onload', this.onLoad);
33 | },
34 | render: function() {
35 | return (
36 |
40 | );
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/integrations/imgur/urls.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var urls = {};
4 |
5 | urls.hostnames_parsed = ['imgur.com', 'www.imgur.com', 'i.imgur.com', 'm.imgur.com'];
6 |
7 | urls.parse = function(url_obj) {
8 | var path_parts = url_obj.pathname.replace(/^\//, '').replace(/\/$/, '').split('/') || [];
9 | switch (path_parts[0]) {
10 | case 'a':
11 | return !_.isEmpty(path_parts[1]) && { api: 'imgur', type: 'content', id: path_parts[1], as: 'album' };
12 | case 'gallery':
13 | return !_.isEmpty(path_parts[1]) && { api: 'imgur', type: 'content', id: path_parts[1], as: 'gallery' };
14 | case 'user':
15 | return !_.isEmpty(path_parts[1]) && { api: 'imgur', type: 'account', id: path_parts[1] };
16 | default:
17 | return !_.isEmpty(path_parts[0]) && { api: 'imgur', type: 'content', id: path_parts[0].replace(/\..+?$/, ''), as: 'image' };
18 | }
19 | };
20 |
21 | urls.represent = function(identity, comment) {
22 | switch (identity.type) {
23 | case 'content':
24 | return _.compact([
25 | _.result(comment, 'id') && ('https://imgur.com/gallery/' + _.result(identity, 'id') + '/comment/' + comment.id),
26 | (identity.as === 'image') && ('https://imgur.com/' + identity.id),
27 | (identity.as === 'image') && ('https://i.imgur.com/' + identity.id),
28 | (identity.as === 'album') && ('https://imgur.com/a/' + identity.id),
29 | 'https://imgur.com/gallery/' + identity.id
30 | ]);
31 | case 'account':
32 | return ['https://imgur.com/user/' + identity.id];
33 | default:
34 | return null;
35 | }
36 | };
37 |
38 | module.exports = urls;
39 |
--------------------------------------------------------------------------------
/integrations/urls/index.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var url = require('url');
3 |
4 | var hostnames_to_urls = {};
5 |
6 | // HACK Can't use require.context because of mocha and node
7 | _.each([require('../imgur/urls'),
8 | require('../instagram/urls'),
9 | require('../reddit/urls'),
10 | require('../soundcloud/urls'),
11 | require('../twitter/urls'),
12 | require('../youtube/urls')], function(api_urls) {
13 | _.each(api_urls.hostnames_parsed, function(hostname_parsed) {
14 | hostnames_to_urls[hostname_parsed] = hostnames_to_urls[hostname_parsed] || [];
15 | hostnames_to_urls[hostname_parsed].push(api_urls);
16 | });
17 | });
18 |
19 | var urls = {};
20 |
21 | urls.parse = function(url_string) {
22 | if (_.isEmpty(url_string)) {
23 | return;
24 | }
25 | var url_object;
26 | try {
27 | url_object = url.parse(url_string, true, true);
28 | } catch (e) {
29 | if (e instanceof URIError) {
30 | return;
31 | }
32 | throw e;
33 | }
34 |
35 | if (url_object.hostname === 'l.facebook.com') {
36 | return urls.parse(url_object.query.u);
37 | }
38 | return _.chain(hostnames_to_urls[url_object.hostname]).invoke('parse', url_object).compact().first().value();
39 | };
40 |
41 | urls.represent = function(identity, comment) {
42 | return require('../' + identity.api + '/urls').represent(identity, comment);
43 | };
44 |
45 | urls.print = function(identity, comment) {
46 | if (!identity) {
47 | return identity;
48 | }
49 | return (urls.represent(identity, comment) || [])[0];
50 | };
51 |
52 | module.exports = urls;
53 |
--------------------------------------------------------------------------------
/components/AccountHovercard/AccountHovercard.styles.css:
--------------------------------------------------------------------------------
1 | .account {
2 | min-height: 140px;
3 | overflow: hidden;
4 | width: 380px;
5 |
6 | &.noAccountImage {
7 | min-height: 88px;
8 | }
9 | }
10 |
11 | .image {
12 | background-position: center;
13 | background-size: cover;
14 | border-radius: 50%;
15 | border: 3px solid white;
16 | display: block;
17 | height: 90px;
18 | margin: auto;
19 | margin-top: 16px;
20 | position: relative;
21 | width: 90px;
22 |
23 | .header + & {
24 | margin-top: -45px;
25 | }
26 | }
27 |
28 | .name-container {
29 | margin: 16px 12px 10px;
30 | text-align: center;
31 |
32 | @raw {
33 | composes: flex-row from '../flex.styles';
34 | }
35 |
36 | .image + & {
37 | margin: 3px 12px 0;
38 | }
39 | }
40 |
41 | .name {
42 | font-size: 16px;
43 | color: rgba(0, 0, 0, .8);
44 | font-weight: 700;
45 | }
46 |
47 | .verified {
48 | background-image: url('../../assets/images/verified.svg');
49 | background-position: center;
50 | background-repeat: no-repeat;
51 | background-size: 100%;
52 | display: inline-block;
53 | height: 16px;
54 | margin-left: 6px;
55 | width: 16px;
56 | }
57 |
58 | .description {
59 | max-height: 120px;
60 | padding: 12px 12px 0 12px;
61 | text-align: center;
62 | width: 100%;
63 |
64 | .text {
65 | text-align: center;
66 | font-size: 14px;
67 | margin: 3px 0;
68 | color: rgba(0, 0, 0, .6);
69 | }
70 |
71 | .name-container + & {
72 | margin: 0;
73 | padding: 6px 12px 0 12px;
74 | }
75 | }
76 |
77 | .text {
78 | display: block;
79 | }
80 |
--------------------------------------------------------------------------------
/components/DiscussionComment/DiscussionComment.styles.css:
--------------------------------------------------------------------------------
1 | .comment-container {
2 | padding: 12px 24px;
3 | }
4 |
5 | .comment {
6 | display: flex;
7 | justify-content: left;
8 | }
9 |
10 | .account-image-container {
11 | height: 44px;
12 | margin: 0 16px 0 0;
13 | width: 44px;
14 | }
15 |
16 | .account-image {
17 | background-position: center;
18 | background-size: cover;
19 | border-radius: 50%;
20 | display: block;
21 | height: 44px;
22 | position: relative;
23 | width: 44px;
24 |
25 | &.empty {
26 | background-image: url('../../assets/images/missing-image.png');
27 | }
28 | }
29 |
30 | .account-name {
31 | font-size: 15px;
32 | font-weight: normal;
33 | text-decoration: underline;
34 | color: rgba(0, 0, 0, .8);
35 | }
36 |
37 | .description {
38 | padding-left: 0;
39 | width: 100%;
40 | }
41 |
42 | .text {
43 | margin: 5px 0;
44 | display: block;
45 |
46 | @raw {
47 | composes: format from '../no-formatting.styles';
48 | }
49 | }
50 |
51 | .stat {
52 | font-size: 15px;
53 | color: rgba(0, 0, 0, .6);
54 |
55 | @raw {
56 | composes: flex-item from '../flex.styles';
57 | }
58 | }
59 |
60 | .stat-number {
61 | font-size: 15px;
62 | color: rgba(0, 0, 0, .8);
63 | }
64 |
65 | .replies {
66 | border-left: 3px solid rgba(0, 0, 0, .1);
67 | padding-left: 20px;
68 | margin-top: 20px;
69 |
70 | .comment-container {
71 | padding: 0;
72 |
73 | &:not(:last-of-type) {
74 | padding-bottom: 16px;
75 | }
76 | }
77 |
78 | .account-image-container {
79 | margin: 0 0 0 16px;
80 | }
81 |
82 | .replies {
83 | margin-right: 0;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/integrations/reddit/urls.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var urls = {};
4 |
5 | urls.hostnames_parsed = ['reddit.com', 'www.reddit.com', 'np.reddit.com', 'm.reddit.com', 'redd.it', 'redditmedia.com', 'www.redditmedia.com'];
6 | urls.non_content_ids = /^(?:ads|advertising|blog|buttons|code|contact|controversial|dev|domain|explore|gilded|gold|help|jobs|login|message|new|password|prefs|promoted|rising|rules|submit|subreddits|top|wiki)$/;
7 |
8 | urls.parse = function(url_obj) {
9 | var pathname = (url_obj.pathname || '').replace(/\/$/, '');
10 | var match;
11 | if ((match = pathname.match(/^\/(?:u(?:ser)?)\/([^/]+)(?:\/[^/]+)?$/)) && match[1]) {
12 | return { api: 'reddit', type: 'account', id: match[1] };
13 | }
14 | if ((match = pathname.match(/^(?:\/r\/([^/]+))?(?:\/comments)?\/([^/]+)(?:\/.+)?$/)) && match[2]) {
15 | if (match[1]) {
16 | return { api: 'reddit', type: 'content', id: match[2], subreddit: match[1] };
17 | }
18 | if (match[2] === 'r') {
19 | return null;
20 | }
21 | if (match[2].match(urls.non_content_ids)) {
22 | return null;
23 | }
24 | return { api: 'reddit', type: 'content', id: match[2] };
25 | }
26 | };
27 |
28 | urls.represent = function(identity, comment) {
29 | switch (identity.type) {
30 | case 'content':
31 | return ['https://www.reddit.com' + (identity.subreddit ? '/r/' + identity.subreddit : '') + '/comments/' + identity.id + (_.result(comment, 'id') ? '/comment/' + comment.id : ''), 'https://redd.it/' + identity.id];
32 | case 'account':
33 | return ['https://www.reddit.com/user/' + identity.id];
34 | default:
35 | return null;
36 | }
37 | };
38 |
39 | module.exports = urls;
40 |
--------------------------------------------------------------------------------
/redux/authentication.reducer.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var createAction = require('redux-actions').createAction;
3 | var handleActions = require('redux-actions').handleActions;
4 |
5 | var browser = require('../extension/browser');
6 |
7 | module.exports = handleActions(
8 | {
9 | SET_AUTHENTICATION: {
10 | next: function(state, action) {
11 | if (action.payload.value === undefined) {
12 | browser.storage.sync.remove('authentication.' + action.payload.api);
13 | return _.omit(state, action.payload.api);
14 | }
15 | browser.storage.sync.set({ ['authentication.' + action.payload.api]: action.payload.value });
16 | return Object.assign({}, state, { [action.payload.api]: action.payload.value });
17 | }
18 | }
19 | },
20 | {}
21 | );
22 |
23 | module.exports.attachStore = function(store) {
24 | browser.storage.sync.get(null)
25 | .then(function(items) {
26 | _.pairs(items).forEach(function(entry) {
27 | var key = entry[0].match(/^authentication\.(.+)/);
28 | if (!key) {
29 | return;
30 | }
31 | store.dispatch(createAction('SET_AUTHENTICATION')({ api: key[1], value: entry[1] }));
32 | });
33 | });
34 |
35 | browser.storage.onChanged.addListener(function(changes, areaName) {
36 | if (areaName !== 'sync') {
37 | return;
38 | }
39 | _.pairs(changes).forEach(function(entry) {
40 | var key = entry[0].match(/^authentication\.(.+)/);
41 | if (!key) {
42 | return;
43 | }
44 | store.dispatch(createAction('SET_AUTHENTICATION')({ api: key[1], value: entry[1].newValue }));
45 | store.dispatch(createAction('CLEAR_ENTITIES')(key[1]));
46 | });
47 | });
48 | };
49 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | var compression = require('compression');
2 | var feathers = require('feathers');
3 | var helmet = require('helmet');
4 | var raven = require('raven');
5 | var ua = require('universal-analytics');
6 |
7 | var report = require('../report');
8 |
9 | var PORT = process.env.PORT || 5100;
10 |
11 | feathers()
12 | .use(helmet())
13 | .use(compression())
14 | .use(raven.middleware.express.requestHandler(report))
15 | .use('/v2', require('./v2'))
16 | .get('/', function(req, res) {
17 | res.redirect(process.env.npm_package_homepage);
18 | })
19 | .get('/track_uninstall', function(req, res) {
20 | if (process.env.GOOGLE_ANALYTICS_ID) {
21 | ua(process.env.GOOGLE_ANALYTICS_ID, req.query.user_id, { strictCidFormat: false })
22 | .pageview('/track_uninstall')
23 | .send();
24 | } else {
25 | console.log('google analytics', ['send', 'pageview', '/track_uninstall']);
26 | }
27 | res.redirect('https://hovercards.typeform.com/to/ajyJv2');
28 | })
29 |
30 | .use(function(err, req, res, next) { // eslint-disable-line no-unused-vars
31 | if (!err.code || err.code < 400 || err.code >= 500) {
32 | return next(err);
33 | }
34 | err.message = err.message || 'Do not recognize url ' + req.path;
35 | res.status(err.code || 500).json(err);
36 | })
37 | .use(raven.middleware.express.errorHandler(report))
38 | .use(function(err, req, res, next) { // eslint-disable-line no-unused-vars
39 | err.message = err.message || 'Do not recognize url ' + req.path;
40 | res.status(err.code || 500).json(err);
41 | })
42 |
43 | .listen(PORT, function() {
44 | console.log('Server is running at', 'http://localhost:' + PORT);
45 | });
46 |
--------------------------------------------------------------------------------
/components/Collapsable/Collapsable.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var styles = require('./Collapsable.styles');
5 |
6 | module.exports = React.createClass({
7 | displayName: 'Collapsable',
8 | propTypes: {
9 | children: React.PropTypes.node.isRequired,
10 | className: React.PropTypes.string,
11 | onExpand: React.PropTypes.func.isRequired,
12 | onResize: React.PropTypes.func.isRequired
13 | },
14 | getInitialState: function() {
15 | return { expanded: false, collapsable: true };
16 | },
17 | componentDidMount: function() {
18 | if (this.refs.collapsable.scrollHeight < this.refs.collapsable.offsetHeight + 5) {
19 | return this.setState({ collapsable: false });
20 | }
21 | },
22 | componentDidUpdate: function(prevProps, prevState) {
23 | if (this.state.expanded === prevState.expanded && this.state.collapsable === prevState.collapsable) {
24 | return;
25 | }
26 | this.props.onResize();
27 | },
28 | onExpand: function(e) {
29 | if (this.state.expanded || !this.state.collapsable) {
30 | return;
31 | }
32 | e.preventDefault();
33 | e.stopPropagation();
34 | this.setState({ expanded: true });
35 | this.props.onExpand();
36 | },
37 | render: function() {
38 | return (
39 |
49 | {this.props.children}
50 |
51 | );
52 | }
53 | });
54 |
--------------------------------------------------------------------------------
/redux/storelistener.js:
--------------------------------------------------------------------------------
1 | var diff = require('deep-diff');
2 |
3 | module.exports = function(store) {
4 | var state = store.getState();
5 |
6 | console.groupCollapsed('store');
7 | console.log(state);
8 | console.groupEnd();
9 | store.subscribe(function() {
10 | var newState = store.getState();
11 | var differences = diff(state, newState);
12 |
13 | if (!differences || !differences.length) {
14 | return;
15 | }
16 | console.groupCollapsed('store change');
17 | differences.forEach(function(diff) {
18 | switch (diff.kind) {
19 | case 'N':
20 | console.log('["' + diff.path.join('"]["') + '"]', 'to', diff.rhs);
21 | break;
22 | case 'D':
23 | console.log('["' + diff.path.join('"]["') + '"]', 'deleted; was', diff.lhs);
24 | break;
25 | case 'E':
26 | console.log('["' + diff.path.join('"]["') + '"]', 'to', diff.rhs, 'from', diff.lhs);
27 | break;
28 | case 'A':
29 | switch (diff.item.kind) {
30 | case 'N':
31 | console.log('["' + diff.path.join('"]["') + '"]', 'added', diff.item.rhs, 'at', diff.index);
32 | break;
33 | case 'D':
34 | console.log('["' + diff.path.join('"]["') + '"]', 'deleted', diff.lhs, 'at', diff.index);
35 | break;
36 | case 'E':
37 | // TODO Finish these cases
38 | console.log('["' + diff.path.join('"]["') + '"]', diff);
39 | break;
40 | case 'A':
41 | console.log('["' + diff.path.join('"]["') + '"]', diff);
42 | break;
43 | default:
44 | break;
45 | }
46 | break;
47 | default:
48 | break;
49 | }
50 | });
51 | console.log(newState);
52 | console.groupEnd();
53 |
54 | state = newState;
55 | });
56 | };
57 |
--------------------------------------------------------------------------------
/extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "__MSG_app_name__",
3 | "short_name": "__MSG_app_short_name__",
4 | "version": "__VERSION__",
5 | "description": "__MSG_app_description__",
6 | "homepage_url": "http://hovercards.com",
7 |
8 | "minimum_chrome_version": "45",
9 | "icons": {
10 | "16": "assets/images/logo-16-active.png",
11 | "19": "assets/images/logo-19-active.png",
12 | "38": "assets/images/logo-38-active.png",
13 | "128": "assets/images/logo-128.png"
14 | },
15 | "browser_action": {
16 | "default_icon": {
17 | "19": "assets/images/logo-19-active.png",
18 | "38": "assets/images/logo-38-active.png"
19 | },
20 | "default_title": "HoverCards"
21 | },
22 |
23 | "background": {
24 | "scripts": [
25 | "common.js",
26 | "background.js"
27 | ]
28 | },
29 | "content_scripts": [
30 | {
31 | "matches": [
32 | ""
33 | ],
34 | "css": [
35 | "top-frame.css"
36 | ],
37 | "js": [
38 | "common.js",
39 | "top-frame.js"
40 | ],
41 | "all_frames": false
42 | }
43 | ],
44 | "options_page": "options.html",
45 | "options_ui": {
46 | "page": "options.html",
47 | "chrome_style": true
48 | },
49 |
50 | "content_security_policy": "script-src 'self' https://www.google-analytics.com; object-src 'self' https://www.youtube.com",
51 | "default_locale": "en",
52 | "manifest_version": 2,
53 | "permissions": [
54 | "activeTab",
55 | "identity",
56 | "storage",
57 | "webRequest",
58 | "webRequestBlocking",
59 | "http://*/*",
60 | "https://*/*"
61 | ],
62 | "web_accessible_resources": [
63 | "*.map",
64 | "assets/*"
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/redux/authentication.actions.background.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var createAction = require('redux-actions').createAction;
3 | var errors = require('feathers-errors');
4 |
5 | var analyticsActions = require('./analytics.actions.background');
6 | var browser = require('../extension/browser');
7 | var integrationsConfig = require('../integrations/config');
8 |
9 | var serverEndpoint = 'http://' + (process.env.NODE_ENV === 'production' ? 'hover.cards' : 'localhost:5100') + '/v2/';
10 |
11 | module.exports.authenticate = function(request, meta, sender) {
12 | return function(dispatch) {
13 | if (!request.api) {
14 | throw new errors.BadRequest('Missing \'api\'');
15 | }
16 | var integrationConfig = integrationsConfig.integrations[request.api];
17 | if (!_.result(integrationConfig, 'authenticatable')) {
18 | throw new errors.BadRequest(request.api + ' cannot be authenticated');
19 | }
20 | return browser.identity.launchWebAuthFlow({
21 | url: _.result(integrationConfig, 'authentication_url', serverEndpoint + '/' + request.api + '/authenticate?chromium_id=EXTENSION_ID').replace('EXTENSION_ID', browser.i18n.getMessage('@@extension_id')),
22 | interactive: true
23 | })
24 | .then(function(redirectURL) {
25 | var user = redirectURL && (redirectURL.split('#', 2)[1] || '').split('=', 2)[1];
26 | if (_.isEmpty(user)) {
27 | throw new errors.GeneralError('No user token returned for ' + request.api + ': ' + redirectURL);
28 | }
29 | dispatch(createAction('SET_AUTHENTICATION')({ api: request.api, value: user }));
30 | dispatch(analyticsActions.analytics(['send', 'event', request.api, 'Authenticated'], sender));
31 | dispatch(createAction('CLEAR_ENTITIES')(request.api));
32 | });
33 | };
34 | };
35 |
--------------------------------------------------------------------------------
/components/ContentDescription/ContentDescription.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 | var connect = require('react-redux').connect;
4 |
5 | var Collapsable = require('../Collapsable/Collapsable');
6 | var actions = require('../../redux/actions.top-frame');
7 | var entityLabel = require('../../utils/entity-label');
8 | var report = require('../../report');
9 | var styles = require('./ContentDescription.styles');
10 | var urls = require('../../integrations/urls');
11 |
12 | module.exports = connect(null, actions)(React.createClass({
13 | displayName: 'ContentDescription',
14 | propTypes: {
15 | analytics: React.PropTypes.func.isRequired,
16 | className: React.PropTypes.string,
17 | content: React.PropTypes.object.isRequired,
18 | onResize: React.PropTypes.func.isRequired
19 | },
20 | componentDidUpdate: function(prevProps) {
21 | if (this.props.content === prevProps.content) {
22 | return;
23 | }
24 | this.props.onResize();
25 | },
26 | onExpandDescription: function() {
27 | this.props.analytics(['send', 'event', entityLabel(this.props.content, true), 'Expanded description'])
28 | .catch(report.catchException);
29 | },
30 | render: function() {
31 | return (this.props.content.name || this.props.content.text) ?
32 |
33 | { this.props.content.name && { this.props.content.name } }
34 | { this.props.content.text && }
35 | :
36 | null;
37 | }
38 | }));
39 |
--------------------------------------------------------------------------------
/components/ContentHovercard/ContentHovercard.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var ContentDescription = require('../ContentDescription/ContentDescription');
5 | var ContentFooter = require('../ContentFooter/ContentFooter');
6 | var ContentHeader = require('../ContentHeader/ContentHeader');
7 | var Discussions = require('../Discussions/Discussions');
8 | var Err = require('../Err/Err');
9 | var Loading = require('../Loading/Loading');
10 | var Media = require('../Media/Media');
11 | var styles = require('./ContentHovercard.styles');
12 |
13 | module.exports = React.createClass({
14 | displayName: 'ContentHovercard',
15 | propTypes: {
16 | className: React.PropTypes.string,
17 | content: React.PropTypes.object.isRequired,
18 | hovered: React.PropTypes.bool.isRequired,
19 | meta: React.PropTypes.object.isRequired,
20 | onResize: React.PropTypes.func.isRequired
21 | },
22 | render: function() {
23 | var className = classnames(styles.content, this.props.className);
24 |
25 | if (this.props.content.err) {
26 | return ;
27 | }
28 | if (!this.props.content.loaded) {
29 | return ;
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/components/AccountFooter/AccountFooter.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var TimeSince = require('../TimeSince/TimeSince');
5 | var browser = require('../../extension/browser');
6 | var config = require('../../integrations/config');
7 | var format = require('../../utils/format');
8 | var styles = Object.assign({}, require('../meta.styles'), require('./AccountFooter.styles'));
9 |
10 | module.exports = React.createClass({
11 | displayName: 'AccountFooter',
12 | propTypes: {
13 | account: React.PropTypes.object.isRequired,
14 | className: React.PropTypes.string
15 | },
16 | render: function() {
17 | return (
18 |
19 |
20 |
21 | {
22 | this.props.account.stats && config.integrations[this.props.account.api].account.stats.map(function(stat) {
23 | if (stat === 'date') {
24 | return {browser.i18n.getMessage('age_of_' + this.props.account.api) || browser.i18n.getMessage('age')};
25 | }
26 | if (this.props.account.stats[stat] === undefined) {
27 | return null;
28 | }
29 | return {format.number(this.props.account.stats[stat])} {browser.i18n.getMessage(stat + '_of_' + this.props.account.api) || browser.i18n.getMessage(stat)};
30 | }.bind(this))
31 | }
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/extension/browser-action.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var browser = require('./browser');
3 | var report = require('../report');
4 |
5 | var analyticsActions = require('../redux/analytics.actions.background');
6 | var url = 'http://hovercards.com';
7 |
8 | browser.browserAction.setBadgeBackgroundColor({ color: '#ff0000' });
9 | browser.browserAction.onClicked.addListener(function() {
10 | browser.tabs.create({ url: url });
11 | analyticsActions.analytics(['send', 'event', 'not applicable', 'Browser Action', 'opened ' + url])();
12 | browser.storage.sync.set({ 'notifications.opensource': true })
13 | .catch(report.captureException);
14 | });
15 |
16 | browser.storage.onChanged.addListener(function(changes, areaName) {
17 | if (areaName !== 'sync') {
18 | return;
19 | }
20 | _.pairs(changes).forEach(function(entry) {
21 | if (entry[0] !== 'notifications.opensource') {
22 | return;
23 | }
24 | if (entry[1].newValue) {
25 | url = 'http://hovercards.com';
26 | browser.browserAction.setBadgeText({ text: '' });
27 | browser.browserAction.setTitle({ title: 'HoverCards' });
28 | } else {
29 | url = 'https://github.com/kogg/hovercards#readme';
30 | browser.browserAction.setBadgeText({ text: '1' });
31 | browser.browserAction.setTitle({ title: 'HoverCards is open source!' });
32 | }
33 | });
34 | });
35 |
36 | browser.storage.sync.get('notifications.opensource')
37 | .then(function(items) {
38 | if (items['notifications.opensource']) {
39 | url = 'http://hovercards.com';
40 | browser.browserAction.setBadgeText({ text: '' });
41 | browser.browserAction.setTitle({ title: 'HoverCards' });
42 | } else {
43 | url = 'https://github.com/kogg/hovercards#readme';
44 | browser.browserAction.setBadgeText({ text: '1' });
45 | browser.browserAction.setTitle({ title: 'HoverCards is open source!' });
46 | }
47 | })
48 | .catch(report.captureException);
49 |
--------------------------------------------------------------------------------
/components/Options/Options.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 | var classnames = require('classnames');
4 | var connect = require('react-redux').connect;
5 | var createStructuredSelector = require('reselect').createStructuredSelector;
6 |
7 | var IntegrationOptions = require('../IntegrationOptions/IntegrationOptions');
8 | var actions = require('../../redux/actions.options');
9 | var styles = require('./Options.styles');
10 |
11 | module.exports = connect(
12 | createStructuredSelector({
13 | options: _.property('options')
14 | }),
15 | actions
16 | )(React.createClass({
17 | displayName: 'Options',
18 | propTypes: {
19 | className: React.PropTypes.string,
20 | options: React.PropTypes.object.isRequired,
21 | setOption: React.PropTypes.func.isRequired
22 | },
23 | render: function() {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | {/*
33 |
34 | */}
35 |
36 | );
37 | }
38 | }));
39 |
--------------------------------------------------------------------------------
/integrations/soundcloud/urls.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var urls = {};
4 |
5 | urls.hostnames_parsed = ['soundcloud.com', 'www.soundcloud.com', 'm.soundcloud.com'];
6 |
7 | urls.non_username_regex = /^(?:explore|groups|jobs|messages|mobile|notifications|pages|people|pro|settings|stream|tags|terms-of-use|upload(?:-classic)?|you)$/;
8 | urls.non_trackname_regex = /^(?:comments|groups|follow(?:ers|ing)|likes|tracks)$/;
9 |
10 | urls.parse = function(url_obj) {
11 | var path_parts = url_obj.pathname.replace(/^\//, '').replace(/\/$/, '').split('/') || [];
12 | if (_.isEmpty(path_parts[0]) || path_parts[0].match(urls.non_username_regex)) {
13 | return;
14 | }
15 | var account = { api: 'soundcloud', type: 'account', id: path_parts[0] };
16 | if (_.isEmpty(path_parts[1]) || path_parts[1].match(urls.non_trackname_regex)) {
17 | return account;
18 | }
19 | var content = { api: 'soundcloud', type: 'content', id: path_parts[1], account: account };
20 | if (path_parts[1] === 'sets') {
21 | if (_.isEmpty(path_parts[2])) {
22 | return account;
23 | }
24 | _.extend(content, { id: path_parts[2], as: 'playlist' });
25 | }
26 | if (url_obj.hash) {
27 | var hash_parts = url_obj.hash.match(/t=(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/);
28 | if (hash_parts) {
29 | content.meta = { time_offset: (Number(hash_parts[1] || 0) * 3600) + (Number(hash_parts[2] || 0) * 60) + Number(hash_parts[3] || 0) };
30 | }
31 | }
32 | return content;
33 | };
34 |
35 | urls.represent = function(identity, comment) {
36 | switch (identity.type) {
37 | case 'content':
38 | return ['https://soundcloud.com/' + (_.result(identity.account, 'id') || 'screen_name') + (identity.as === 'playlist' ? '/sets' : '') + '/' + identity.id + (_.result(comment, 'id') ? '/comments/' + comment.id : '')];
39 | case 'account':
40 | return ['https://soundcloud.com/' + identity.id];
41 | default:
42 | return null;
43 | }
44 | };
45 |
46 | module.exports = urls;
47 |
--------------------------------------------------------------------------------
/utils/dom.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | module.exports.hasClass = function(element, classNames) {
4 | // Based off of jQuery 1.5.0
5 | return _.every([].concat(classNames), function(className) {
6 | return ((' ' + element.className + ' ').replace(/[\n\t\r]/g, ' ').indexOf(' ' + className + ' ') > -1);
7 | });
8 | };
9 |
10 | module.exports.addClass = function(element, value) {
11 | // Based off of jQuery 1.5.0
12 | if (element.nodeType !== 1) {
13 | return;
14 | }
15 | if (!element.className) {
16 | element.className = value;
17 | return;
18 | }
19 | var className = ' ' + element.className + ' ';
20 | var setClass = element.className;
21 |
22 | (value || '').split(/\s+/).forEach(function(aClassName) {
23 | if (className.indexOf(' ' + aClassName + ' ') < 0) {
24 | setClass += ' ' + aClassName;
25 | }
26 | });
27 | element.className = setClass.trim();
28 | };
29 |
30 | module.exports.removeClass = function(element, value) {
31 | // Based off of jQuery 1.5.0
32 | if (element.nodeType !== 1 || !element.className) {
33 | return;
34 | }
35 | var className = (' ' + element.className + ' ').replace(/[\n\t\r]/g, ' ');
36 |
37 | (value || '').split(/\s+/).forEach(function(aClassName) {
38 | className = className.replace(' ' + aClassName + ' ', ' ');
39 | });
40 |
41 | element.className = className.trim();
42 | };
43 |
44 | module.exports.massageUrl = function(url) {
45 | if (!url) {
46 | return null;
47 | }
48 | if (url === '#') {
49 | return null;
50 | }
51 | if (url.match(/^javascript:.*/)) {
52 | return null;
53 | }
54 | var a = document.createElement('a');
55 | a.href = url;
56 | url = a.href;
57 | a.href = '';
58 | if (a.remove) {
59 | a.remove();
60 | }
61 | if (url === document.URL + '#') {
62 | return null;
63 | }
64 | return url;
65 | };
66 |
67 | module.exports.imageLoaded = function(src) {
68 | return new Promise(function(resolve, reject) {
69 | var img = new Image();
70 | img.onload = resolve;
71 | img.onerror = reject;
72 | img.src = src;
73 | });
74 | };
75 |
--------------------------------------------------------------------------------
/components/ContentFooter/ContentFooter.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var TimeSince = require('../TimeSince/TimeSince');
5 | var browser = require('../../extension/browser');
6 | var config = require('../../integrations/config');
7 | var format = require('../../utils/format');
8 | var styles = require('../meta.styles');
9 | var urls = require('../../integrations/urls');
10 |
11 | module.exports = React.createClass({
12 | displayName: 'ContentFooter',
13 | propTypes: {
14 | className: React.PropTypes.string,
15 | content: React.PropTypes.object.isRequired
16 | },
17 | render: function() {
18 | return (
19 |
20 |
21 |
22 |
23 | {
24 | this.props.content.stats && config.integrations[this.props.content.api].content.stats.map(function(stat) {
25 | if (this.props.content.stats[stat] === undefined || this.props.content.stats[stat] === null) {
26 | return null;
27 | }
28 | var number = stat.match(/_ratio$/) ?
29 | parseInt(this.props.content.stats[stat] * 100, 10) + '%' :
30 | format.number(this.props.content.stats[stat]);
31 | return {number} {browser.i18n.getMessage(stat + '_of_' + this.props.content.api) || browser.i18n.getMessage(stat)};
32 | }.bind(this))
33 | }
34 |
35 |
36 |
37 | {
38 | this.props.content.date &&
39 |
40 |
41 |
42 | }
43 |
44 |
45 |
46 | );
47 | }
48 | });
49 |
--------------------------------------------------------------------------------
/components/TimeSince/TimeSince.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 |
4 | var browser = require('../../extension/browser');
5 |
6 | var units = [
7 | { copy: 'x_seconds', value: -1 },
8 | { copy: 'x_seconds', value: 1000 },
9 | { copy: 'x_minutes', value: 1000 * 60 },
10 | { copy: 'x_hours', value: 1000 * 60 * 60 },
11 | { copy: 'x_days', value: 1000 * 60 * 60 * 24 },
12 | { copy: 'x_months', value: 1000 * 60 * 60 * 24 * 31 },
13 | { copy: 'x_years', value: 1000 * 60 * 60 * 24 * 31 * 12 }
14 | ];
15 |
16 | module.exports = React.createClass({
17 | displayName: 'TimeSince',
18 | propTypes: {
19 | date: React.PropTypes.number.isRequired
20 | },
21 | getInitialState: function() {
22 | return { timesince: '' };
23 | },
24 | componentDidMount: function() {
25 | this.componentWillReceiveProps();
26 | },
27 | componentWillReceiveProps: function(nextProps) {
28 | clearTimeout(this.timeout);
29 | var date = new Date((nextProps || this.props).date);
30 |
31 | var now = new Date();
32 | var now_with_date_time = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()));
33 | // This difference pretends that months are exactly 31 days.
34 | var difference = now - now_with_date_time + (1000 * 60 * 60 * 24 * (now.getUTCDate() - date.getUTCDate() + (31 * (now.getUTCMonth() - date.getUTCMonth() + (12 * (now.getUTCFullYear() - date.getUTCFullYear()))))));
35 | var unit = units[_.sortedIndex(units, { value: difference }, 'value') - 1];
36 | var value = Math.floor(difference / unit.value);
37 |
38 | this.setState({ timesince: browser.i18n.getMessage(unit.copy, [value]) });
39 | this.timeout = setTimeout(this.componentWillReceiveProps.bind(this), date + ((value + 1) * unit.value) - now);
40 | },
41 | componentWillUnmount: function() {
42 | clearTimeout(this.timeout);
43 | },
44 | render: function() {
45 | return {this.state.timesince};
46 | }
47 | });
48 |
--------------------------------------------------------------------------------
/components/AccountHeader/AccountHeader.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var dom = require('../../utils/dom');
5 | var styles = require('./AccountHeader.styles');
6 |
7 | module.exports = React.createClass({
8 | displayName: 'AccountHeader',
9 | propTypes: {
10 | account: React.PropTypes.object.isRequired,
11 | className: React.PropTypes.string
12 | },
13 | componentDidMount: function() {
14 | if (this.props.account.content && this.props.account.content.content) {
15 | Promise
16 | .all(this.props.account.content.content.slice(0, 3).map(function(content) {
17 | return dom.imageLoaded(content.image.medium || content.image.large || content.image.small);
18 | }))
19 | .then(this.props.onResize);
20 | } else if (this.props.account.banner) {
21 | dom.imageLoaded(this.props.account.banner).then(this.props.onResize);
22 | }
23 | },
24 | componentDidUpdate: function() {
25 | if (this.props.account.content && this.props.account.content.content) {
26 | Promise
27 | .all(this.props.account.content.content.slice(0, 3).map(function(content) {
28 | return dom.imageLoaded(content.image.medium || content.image.large || content.image.small);
29 | }))
30 | .then(this.props.onResize);
31 | } else if (this.props.account.banner) {
32 | dom.imageLoaded(this.props.account.banner).then(this.props.onResize);
33 | }
34 | },
35 | render: function() {
36 | if (this.props.account.content && this.props.account.content.content) {
37 | return (
38 |
39 | {this.props.account.content.content.slice(0, 3).map(function(content, i) {
40 | return
;
41 | })}
42 |
43 | );
44 | }
45 | if (this.props.account.banner) {
46 | return ;
47 | }
48 | return null;
49 | }
50 | });
51 |
52 |
--------------------------------------------------------------------------------
/integrations/urls/index.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-expressions */
2 | var chai = require('chai');
3 | var sinon = require('sinon');
4 | var sinonChai = require('sinon-chai');
5 | var expect = chai.expect;
6 | chai.use(sinonChai);
7 |
8 | describe('urls', function() {
9 | var sandbox;
10 | var urls;
11 |
12 | beforeEach(function() {
13 | sandbox = sinon.sandbox.create();
14 | });
15 |
16 | afterEach(function() {
17 | sandbox.restore();
18 | });
19 |
20 | describe('.parse', function() {
21 | it('should call the correct api\'s urls.parse', function() {
22 | var youtube_urls = require('../youtube/urls');
23 | sandbox.stub(youtube_urls, 'parse');
24 | urls = require('.');
25 |
26 | var url_string = 'https://www.youtube.com';
27 | urls.parse(url_string);
28 | expect(youtube_urls.parse).to.have.been.calledWith(require('url').parse(url_string, true, true));
29 | });
30 |
31 | it('should parse through l.facebook.com links', function() {
32 | urls = require('.');
33 | sandbox.spy(urls, 'parse');
34 |
35 | var url_string = 'https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DB1rnO_l46zM&h=NAQFdrek9';
36 | urls.parse(url_string);
37 | expect(urls.parse).to.have.been.calledWith('https://www.youtube.com/watch?v=B1rnO_l46zM');
38 | });
39 | });
40 |
41 | describe('.print', function() {
42 | it('should call the correct api\'s urls.represent()[0]', function() {
43 | var youtube_urls = require('../youtube/urls');
44 | var identity = { api: 'youtube' };
45 | sandbox.stub(youtube_urls, 'represent');
46 | youtube_urls.represent.withArgs(identity).returns(['test', 'test2']);
47 | urls = require('.');
48 |
49 | expect(urls.print(identity)).to.equal('test');
50 | });
51 | });
52 |
53 | describe('.represent', function() {
54 | it('should call the correct api\'s urls.represent()', function() {
55 | var youtube_urls = require('../youtube/urls');
56 | var identity = { api: 'youtube' };
57 | sandbox.stub(youtube_urls, 'represent');
58 | youtube_urls.represent.withArgs(identity).returns(['test', 'test2']);
59 | urls = require('.');
60 |
61 | expect(urls.represent(identity)).to.eql(['test', 'test2']);
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/components/IntegrationOptions/IntegrationOptions.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 | var classnames = require('classnames');
4 |
5 | var browser = require('../../extension/browser');
6 | var report = require('../../report');
7 | var styles = require('./IntegrationOptions.styles');
8 |
9 | var requireLogo = require.context('../../assets/images', false, /-icon-full_color.png$/);
10 |
11 | module.exports = React.createClass({
12 | displayName: 'IntegrationOptions',
13 | propTypes: {
14 | className: React.PropTypes.string,
15 | integration: React.PropTypes.string.isRequired,
16 | setOption: React.PropTypes.func.isRequired,
17 | options: React.PropTypes.object.isRequired
18 | },
19 | onChange: function(type) {
20 | this.props.setOption({ option: this.props.integration + '.' + type + '.enabled', value: !this.props.options[type].enabled })
21 | .catch(report.catchException);
22 | },
23 | render: function() {
24 | return (
25 |
26 |
27 |
34 |
35 |
36 |
43 |
44 |
45 | );
46 | }
47 | });
48 |
--------------------------------------------------------------------------------
/components/Carousel/Carousel.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 | var classnames = require('classnames');
4 |
5 | var styles = require('./Carousel.styles');
6 |
7 | module.exports = React.createClass({
8 | displayName: 'Carousel',
9 | propTypes: {
10 | children: React.PropTypes.arrayOf(React.PropTypes.node).isRequired,
11 | className: React.PropTypes.string,
12 | onChange: React.PropTypes.func.isRequired,
13 | onResize: React.PropTypes.func.isRequired
14 | },
15 | getInitialState: function() {
16 | return { index: 0 };
17 | },
18 | componentDidMount: function() {
19 | window.addEventListener('keydown', this.onWindowKeyDown);
20 | },
21 | componentWillUnmount: function() {
22 | window.addEventListener('keydown', this.onWindowKeyDown);
23 | },
24 | next: function(how) {
25 | var index = Math.min(this.props.children.length - 1, this.state.index + 1);
26 | this.setState({ index: index }, this.props.onResize);
27 | this.props.onChange(index, how);
28 | },
29 | previous: function(how) {
30 | var index = Math.max(0, this.state.index - 1);
31 | this.setState({ index: index }, this.props.onResize);
32 | this.props.onChange(index, how);
33 | },
34 | onWindowKeyDown: function(e) {
35 | switch (e.keyCode) {
36 | case 37:
37 | case 72:
38 | if (this.state.index === 0) {
39 | break;
40 | }
41 | this.previous('keydown');
42 | break;
43 | case 39:
44 | case 76:
45 | if (this.state.index === this.props.children.length - 1) {
46 | break;
47 | }
48 | this.next('keydown');
49 | break;
50 | default:
51 | break;
52 | }
53 | },
54 | render: function() {
55 | if (this.props.children.length === 0) {
56 | return null;
57 | }
58 | return (
59 |
60 | {
61 | (this.props.children.length > 1) &&
62 |
63 | {(this.state.index > 0) &&
}
64 | {(this.state.index < this.props.children.length - 1) &&
}
65 |
66 | }
67 | {this.props.children[this.state.index]}
68 |
69 | );
70 | }
71 | });
72 |
--------------------------------------------------------------------------------
/extension/browser.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var errors = require('feathers-errors');
3 | var isFSA = require('flux-standard-action').isFSA;
4 | var promisify = require('es6-promisify');
5 |
6 | var chrome = global.chrome;
7 |
8 | function makeFSA(action) {
9 | if (chrome.runtime.lastError) {
10 | return Promise.reject(chrome.runtime.lastError);
11 | }
12 | if (!isFSA(action) || !action.error || _.isError(action.payload)) {
13 | return Promise.resolve(action);
14 | }
15 | if (action.payload.type === 'FeathersError') {
16 | return Promise.resolve(Object.assign(
17 | {},
18 | action,
19 | { payload: Object.assign(
20 | new errors.FeathersError(
21 | action.payload.message,
22 | action.payload.name,
23 | action.payload.code,
24 | action.payload.className,
25 | action.payload.data
26 | ),
27 | { errors: action.payload.errors, request: action.payload.request }
28 | ) }
29 | ));
30 | }
31 | return Promise.resolve(Object.assign({}, action, { payload: new Error(action.payload.message) }));
32 | }
33 |
34 | [
35 | { obj: chrome.identity, method: 'launchWebAuthFlow' },
36 | { obj: chrome.runtime, method: 'sendMessage' },
37 | { obj: chrome.runtime, method: 'setUninstallURL' },
38 | { obj: chrome.storage.local, method: 'clear' },
39 | { obj: chrome.storage.local, method: 'get' },
40 | { obj: chrome.storage.local, method: 'getBytesInUse' },
41 | { obj: chrome.storage.local, method: 'remove' },
42 | { obj: chrome.storage.local, method: 'set' },
43 | { obj: chrome.storage.sync, method: 'clear' },
44 | { obj: chrome.storage.sync, method: 'get' },
45 | { obj: chrome.storage.sync, method: 'getBytesInUse' },
46 | { obj: chrome.storage.sync, method: 'remove' },
47 | { obj: chrome.storage.sync, method: 'set' },
48 | { obj: chrome.tabs, method: 'sendMessage' }
49 | ].forEach(function(wrapIt) {
50 | if (!wrapIt.obj || !wrapIt.obj[wrapIt.method]) {
51 | return;
52 | }
53 | wrapIt.obj[wrapIt.method] = _.wrap(wrapIt.obj[wrapIt.method], function(func) {
54 | return promisify(func).apply(this, _.rest(arguments))
55 | .then(makeFSA, makeFSA);
56 | });
57 | });
58 |
59 | chrome.runtime.onMessage.addListener = _.wrap(chrome.runtime.onMessage.addListener, function(addListener, listener) {
60 | return addListener.bind(chrome.runtime.onMessage)(function(action, sender, sendResponse) {
61 | return listener(makeFSA(action), sender, sendResponse);
62 | });
63 | });
64 |
65 | module.exports = chrome;
66 |
--------------------------------------------------------------------------------
/components/Err/Err.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 | var connect = require('react-redux').connect;
4 |
5 | var actions = require('../../redux/actions.top-frame');
6 | var browser = require('../../extension/browser');
7 | var report = require('../../report');
8 | var styles = require('./Err.styles');
9 |
10 | module.exports = connect(null, actions)(React.createClass({
11 | displayName: 'Err',
12 | propTypes: {
13 | authenticate: React.PropTypes.func.isRequired,
14 | className: React.PropTypes.string,
15 | error: React.PropTypes.object.isRequired,
16 | getEntity: React.PropTypes.func.isRequired
17 | },
18 | onClickCTA: function() {
19 | return this.props.authenticate({ api: this.props.error.request && this.props.error.request.api })
20 | .then(function() {
21 | if (!this.isMounted()) {
22 | // HACK isMounted is an anti-pattern https://facebook.github.io/react/blog/2015/12/16/ismounted-antipattern.html
23 | return null;
24 | }
25 | return this.props.getEntity(this.props.error.request);
26 | }.bind(this))
27 | .catch(report.catchException);
28 | },
29 | render: function() {
30 | var integration = this.props.error.request && this.props.error.request.api;
31 | var cta = (
32 | browser.i18n.getMessage('err_' + (this.props.error.code || 500) + '_cta_of_' + integration) ||
33 | browser.i18n.getMessage('err_' + (this.props.error.code || 500) + '_cta')
34 | );
35 |
36 | return (
37 |
38 |
39 |
40 |
{
41 | browser.i18n.getMessage('err_' + (this.props.error.code || 500) + '_name_of_' + integration, [browser.i18n.getMessage('name_of_' + integration)]) ||
42 | browser.i18n.getMessage('err_' + (this.props.error.code || 500) + '_name', [browser.i18n.getMessage('name_of_' + integration)])
43 | }
44 |
{
45 | browser.i18n.getMessage('err_' + (this.props.error.code || 500) + '_text_of_' + integration, [browser.i18n.getMessage('name_of_' + integration)]) ||
46 | browser.i18n.getMessage('err_' + (this.props.error.code || 500) + '_text', [browser.i18n.getMessage('name_of_' + integration)])
47 | }
48 | {cta &&
{cta}}
49 |
50 |
51 | );
52 | }
53 | }));
54 |
--------------------------------------------------------------------------------
/components/no-formatting.styles.css:
--------------------------------------------------------------------------------
1 | .format {
2 | br {
3 | display: none;
4 | }
5 |
6 | * {
7 | font-size: 14px;
8 | }
9 |
10 | del {
11 | text-decoration: line-through;
12 | }
13 |
14 | blockquote,
15 | h1,
16 | h2,
17 | h3,
18 | li,
19 | ul {
20 | display: block; /* no important */
21 | }
22 |
23 | table {
24 | display: table;
25 | border-collapse: collapse;
26 | border-spacing: 0;
27 | empty-cells: show;
28 | border: 1px solid #cbcbcb;
29 | }
30 |
31 | th,
32 | td {
33 | display: table-cell;
34 | border-left: 1px solid #cbcbcb;
35 | border-width: 0 0 0 1px;
36 | margin: 0;
37 | overflow: visible;
38 | padding: .5em 1em;
39 | }
40 |
41 | th:first-of-type {
42 | border-left-width: 0;
43 | }
44 |
45 | thead {
46 | display: table-header-group;
47 | border-color: inherit;
48 | background-color: rgba(0, 0, 0, .1);
49 | color: #000;
50 | text-align: left;
51 | vertical-align: bottom;
52 | }
53 |
54 | tbody {
55 | display: table-row-group;
56 | vertical-align: middle;
57 | border-color: inherit;
58 | }
59 |
60 | tr {
61 | display: table-row;
62 | vertical-align: inherit;
63 | border-color: inherit;
64 | }
65 |
66 | h1,
67 | h2,
68 | h3,
69 | h4,
70 | h5,
71 | h6 {
72 | display: block;
73 | font-weight: 600;
74 | }
75 |
76 | h1 {
77 | font-size: 21px;
78 | }
79 |
80 | h2 {
81 | font-size: 18px;
82 | }
83 |
84 | h3 {
85 | font-size: 16px;
86 | }
87 |
88 | sup {
89 | color: rgba(0, 0, 0, .6) !important;
90 | vertical-align: super;
91 | font-size: 12px;
92 | }
93 |
94 | blockquote,
95 | pre {
96 | color: rgba(0, 0, 0, .6);
97 | background: rgba(0, 0, 0, .05);
98 | padding: 6px;
99 | display: block;
100 | border-left: 3px solid rgba(0, 0, 0, .1);
101 |
102 | * {
103 | color: rgba(0, 0, 0, .6);
104 | }
105 | }
106 |
107 | ul {
108 | padding-left: 15px;
109 | display: block;
110 |
111 | li {
112 | color: rgba(0, 0, 0, .6);
113 | display: list-item;
114 | text-align: -webkit-match-parent;
115 | }
116 | }
117 |
118 | p {
119 | display: block;
120 | color: rgba(0, 0, 0, .6);
121 | }
122 |
123 | * {
124 | margin: 10px 0;
125 | }
126 |
127 | blockquote * {
128 | margin: 0;
129 | }
130 |
131 | strong {
132 | color: rgba(0, 0, 0, .6);
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/redux/options.reducer.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var createAction = require('redux-actions').createAction;
3 | var handleActions = require('redux-actions').handleActions;
4 |
5 | var browser = require('../extension/browser');
6 | var config = require('../extension/config');
7 | var report = require('../report');
8 |
9 | module.exports = handleActions(
10 | {
11 | SET_OPTION: {
12 | next: function(state, action) {
13 | var keys = action.payload.option.split('.');
14 | var newState = _.clone(state);
15 | var obj = newState;
16 | for (var i = 0; i < keys.length - 1; i++) {
17 | if (obj[keys[i]] === undefined) {
18 | return state;
19 | }
20 | obj[keys[i]] = _.clone(obj[keys[i]]);
21 | obj = obj[keys[i]];
22 | }
23 | if (obj === undefined) {
24 | return state;
25 | }
26 | obj[keys[keys.length - 1]] = action.payload.value;
27 | browser.storage.sync.set({ ['options.' + action.payload.option]: action.payload.value })
28 | .catch(report.captureException);
29 | return newState;
30 | }
31 | }
32 | },
33 | _.omit(config.options, 'keys')
34 | );
35 |
36 | module.exports.attachStore = function(store) {
37 | browser.storage.sync.get(null).then(function(items) {
38 | if (items.disabled) {
39 | // Migrate disabled.API.TYPE to !options.API.TYPE.enabled
40 | ['imgur', 'instagram', 'reddit', 'soundcloud', 'twitter', 'youtube'].forEach(function(integration) {
41 | if (!items.disabled[integration]) {
42 | return;
43 | }
44 | ['content', 'account'].forEach(function(type) {
45 | if (!items.disabled[integration][type]) {
46 | return;
47 | }
48 | store.dispatch(createAction('SET_OPTION')({ option: [integration, type, 'enabled'].join('.'), value: !items.disabled[integration][type] }));
49 | });
50 | });
51 | browser.storage.sync.remove('disabled')
52 | .catch(report.captureException);
53 | }
54 | _.pairs(items).forEach(function(entry) {
55 | var key = entry[0].match(/^options\.(.+)/);
56 | if (!key) {
57 | return;
58 | }
59 | store.dispatch(createAction('SET_OPTION')({ option: key[1], value: entry[1] }));
60 | });
61 | });
62 |
63 | browser.storage.onChanged.addListener(function(changes, areaName) {
64 | if (areaName !== 'sync') {
65 | return;
66 | }
67 | _.pairs(changes).forEach(function(entry) {
68 | var key = entry[0].match(/^options\.(.+)/);
69 | if (!key) {
70 | return;
71 | }
72 | store.dispatch(createAction('SET_OPTION')({ option: key[1], value: entry[1].newValue }));
73 | });
74 | });
75 | };
76 |
--------------------------------------------------------------------------------
/integrations/index.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var promisify = require('es6-promisify');
3 |
4 | var config = require('./config');
5 | var redis = require('../server/redis');
6 |
7 | var promises = {};
8 |
9 | module.exports = _.chain(config.integrations)
10 | .pick(function(integrationConfig) {
11 | return (integrationConfig.environment || 'server') === 'server' || integrationConfig.authenticated_environment === 'server';
12 | })
13 | .mapObject(function(integrationConfig, integration) {
14 | // HACK This only applied to twitter
15 | return require('../integrations/' + integration)({
16 | secret_storage: {
17 | del: function(token) {
18 | return promisify(redis.del.bind(redis))('auth:twitter:' + token);
19 | },
20 | get: function(token) {
21 | return promisify(redis.get.bind(redis))('auth:twitter:' + token);
22 | }
23 | }
24 | });
25 | })
26 | .mapObject(process.env.NODE_ENV === 'production' ?
27 | function(api, integration) { // This is the ONLY proper use of the word "api" so far
28 | return Object.assign({}, api, { model: _.mapObject(api.model, function(func, name) {
29 | return function(args, args_not_cached, usage) {
30 | if (_.result(args_not_cached, 'user')) {
31 | // The server-side cache (ie redis) helps us use the same work we've done
32 | // for other users who want the same thing. An authenticated request needs
33 | // work that is specific to that user's view, so this doesn't apply there.
34 | // The user will have the request cached on the client-side, in those cases.
35 | return func(args, args_not_cached, usage);
36 | }
37 | var key = _.chain(['cache', integration, name])
38 | .union(_.map(args, function(val, key) {
39 | return key + ':' + JSON.stringify(val);
40 | }))
41 | .join('::')
42 | .value();
43 |
44 | promises[key] = promises[key] || promisify(redis.get.bind(redis))(key)
45 | .then(function(result) {
46 | delete promises[key];
47 | return result ? JSON.parse(result) : Promise.reject();
48 | })
49 | .catch(function() {
50 | delete promises[key];
51 | return func(args, args_not_cached, usage);
52 | });
53 |
54 | // Whether the promise was set before or not, reset the expiration date
55 | promises[key]
56 | .then(function(result) {
57 | return promisify(redis.setex.bind(redis))(key, (config.integrations[integration].cache_length || 5 * 60 * 1000) / 1000, JSON.stringify(result));
58 | });
59 |
60 | return promises[key];
61 | };
62 | }) });
63 | } :
64 | _.identity
65 | )
66 | .value();
67 |
68 |
--------------------------------------------------------------------------------
/integrations/twitter/urls.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 |
3 | var urls = {};
4 |
5 | urls.hostnames_parsed = ['twitter.com', 'www.twitter.com', 'm.twitter.com', 'mobile.twitter.com'];
6 |
7 | // FROM: https://dev.twitter.com/rest/reference/get/help/configuration
8 | urls.non_username_regex = /^(?:about|accounts?|activity|all|announcements|anywhere|api(?:_?rules|_terms)|apps|auth|badges|blog|business|buttons|contacts|devices|direct_messages|downloads?|edit_announcements|faq|favorites|find_(?:sources|users)|follow(?:ers|ing)|friend(?:s|_?request)|goodies|hashtag|help|home|i|im_account|inbox|invitations|invite|jobs|list|log(?:in|o|out)|me|media_signup|mentions|messages|mockview|newtwitter|notifications|nudge|oauth|phoenix_search|positions|privacy|public_timeline|related_tweets|replies|retweet(?:ed_of_mine|s|s_by_others)|rules|saved_searches|search|sent|sessions|settings|share|sign(?:in|up)|similar_to|statistics|terms|tos|translate|trends|tweetbutton|twttr|update_discoverability|users|welcome|who_to_follow|widgets|zendesk_auth)$/;
9 |
10 | urls.parse = function(url_obj) {
11 | var path_parts = url_obj.pathname.replace(/^\//, '').replace(/\/$/, '').split('/') || [];
12 | if (path_parts[0] === 'intent') {
13 | switch (path_parts[1]) {
14 | case 'tweet':
15 | return !_.isEmpty(url_obj.query.in_reply_to) && { api: 'twitter', type: 'content', id: url_obj.query.in_reply_to };
16 | case 'favorite':
17 | case 'retweet':
18 | return !_.isEmpty(url_obj.query.tweet_id) && { api: 'twitter', type: 'content', id: url_obj.query.tweet_id };
19 | case 'follow':
20 | case 'user':
21 | return !_.isEmpty(url_obj.query.screen_name) && { api: 'twitter', type: 'account', id: url_obj.query.screen_name };
22 | default:
23 | return;
24 | }
25 | }
26 | if (path_parts.length === 1 && _.isEmpty(path_parts[0]) && (url_obj.hash || '').indexOf('#!') === 0) {
27 | path_parts = url_obj.hash.replace(/^#!\//, '').replace(/\/$/, '').split('/') || [];
28 | }
29 | if (_.isEmpty(path_parts[0]) || path_parts[0].match(urls.non_username_regex)) {
30 | return;
31 | }
32 | var account = { api: 'twitter', type: 'account', id: path_parts[0] };
33 | if (_.isEmpty(path_parts[1]) || !path_parts[1].match(/^status(?:es)?$/)) {
34 | return account;
35 | }
36 | return !_.isEmpty(path_parts[2]) && { api: 'twitter', type: 'content', id: path_parts[2], account: account };
37 | };
38 |
39 | urls.represent = function(identity) {
40 | switch (identity.type) {
41 | case 'content':
42 | return ['https://twitter.com/' + (_.result(identity.account, 'id') || 'screen_name') + '/status/' + identity.id];
43 | case 'account':
44 | return ['https://twitter.com/' + identity.id];
45 | }
46 | };
47 |
48 | module.exports = urls;
49 |
--------------------------------------------------------------------------------
/components/ContentHeader/ContentHeader.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 | var classnames = require('classnames');
4 | var connect = require('react-redux').connect;
5 |
6 | var browser = require('../../extension/browser');
7 | var actions = require('../../redux/actions.top-frame');
8 | var config = require('../../integrations/config');
9 | var entityLabel = require('../../utils/entity-label');
10 | var report = require('../../report');
11 | var styles = require('./ContentHeader.styles');
12 | var urls = require('../../integrations/urls');
13 |
14 | module.exports = connect(null, actions)(React.createClass({
15 | displayName: 'ContentHeader',
16 | propTypes: {
17 | analytics: React.PropTypes.func.isRequired,
18 | className: React.PropTypes.string,
19 | content: React.PropTypes.object.isRequired
20 | },
21 | onShare: function(network, e) {
22 | e.stopPropagation();
23 | this.props.analytics(['send', 'event', entityLabel(this.props.content, true), 'Shared', network])
24 | .catch(report.catchException);
25 | },
26 | render: function() {
27 | var accountImage = (
28 | !config.integrations[this.props.content.api].account.noImage &&
29 | this.props.content.account &&
30 | this.props.content.account.image &&
31 | (
32 | this.props.content.account.image.small ||
33 | this.props.content.account.image.medium ||
34 | this.props.content.account.image.large
35 | )
36 | );
37 | var accountName = (
38 | (
39 | this.props.content.account &&
40 | (
41 | this.props.content.account.name ||
42 | browser.i18n.getMessage('account_id_of_' + this.props.content.account.api, [this.props.content.account.id]) ||
43 | browser.i18n.getMessage('account_id', [this.props.content.account.id])
44 | )
45 | ) ||
46 | browser.i18n.getMessage('empty_account_id_of_' + ((this.props.content.account && this.props.content.account.api) || this.props.content.api))
47 | );
48 |
49 | return (
50 |
51 | {accountImage &&
}
52 |
55 |
56 |
57 |
58 | );
59 | }
60 | }));
61 |
--------------------------------------------------------------------------------
/integrations/youtube/urls.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var url = require('url');
3 |
4 | var urls = {};
5 |
6 | urls.hostnames_parsed = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be'];
7 |
8 | urls.parse = function(url_obj) {
9 | var path_parts = url_obj.pathname.replace(/^\//, '').replace(/\/$/, '').split('/') || [];
10 | switch (url_obj.hostname) {
11 | case 'youtube.com':
12 | case 'www.youtube.com':
13 | case 'm.youtube.com':
14 | switch (path_parts[0]) {
15 | case 'watch':
16 | return !_.isEmpty(url_obj.query.v) && _.extend({ api: 'youtube', type: 'content', id: url_obj.query.v.replace(/[?&].*/, '') }, url_obj.query.t && { meta: { time_offset: Number(url_obj.query.t) } });
17 | case 'embed':
18 | case 'v':
19 | return !_.isEmpty(path_parts[1]) && _.extend({ api: 'youtube', type: 'content', id: path_parts[1].replace(/[?&].*/, '') }, url_obj.query.start && { meta: { time_offset: Number(url_obj.query.start) } });
20 | case 'attribution_link':
21 | return !_.isEmpty(url_obj.query.u) && urls.parse(url.parse('https://youtube.com' + url_obj.query.u, true, true));
22 | case 'channel':
23 | return !_.isEmpty(path_parts[1]) && { api: 'youtube', type: 'account', id: path_parts[1].replace(/[?&].*/, '') };
24 | case 'user':
25 | return !_.isEmpty(path_parts[1]) && { api: 'youtube', type: 'account', id: path_parts[1].replace(/[?&].*/, ''), as: 'legacy_username' };
26 | case 'c':
27 | return !_.isEmpty(path_parts[1]) && { api: 'youtube', type: 'account', id: 'c/' + path_parts[1].replace(/[?&].*/, ''), as: 'custom_url' };
28 | default:
29 | return !_.isEmpty(path_parts[0]) && !path_parts[0].match(/^(?:account|channels|dashboard|feed|logout|playlist|signin|subscription_(?:center|manager)|t|testtube|upload|yt)$/) && { api: 'youtube', type: 'account', id: path_parts[0].replace(/[?&].*/, ''), as: 'custom_url' };
30 | }
31 | case 'youtu.be':
32 | return !_.isEmpty(path_parts[0]) && _.extend({ api: 'youtube', type: 'content', id: path_parts[0].replace(/[?&].*/, '') }, url_obj.query.t && { meta: { time_offset: Number(url_obj.query.t) } });
33 | default:
34 | return null;
35 | }
36 | };
37 |
38 | urls.represent = function(identity, comment) {
39 | switch (identity.type) {
40 | case 'content':
41 | return [
42 | 'https://www.youtube.com/watch?v=' + identity.id + (_.result(comment, 'id') ? '&lc=' + comment.id : ''),
43 | 'https://youtu.be/' + identity.id + (_.result(comment, 'id') ? '?lc=' + comment.id : ''),
44 | 'https://m.youtube.com/watch?v=' + identity.id + (_.result(comment, 'id') ? '&lc=' + comment.id : '')
45 | ];
46 | case 'account':
47 | switch (identity.as) {
48 | case 'custom_url':
49 | return ['https://www.youtube.com/' + identity.id];
50 | case 'legacy_username':
51 | return ['https://www.youtube.com/user/' + identity.id];
52 | default:
53 | return ['https://www.youtube.com/channel/' + identity.id];
54 | }
55 | default:
56 | return null;
57 | }
58 | };
59 |
60 | module.exports = urls;
61 |
--------------------------------------------------------------------------------
/redux/analytics.actions.background.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var promisify = require('es6-promisify');
3 |
4 | var browser = require('../extension/browser');
5 | var report = require('../report');
6 |
7 | var ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
8 |
9 | browser.storage.local.get('user_id')
10 | .then(_.property('user_id'))
11 | .then(function(user_id) {
12 | if (!user_id) {
13 | return;
14 | }
15 |
16 | return Promise.all([
17 | browser.storage.local.remove('user_id'),
18 | browser.storage.sync.set({ user_id: user_id })
19 | ]);
20 | })
21 | .catch(report.captureException);
22 |
23 | var getAnalytics;
24 |
25 | module.exports.analytics = function(request, meta, sender) {
26 | return function() {
27 | getAnalytics = getAnalytics || (
28 | process.env.GOOGLE_ANALYTICS_ID ?
29 | new Promise(function(resolve) {
30 | /* eslint-disable */
31 | (function(i, s, o, g, r, a, m) {i['GoogleAnalyticsObject'] = r;i[r] = i[r] || function() {
32 | (i[r].q = i[r].q || []).push(arguments);}, i[r].l = 1 * new Date();a = s.createElement(o),
33 | m = s.getElementsByTagName(o)[0];a.async = 1;a.src = g;m.parentNode.insertBefore(a, m);
34 | })(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
35 | /* eslint-enable */
36 |
37 | setTimeout(resolve);
38 | })
39 | .then(function() {
40 | return promisify(global.ga)();
41 | })
42 | .then(function() {
43 | return browser.storage.sync.get('user_id');
44 | })
45 | .then(_.property('user_id'))
46 | .then(function(user_id) {
47 | if (user_id) {
48 | return user_id;
49 | }
50 |
51 | user_id = _.times(25, _.partial(_.sample, ALPHANUMERIC, null)).join('');
52 | return browser.storage.sync.set({ user_id: user_id }).then(_.constant(user_id));
53 | })
54 | .then(function(user_id) {
55 | global.ga('create', process.env.GOOGLE_ANALYTICS_ID, { userId: user_id });
56 | global.ga('set', 'checkProtocolTask', _.noop);
57 | global.ga('set', { appName: browser.i18n.getMessage('app_name'), appVersion: browser.runtime.getManifest().version });
58 |
59 | return global.ga;
60 | }) :
61 | Promise.resolve(function() {
62 | var request = _.toArray(arguments);
63 | if (_.chain(request).first(2).isEqual(['send', 'exception']).value()) {
64 | console.error('google analytics', request);
65 | } else {
66 | console.debug('google analytics', request);
67 | }
68 | })
69 | );
70 |
71 | var last = _.last(request);
72 | if (!_.isObject(last) || _.isString(last) || _.isFunction(last)) {
73 | last = {};
74 | request.push(last);
75 | }
76 |
77 | var screenName = _.last(request).screenName || _.chain(sender).result('tab').result('url').value() || _.result(sender, 'url');
78 | if (screenName) {
79 | last.screenName = screenName;
80 | }
81 |
82 | return getAnalytics
83 | .then(function(ga) {
84 | ga.apply(this, request);
85 | });
86 | };
87 | };
88 |
--------------------------------------------------------------------------------
/redux/entities.actions.background.js:
--------------------------------------------------------------------------------
1 | var createAction = require('redux-actions').createAction;
2 |
3 | var analyticsActions = require('./analytics.actions.background');
4 | var browser = require('../extension/browser');
5 | var integrations = require('../integrations/index.background');
6 | var integrationsConfig = require('../integrations/config');
7 | var entityLabel = require('../utils/entity-label');
8 | var report = require('../report');
9 |
10 | var setTabEntity = createAction('setEntity', null, function(entity, label) {
11 | if (!label) {
12 | return null;
13 | }
14 | return { label: label };
15 | });
16 |
17 | var setEntity = createAction('SET_ENTITY', null, function(entity, label) {
18 | if (!label) {
19 | return null;
20 | }
21 | return { label: label };
22 | });
23 |
24 | var loading = {};
25 |
26 | module.exports.getEntity = function(request, meta, sender) {
27 | var start = Date.now();
28 |
29 | return function(dispatch, getState) {
30 | var label = entityLabel(request);
31 | var state = getState();
32 |
33 | var entity = state.entities[label];
34 |
35 | if (entity && entity.loaded && Date.now() - entity.loaded <= (integrationsConfig.integrations[request.api].cache_length || 5 * 60 * 1000)) {
36 | var newLabel = entityLabel(entity);
37 | if (newLabel !== label) {
38 | browser.tabs.sendMessage(sender.tab.id, setTabEntity(entity, label))
39 | .catch(report.captureException);
40 | }
41 | return Promise.resolve({ payload: entity });
42 | }
43 |
44 | if (!loading[label]) {
45 | loading[label] = integrations(request);
46 |
47 | loading[label]
48 | .then(function(entity) {
49 | delete loading[label];
50 | dispatch(setEntity(entity));
51 | var newLabel = entityLabel(entity);
52 | if (newLabel !== label) {
53 | dispatch(setEntity(entity, label));
54 | }
55 | dispatch(analyticsActions.analytics(['send', 'timing', entityLabel(entity, true), 'Loading', Date.now() - start], sender))
56 | .catch(report.captureException);
57 | })
58 | .catch(function(err) {
59 | delete loading[label];
60 | err.request = request;
61 | dispatch(setEntity(err));
62 | if (!err.code || err.code >= 500) {
63 | report.captureException(err);
64 | }
65 | });
66 | }
67 |
68 | if (sender.tab.id !== undefined) {
69 | loading[label]
70 | .then(function(entity) {
71 | browser.tabs.sendMessage(sender.tab.id, setTabEntity(entity))
72 | .catch(report.captureException);
73 | var newLabel = entityLabel(entity);
74 | if (newLabel !== label) {
75 | browser.tabs.sendMessage(sender.tab.id, setTabEntity(entity, label))
76 | .catch(report.captureException);
77 | }
78 | })
79 | .catch(function(err) {
80 | if (err.toJSON) {
81 | err = err.toJSON();
82 | }
83 | browser.tabs.sendMessage(sender.tab.id, { type: 'setEntity', payload: Object.assign(err, { type: 'FeathersError', request: request }), error: true })
84 | .catch(report.captureException);
85 | });
86 | }
87 |
88 | dispatch(setEntity(state.entities[label] || request));
89 | return Promise.resolve({ payload: state.entities[label] || request });
90 | };
91 | };
92 |
--------------------------------------------------------------------------------
/components/SoundCloudPlayer/SoundCloudPlayer.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 | var classnames = require('classnames');
4 | var errors = require('feathers-errors');
5 | var promisify = require('es6-promisify');
6 |
7 | var report = require('../../report');
8 | var styles = require('./SoundCloudPlayer.styles');
9 | var urls = require('../../integrations/urls');
10 |
11 | var SoundCloudPlayer = module.exports = React.createClass({
12 | displayName: 'SoundCloudPlayer',
13 | propTypes: {
14 | className: React.PropTypes.string,
15 | content: React.PropTypes.object.isRequired,
16 | image: React.PropTypes.object,
17 | meta: React.PropTypes.object.isRequired,
18 | muted: React.PropTypes.bool.isRequired,
19 | onLoad: React.PropTypes.func.isRequired
20 | },
21 | statics: {
22 | getSC: function() {
23 | if (window.SC) {
24 | report.catchException(new Error('window.SC should not exist'));
25 | return null;
26 | }
27 | SoundCloudPlayer.getSC = _.constant(
28 | fetch('https://w.soundcloud.com/player/api.js')
29 | .then(function(response) {
30 | if (!response.ok) {
31 | throw new errors.FeathersError('Youtube API won\'t load', 'FeathersError');
32 | }
33 | return response.text();
34 | })
35 | .then(function(text) {
36 | // HACK Eval-ing text we downloaded
37 | eval(text); // eslint-disable-line no-eval
38 | return window.SC;
39 | })
40 | );
41 | return SoundCloudPlayer.getSC();
42 | }
43 | },
44 | getInitialState: function() {
45 | return { player: null };
46 | },
47 | componentDidMount: function() {
48 | SoundCloudPlayer.getSC()
49 | .then(function(SC) {
50 | var player = SC.Widget(this.refs.player);
51 | player.bind(SC.Widget.Events.ERROR, report.catchException);
52 | if (this.props.meta.time_offset) {
53 | player.bind(SC.Widget.Events.PLAY, function() {
54 | player.seekTo(this.props.meta.time_offset * 1000);
55 | }.bind(this));
56 | }
57 | return promisify(player.bind.bind(player))(SC.Widget.Events.READY)
58 | .then(_.constant(player));
59 | }.bind(this))
60 | .then(function(player) {
61 | // HACK https://twitter.com/saiichihashi/status/763499483057393664
62 | player.play();
63 | this.setState({ player: player });
64 | }.bind(this))
65 | .catch(report.catchException);
66 | },
67 | componentDidUpdate: function() {
68 | if (!this.state || !this.state.player) {
69 | return;
70 | }
71 | this.state.player.setVolume(this.props.muted ? 0 : 1);
72 | },
73 | render: function() {
74 | // FIXME https://github.com/kogg/hovercards/issues/108
75 | return (
76 |
83 | );
84 | }
85 | });
86 |
--------------------------------------------------------------------------------
/integrations/instagram/urls.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-expressions */
2 | var chai = require('chai');
3 | var expect = chai.expect;
4 |
5 | describe('instagram urls', function() {
6 | var urls;
7 |
8 | beforeEach(function() {
9 | urls = require('./urls');
10 | });
11 |
12 | describe('.parse', function() {
13 | var url;
14 |
15 | beforeEach(function() {
16 | url = require('url');
17 | });
18 |
19 | describe('into content', function() {
20 | it('from instagram.com/p/CONTENT_ID', function() {
21 | expect(urls.parse(url.parse('https://www.instagram.com/p/CONTENT_ID', true, true)))
22 | .to.eql({ api: 'instagram', type: 'content', id: 'CONTENT_ID' });
23 | });
24 |
25 | it('from instagram.com/p/CONTENT_ID/embed/captioned', function() {
26 | expect(urls.parse(url.parse('https://www.instagram.com/p/CONTENT_ID/embed/captioned/?v=4', true, true)))
27 | .to.eql({ api: 'instagram', type: 'content', id: 'CONTENT_ID' });
28 | });
29 |
30 | it('from instagr.am/p/CONTENT_ID', function() {
31 | expect(urls.parse(url.parse('https://instagr.am/p/CONTENT_ID', true, true)))
32 | .to.eql({ api: 'instagram', type: 'content', id: 'CONTENT_ID' });
33 | });
34 |
35 | it('from instagr.am/p/CONTENT_ID/embed/captioned', function() {
36 | expect(urls.parse(url.parse('https://instagr.am/p/CONTENT_ID/embed/captioned/?v=4', true, true)))
37 | .to.eql({ api: 'instagram', type: 'content', id: 'CONTENT_ID' });
38 | });
39 | });
40 |
41 | describe('into account', function() {
42 | it('from instagram.com/ACCOUNT_ID', function() {
43 | expect(urls.parse(url.parse('https://www.instagram.com/ACCOUNT_ID', true, true)))
44 | .to.eql({ api: 'instagram', type: 'account', id: 'ACCOUNT_ID' });
45 | });
46 |
47 | it('from instagr.am/ACCOUNT_ID', function() {
48 | expect(urls.parse(url.parse('https://www.instagram.com/ACCOUNT_ID', true, true)))
49 | .to.eql({ api: 'instagram', type: 'account', id: 'ACCOUNT_ID' });
50 | });
51 |
52 | it('not from instagram.com/about', function() {
53 | expect(urls.parse(url.parse('https://www.instagram.com/about', true, true))).not.to.be.ok;
54 | });
55 |
56 | it('not from instagram.com/developer', function() {
57 | expect(urls.parse(url.parse('https://www.instagram.com/developer', true, true))).not.to.be.ok;
58 | });
59 |
60 | it('not from instagram.com/explore', function() {
61 | expect(urls.parse(url.parse('https://www.instagram.com/explore', true, true))).not.to.be.ok;
62 | });
63 |
64 | it('not from instagram.com/legal', function() {
65 | expect(urls.parse(url.parse('https://www.instagram.com/legal', true, true))).not.to.be.ok;
66 | });
67 |
68 | it('not from instagram.com/press', function() {
69 | expect(urls.parse(url.parse('https://www.instagram.com/press', true, true))).not.to.be.ok;
70 | });
71 | });
72 | });
73 |
74 | describe('.represent', function() {
75 | it('should represent content', function() {
76 | var representations = urls.represent({ type: 'content', id: 'CONTENT_ID' });
77 | expect(representations).to.contain('https://instagram.com/p/CONTENT_ID/');
78 | expect(representations).to.contain('https://instagr.am/p/CONTENT_ID/');
79 | });
80 |
81 | it('should represent account', function() {
82 | var representations = urls.represent({ type: 'account', id: 'ACCOUNT_ID' });
83 | expect(representations).to.contain('https://instagram.com/ACCOUNT_ID/');
84 | expect(representations).to.contain('https://instagr.am/ACCOUNT_ID/');
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/www/js/index.js:
--------------------------------------------------------------------------------
1 | var report = require('../../report');
2 | require('../css/index.css');
3 | var $ = require('jquery');
4 |
5 | /* global ga */
6 | $(function() {
7 | var ALL_TIMINGS = [
8 | [3000, 3000, 3000],
9 | [2700, 3500, 2600, 800],
10 | [4200, 700, 4200, 700]
11 | ];
12 |
13 | $('.image-holder').each(function(index) {
14 | var i = -1;
15 | var timings = ALL_TIMINGS[index];
16 | var image_holder = $(this);
17 | function cycle_slides() {
18 | image_holder.removeClass('hovercard-slide' + (index ? (index + 1) : '') + '-' + ((i % timings.length) + 1));
19 | i++;
20 | image_holder.addClass('hovercard-slide' + (index ? (index + 1) : '') + '-' + ((i % timings.length) + 1));
21 | setTimeout(cycle_slides, timings[i % timings.length]);
22 | }
23 | cycle_slides();
24 | });
25 |
26 | var screenheight;
27 | var moverpositions;
28 | var screenmovers = $('.screenmover');
29 | var screens = $('.screen');
30 | function scroll() {
31 | var scrollTop = $(window).scrollTop();
32 | screens.each(function(i) {
33 | $(this).toggleClass('active-screen', (scrollTop + screenheight > moverpositions[i]) && (!moverpositions[i + 1] || (scrollTop + screenheight <= moverpositions[i + 1])));
34 | });
35 | }
36 | function resize() {
37 | screenheight = $(window).height();
38 | moverpositions = screenmovers.map(function() {
39 | return $(this).offset().top;
40 | }).get();
41 | scroll();
42 | }
43 | resize();
44 | $(document).on('scroll', scroll);
45 | $(window).on('resize', resize);
46 |
47 | $('a[href="https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco"]').click(function() {
48 | ga('send', 'event', 'install link', 'click', $(this).parents('.controw').data('id'));
49 | });
50 | if (window.chrome && window.chrome.webstore && window.chrome.webstore.install) {
51 | $('a[href="https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco"]').click(function(e) {
52 | if (e.which === 3 || e.metaKey || e.ctrlKey) {
53 | return;
54 | }
55 | e.preventDefault();
56 | window.chrome.webstore.install('https://chrome.google.com/webstore/detail/dighmiipfpfdfbfmpodcmfdgkkcakbco',
57 | function() { },
58 | function(error) {
59 | report.captureException(error, {
60 | level: 'warning'
61 | });
62 | }
63 | );
64 | });
65 | }
66 | $('a[href*="#"]:not([href="#"])').click(function() {
67 | if (location.pathname.replace(/^\//, '') === this.pathname.replace(/^\//, '') && location.hostname === this.hostname) {
68 | var target = $(this.hash);
69 | target = target.length ? target : $('[name=' + this.hash.slice(1) + ']');
70 | if (target.length) {
71 | $('html, body').animate({
72 | scrollTop: target.offset().top
73 | }, 1000);
74 | return false;
75 | }
76 | }
77 | });
78 | });
79 |
80 | /*eslint-disable */
81 |
82 | /*
83 | * FACEBOOK STUFF
84 | */
85 | (function(d, s, id) {
86 | var js, fjs = d.getElementsByTagName(s)[0];
87 | if (d.getElementById(id)) return;
88 | js = d.createElement(s); js.id = id;
89 | js.src = '//connect.facebook.net/en_US/sdk.js#xfbml=1&version=v2.4';
90 | fjs.parentNode.insertBefore(js, fjs);
91 | }(document, 'script', 'facebook-jssdk'));
92 |
93 | /*
94 | * TWITTER STUFF
95 | */
96 | ! function(d, s, id) {var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location)?'http':'https'; if (!d.getElementById(id)) {js = d.createElement(s);js.id = id;js.src = p+'://platform.twitter.com/widgets.js';fjs.parentNode.insertBefore(js, fjs);}}(document, 'script', 'twitter-wjs');
97 |
98 | /*eslint-enable */
99 |
--------------------------------------------------------------------------------
/components/DiscussionComment/DiscussionComment.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 |
4 | var browser = require('../../extension/browser');
5 | var config = require('../../integrations/config');
6 | var format = require('../../utils/format');
7 | var styles = require('./DiscussionComment.styles.css');
8 | var urls = require('../../integrations/urls');
9 |
10 | var DiscussionComment = module.exports = React.createClass({
11 | displayName: 'DiscussionComment',
12 | propTypes: {
13 | className: React.PropTypes.string,
14 | comment: React.PropTypes.object.isRequired,
15 | integration: React.PropTypes.string.isRequired,
16 | onClickText: React.PropTypes.func.isRequired
17 | },
18 | render: function() {
19 | var accountImage = (
20 | !config.integrations[this.props.integration].account.noImage &&
21 | this.props.comment.account &&
22 | this.props.comment.account.image &&
23 | (
24 | this.props.comment.account.image.medium ||
25 | this.props.comment.account.image.large ||
26 | this.props.comment.account.image.small
27 | )
28 | );
29 | var accountName = (
30 | (
31 | this.props.comment.account &&
32 | (
33 | this.props.comment.account.name ||
34 | browser.i18n.getMessage('account_id_of_' + this.props.integration, [this.props.comment.account.id]) ||
35 | browser.i18n.getMessage('account_id', [this.props.comment.account.id])
36 | )
37 | ) ||
38 | browser.i18n.getMessage('empty_account_id_of_' + this.props.integration)
39 | );
40 |
41 | return (
42 |
43 |
44 | {
45 | !config.integrations[this.props.integration].account.noImage &&
46 |
49 | }
50 |
51 |
{accountName}
52 |
53 | {
54 | config.integrations[this.props.integration].discussion.comments &&
55 | config.integrations[this.props.integration].discussion.comments.stats &&
56 |
57 | {config.integrations[this.props.integration].discussion.comments.stats.map(function(stat) {
58 | if (this.props.comment.stats[stat] === undefined) {
59 | return null;
60 | }
61 | var number = format.number(this.props.comment.stats[stat]);
62 |
63 | return {number} {browser.i18n.getMessage(stat + '_of_' + this.props.integration) || browser.i18n.getMessage(stat)};
64 | }.bind(this))}
65 |
66 | }
67 |
68 |
69 | {
70 | this.props.comment.replies &&
71 | Boolean(this.props.comment.replies.length) &&
72 |
73 | {this.props.comment.replies && this.props.comment.replies.map(function(reply, i) {
74 | return ;
75 | }.bind(this))}
76 |
77 | }
78 |
79 | );
80 | }
81 | });
82 |
--------------------------------------------------------------------------------
/assets/images/soundcloud.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
48 |
--------------------------------------------------------------------------------
/components/AccountHovercard/AccountHovercard.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 | var connect = require('react-redux').connect;
4 |
5 | var AccountFooter = require('../AccountFooter/AccountFooter');
6 | var AccountHeader = require('../AccountHeader/AccountHeader');
7 | var Collapsable = require('../Collapsable/Collapsable');
8 | var Err = require('../Err/Err');
9 | var Loading = require('../Loading/Loading');
10 | var actions = require('../../redux/actions.top-frame');
11 | var browser = require('../../extension/browser');
12 | var config = require('../../integrations/config');
13 | var dom = require('../../utils/dom');
14 | var entityLabel = require('../../utils/entity-label');
15 | var report = require('../../report');
16 | var styles = require('./AccountHovercard.styles.css');
17 | var urls = require('../../integrations/urls');
18 |
19 | module.exports = connect(null, actions)(React.createClass({
20 | displayName: 'AccountHovercard',
21 | propTypes: {
22 | account: React.PropTypes.object.isRequired,
23 | analytics: React.PropTypes.func.isRequired,
24 | className: React.PropTypes.string,
25 | hovered: React.PropTypes.bool.isRequired,
26 | onResize: React.PropTypes.func.isRequired
27 | },
28 | componentDidMount: function() {
29 | if (!config.integrations[this.props.account.api].account.noImage && this.props.account.image) {
30 | dom.imageLoaded(this.props.account.image.medium || this.props.account.image.small || this.props.account.image.large)
31 | .then(this.props.onResize);
32 | }
33 | },
34 | componentDidUpdate: function() {
35 | if (!config.integrations[this.props.account.api].account.noImage && this.props.account.image) {
36 | dom.imageLoaded(this.props.account.image.medium || this.props.account.image.small || this.props.account.image.large)
37 | .then(this.props.onResize);
38 | }
39 | },
40 | onExpandDescription: function() {
41 | this.props.analytics(['send', 'event', entityLabel(this.props.account, true), 'Expanded description'])
42 | .catch(report.catchException);
43 | },
44 | render: function() {
45 | var className = classnames(styles.account, { [styles.noAccountImage]: config.integrations[this.props.account.api].account.noImage }, this.props.className);
46 |
47 | if (this.props.account.err) {
48 | return ;
49 | }
50 | if (!this.props.account.loaded) {
51 | return ;
52 | }
53 |
54 | return (
55 |
83 | );
84 | }
85 | }));
86 |
--------------------------------------------------------------------------------
/components/YoutubeVideo/YoutubeVideo.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var React = require('react');
3 | var classnames = require('classnames');
4 | var compose = require('redux').compose;
5 | var errors = require('feathers-errors');
6 | var promisify = require('es6-promisify');
7 |
8 | var report = require('../../report');
9 | var styles = require('./YoutubeVideo.styles');
10 |
11 | var YoutubeVideo = module.exports = React.createClass({
12 | displayName: 'YoutubeVideo',
13 | propTypes: {
14 | className: React.PropTypes.string,
15 | content: React.PropTypes.object.isRequired,
16 | image: React.PropTypes.object,
17 | meta: React.PropTypes.object.isRequired,
18 | muted: React.PropTypes.bool.isRequired,
19 | onLoad: React.PropTypes.func.isRequired
20 | },
21 | statics: {
22 | getYT: function() {
23 | if (window.YT) {
24 | report.catchException(new Error('window.YT should not exist'));
25 | return null;
26 | }
27 | window.YT = window.YT || { loading: 0, loaded: 0 };
28 | window.YTConfig = window.YTConfig || { host: 'http://www.youtube.com' };
29 | if (window.YT.loading) {
30 | report.catchException(new Error('window.YT.loading should not exist'));
31 | return null;
32 | }
33 | window.YT.loading = 1;
34 | var l = [];
35 | // Mostly copied from https://www.youtube.com/iframe_api
36 | /* eslint-disable */
37 | window.YT.ready = function(f) {if (window.YT.loaded) {f();} else {l.push(f);}};
38 | window.onYTReady = function() {window.YT.loaded = 1; for (var i = 0; i < l.length; i++) {try {l[i]();} catch (e) {}}};
39 | window.YT.setConfig = function(c) {for (var k in c) {if (c.hasOwnProperty(k)) {window.YTConfig[k] = c[k];}}};
40 | /* eslint-enable */
41 | YoutubeVideo.getYT = _.constant(
42 | Promise.all([
43 | // HACK This is a url from within https://www.youtube.com/iframe_api
44 | fetch('https://s.ytimg.com/yts/jsbin/www-widgetapi-vflwSZmGJ/www-widgetapi.js')
45 | .then(function(response) {
46 | if (!response.ok) {
47 | throw new errors.FeathersError('Youtube API won\'t load', 'FeathersError');
48 | }
49 | return response.text();
50 | })
51 | .then(function(text) {
52 | // HACK Eval-ing text we downloaded
53 | eval(text); // eslint-disable-line no-eval
54 | }),
55 | promisify(window.YT.ready.bind(window.YT))()
56 | ])
57 | .then(function() {
58 | return window.YT;
59 | })
60 | );
61 | return YoutubeVideo.getYT();
62 | }
63 | },
64 | getInitialState: function() {
65 | return { player: null };
66 | },
67 | componentDidMount: function() {
68 | YoutubeVideo.getYT()
69 | .then(function(YT) {
70 | return new Promise(function(resolve) {
71 | /* eslint-disable no-new */
72 | new YT.Player(this.refs.video, {
73 | events: {
74 | onReady: compose(resolve, _.property('target')),
75 | onError: report.catchException
76 | }
77 | });
78 | /* eslint-enable no-new */
79 | }.bind(this));
80 | }.bind(this))
81 | .then(function(player) {
82 | this.setState({ player: player });
83 | }.bind(this))
84 | .catch(report.catchException);
85 | },
86 | componentDidUpdate: function() {
87 | if (!this.state || !this.state.player) {
88 | return;
89 | }
90 | if (this.props.muted) {
91 | this.state.player.mute();
92 | } else {
93 | this.state.player.unMute();
94 | }
95 | },
96 | render: function() {
97 | return (
98 |
104 | );
105 | }
106 | });
107 |
--------------------------------------------------------------------------------
/webpack.config.www.js:
--------------------------------------------------------------------------------
1 | var BellOnBundlerErrorPlugin = require('bell-on-bundler-error-plugin');
2 | var CleanWebpackPlugin = require('clean-webpack-plugin');
3 | var CopyWebpackPlugin = require('copy-webpack-plugin');
4 | var ExtractTextPlugin = require('extract-text-webpack-plugin');
5 | var FaviconsWebpackPlugin = require('favicons-webpack-plugin');
6 | var HtmlWebpackPlugin = require('html-webpack-plugin');
7 | var autoprefixer = require('autoprefixer');
8 | var nested = require('postcss-nested');
9 | var webpack = require('webpack');
10 |
11 | module.exports = {
12 | entry: {
13 | index: './www/js/index',
14 | privacy: './www/js/privacy'
15 | },
16 | output: {
17 | path: 'dist-www',
18 | filename: '[name].[hash].js'
19 | },
20 | module: {
21 | loaders: [
22 | { exclude: 'node_modules', test: /\.(css)$/, loader: ExtractTextPlugin.extract('style', ['css?-autoprefixer&importLoaders=1&sourceMap', 'postcss']) },
23 | { exclude: 'node_modules', test: /\.(eot|ttf|(woff(2)?(\?v=\d\.\d\.\d)?))$/, loader: 'url?name=fonts/[name].[hash].[ext]&limit=10000' },
24 | { exclude: 'node_modules', test: /\.(gif|png|jpe?g|svg)$/i, loaders: ['url?name=images/[name].[hash].[ext]&limit=10000', 'image-webpack'] },
25 | { exclude: 'node_modules', test: /\.(js)$/, loader: 'babel?cacheDirectory' },
26 | { exclude: 'node_modules', test: /\.(json)/, loader: 'file?name=[name].[hash].[ext]' }
27 | ]
28 | },
29 | resolve: {
30 | extensions: extensions(
31 | [''],
32 | ['.www', '.browser', ''],
33 | ['.json', '.js', '.css']
34 | )
35 | },
36 | bail: process.env.NODE_ENV,
37 | devtool: process.env.NODE_ENV ? 'source-map' : 'cheap-source-map',
38 | devServer: {
39 | port: process.env.PORT,
40 | stats: {
41 | assets: true,
42 | cached: false, // Filters information from devServer.stats.modules
43 | cachedAssets: false, // Filters information from devServer.stats.assets
44 | children: true,
45 | chunks: false,
46 | colors: true,
47 | errorDetails: true,
48 | errors: true,
49 | hash: false,
50 | modules: true,
51 | publicPath: false,
52 | reasons: true,
53 | timings: true,
54 | version: false,
55 | warnings: false
56 | }
57 | },
58 | plugins: [
59 | new webpack.EnvironmentPlugin([
60 | 'CHROME_EXTENSION_ID'
61 | ]),
62 | new BellOnBundlerErrorPlugin(),
63 | new CleanWebpackPlugin(['dist-www']),
64 | new CopyWebpackPlugin([
65 | { from: 'assets/images/facebeefbanner.jpg', to: 'images' },
66 | { from: 'www/CNAME' }
67 | ]),
68 | new ExtractTextPlugin('[name].[hash].css'),
69 | new FaviconsWebpackPlugin({ logo: './assets/images/logo.png', title: 'HoverCards', prefix: 'favicons-[hash]/' }),
70 | new HtmlWebpackPlugin({
71 | title: 'HoverCards - More content. Fewer Tabs.',
72 | template: 'www/index.ejs',
73 | inject: false,
74 | chunks: ['index'],
75 | googleAnalytics: process.env.GOOGLE_ANALYTICS_ID && {
76 | trackingId: process.env.GOOGLE_ANALYTICS_ID,
77 | pageViewOnLoad: true
78 | },
79 | mobile: true
80 | }),
81 | new HtmlWebpackPlugin({
82 | title: 'HoverCards - Privacy Policy.',
83 | filename: 'privacy.html',
84 | template: 'www/privacy.ejs',
85 | inject: false,
86 | chunks: ['privacy'],
87 | googleAnalytics: process.env.GOOGLE_ANALYTICS_ID && {
88 | trackingId: process.env.GOOGLE_ANALYTICS_ID,
89 | pageViewOnLoad: true
90 | },
91 | mobile: true
92 | })
93 | ],
94 | postcss: function() {
95 | return [nested, autoprefixer];
96 | }
97 | };
98 |
99 | if (!process.env.NODE_ENV) {
100 | var DotenvPlugin = require('webpack-dotenv-plugin');
101 |
102 | module.exports.plugins = module.exports.plugins.concat([
103 | new DotenvPlugin()
104 | ]);
105 | }
106 |
107 | function extensions(injections, builds, extensions) {
108 | var results = [''];
109 |
110 | [].concat(injections).forEach(function(injection) {
111 | [].concat(builds).forEach(function(build) {
112 | [].concat(extensions).forEach(function(extension) {
113 | if (!injection && !build && !extension) {
114 | return;
115 | }
116 | results.push([injection, build, extension].join(''));
117 | });
118 | });
119 | });
120 | return results;
121 | }
122 |
--------------------------------------------------------------------------------
/components/Media/Media.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var classnames = require('classnames');
3 | var connect = require('react-redux').connect;
4 |
5 | var Carousel = require('../Carousel/Carousel');
6 | var Gif = require('../Gif/Gif');
7 | var Image = require('../Image/Image');
8 | var OEmbed = require('../OEmbed/OEmbed');
9 | var SoundCloudPlayer = require('../SoundCloudPlayer/SoundCloudPlayer');
10 | var Video = require('../Video/Video');
11 | var YoutubeVideo = require('../YoutubeVideo/YoutubeVideo');
12 | var actions = require('../../redux/actions.top-frame');
13 | var entityLabel = require('../../utils/entity-label');
14 | var report = require('../../report');
15 | var styles = require('./Media.styles');
16 |
17 | module.exports = connect(null, actions)(React.createClass({
18 | displayName: 'Media',
19 | propTypes: {
20 | analytics: React.PropTypes.func.isRequired,
21 | className: React.PropTypes.string,
22 | content: React.PropTypes.object.isRequired,
23 | hovered: React.PropTypes.bool.isRequired,
24 | meta: React.PropTypes.object.isRequired,
25 | onResize: React.PropTypes.func.isRequired
26 | },
27 | onCarouselChange: function(index, how) {
28 | this.props.analytics(['send', 'event', entityLabel(this.props.content, true), 'Carousel Changed', how, index])
29 | .catch(report.catchException);
30 | },
31 | render: function() {
32 | switch (this.props.content.api) {
33 | case 'imgur':
34 | if (!this.props.content.content) {
35 | break;
36 | }
37 | return (
38 |
39 |
40 | {this.props.content.content.map(function(item, i) {
41 | return (
42 |
43 | {
44 | item.gif ?
45 |
:
46 |
47 | }
48 |
49 |
{ item.name }
50 |
51 |
52 |
53 | );
54 | }.bind(this))}
55 |
56 |
57 | );
58 | case 'soundcloud':
59 | return (
60 |
61 |
62 |
63 | );
64 | case 'youtube':
65 | return (
66 |
67 |
68 |
69 | );
70 | default:
71 | break;
72 | }
73 | if (this.props.content.oembed) {
74 | return (
75 |
76 |
77 |
78 | );
79 | }
80 | if (this.props.content.video) {
81 | return (
82 |
83 |
84 |
85 | );
86 | }
87 | if (this.props.content.gif) {
88 | return (
89 |
90 |
91 |
92 | );
93 | }
94 | if (this.props.content.images) {
95 | return (
96 |
97 |
98 | {this.props.content.images.map(function(image, i) {
99 | return ;
100 | }.bind(this))}
101 |
102 |
103 | );
104 | }
105 | if (this.props.content.image) {
106 | return (
107 |
108 |
109 |
110 | );
111 | }
112 | return null;
113 | }
114 | }));
115 |
--------------------------------------------------------------------------------
/assets/images/reddit.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
51 |
--------------------------------------------------------------------------------
/server/v2.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var TwitterStrategy = require('passport-twitter').Strategy;
3 | var errors = require('feathers-errors');
4 | var feathers = require('feathers');
5 | var passport = require('passport');
6 | // var promisify = require('es6-promisify');
7 | var session = require('express-session');
8 | var RedisStore = require('connect-redis')(session);
9 |
10 | var config = require('../integrations/config');
11 | var integrations = require('../integrations');
12 | var redis = require('./redis');
13 |
14 | var CHROMIUM_IDS = process.env.CHROMIUM_IDS.split(';');
15 |
16 | // HACK This only applied to twitter
17 | passport.use(new TwitterStrategy({
18 | consumerKey: process.env.TWITTER_CONSUMER_KEY,
19 | consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
20 | callbackURL: '/v2/twitter/callback'
21 | }, function(token, tokenSecret, profile, callback) {
22 | redis.set('auth:twitter:' + token, tokenSecret, _.partial(callback, _, token));
23 | }));
24 |
25 | module.exports = feathers.Router()
26 | .use(session({
27 | store: new RedisStore({ client: redis }),
28 | secret: (process.env.SECURE_KEY || '').split(','),
29 | saveUninitialized: false,
30 | resave: false
31 | }))
32 | .use(passport.initialize())
33 | .use(passport.session())
34 |
35 | .param('integration', function(req, res, next, integration) {
36 | if (!integrations[integration]) {
37 | return next(new errors.NotFound());
38 | }
39 | next();
40 | })
41 |
42 | .get('/:integration/content/:id', function(req, res, next) {
43 | if (!integrations[req.params.integration].content) {
44 | return next(new errors.NotFound());
45 | }
46 | integrations[req.params.integration].content(Object.assign({}, req.headers, req.query, { id: req.params.id }))
47 | .then(function(result) {
48 | res.json(result);
49 | })
50 | .catch(next);
51 | })
52 | .get('/:integration/content/:id/discussion', function(req, res, next) {
53 | if (!integrations[req.params.integration].discussion) {
54 | return next(new errors.NotFound());
55 | }
56 | integrations[req.params.integration].discussion(Object.assign({}, req.headers, req.query, { id: req.params.id }))
57 | .then(function(result) {
58 | res.json(result);
59 | })
60 | .catch(next);
61 | })
62 | .get('/:for_integration/content/:for_id/discussion/:integration', function(req, res, next) {
63 | if (!integrations[req.params.integration].discussion) {
64 | return next(new errors.NotFound());
65 | }
66 | integrations[req.params.integration].discussion(Object.assign({}, req.headers, req.query, { for: { integration: req.params.for_integration, type: 'content', id: req.params.for_id } }))
67 | .then(function(result) {
68 | res.json(result);
69 | })
70 | .catch(next);
71 | })
72 | .get('/:integration/account/:id', function(req, res, next) {
73 | if (!integrations[req.params.integration].account) {
74 | return next(new errors.NotFound());
75 | }
76 | integrations[req.params.integration].account(Object.assign({}, req.headers, req.query, { id: req.params.id }))
77 | .then(function(result) {
78 | res.json(result);
79 | })
80 | .catch(next);
81 | })
82 |
83 | .get('/:integration/authenticate', function(req, res, next) {
84 | if (!config.integrations[req.params.integration].authenticatable || config.integrations[req.params.integration].authentication_url || !_.contains(CHROMIUM_IDS, req.query.chromium_id)) {
85 | return next(new errors.BadRequest());
86 | }
87 | passport.authenticate(req.params.integration, { session: false, callbackURL: '/v2/' + req.params.integration + '/callback?chromium_id=' + req.query.chromium_id })(req, res, next);
88 | })
89 | .get(
90 | '/:integration/callback',
91 | function(req, res, next) {
92 | if (!config.integrations[req.params.integration].authenticatable || config.integrations[req.params.integration].authentication_url || !_.contains(CHROMIUM_IDS, req.query.chromium_id)) {
93 | return next(new errors.BadRequest());
94 | }
95 | passport.authenticate(req.params.integration, { session: false })(req, res, next);
96 | },
97 | function(req, res) {
98 | res.redirect('https://' + req.query.chromium_id + '.chromiumapp.org/callback#access_token=' + req.user);
99 | }
100 | );
101 |
102 | /*
103 | * TODO https://github.com/kogg/hovercards/issues/47
104 | .get('/in-app-messaging', function(req, res, next) {
105 | promisify(redis.get.bind(redis))('active-message')
106 | .then(function(activeMessage) {
107 | if (!activeMessage) {
108 | throw new errors.NotFound('No active message');
109 | }
110 | return promisify(redis.hgetall.bind(redis))(activeMessage)
111 | .then(function(message) {
112 | if (!message) {
113 | throw new errors.NotFound('No active message');
114 | }
115 | return [_.defaults(message, { id: activeMessage })];
116 | });
117 | })
118 | .then(function(result) {
119 | res.json(result);
120 | })
121 | .catch(next);
122 | })
123 | */
124 |
--------------------------------------------------------------------------------
/extension/copy.json:
--------------------------------------------------------------------------------
1 | {
2 | "app_name": { "message": "HoverCards" },
3 | "app_short_name": { "message": "hovercards" },
4 | "app_description": { "message": "See what's behind social links with HoverCards." },
5 |
6 | "err_empty_account_content_name": { "message": "No Content" },
7 | "err_empty_account_content_text": { "message": "This $1 user has no content. Check back later!" },
8 | "err_empty_discussion_name": { "message": "No Comments" },
9 | "err_empty_discussion_text": { "message": "There are no $1 comments." },
10 | "err_uncommentable_discussion_name": { "message": "Comments Disabled" },
11 | "err_uncommentable_discussion_text": { "message": "Comments are disabled for this." },
12 |
13 | "account_id": { "message": "$1" },
14 | "account_id_of_instagram": { "message": "@$1" },
15 | "account_id_of_soundcloud": { "message": "@$1" },
16 | "account_id_of_twitter": { "message": "@$1" },
17 | "age": { "message": "Age" },
18 | "comment_karma_of_reddit": { "message": "Cmnt Karma" },
19 | "comments": { "message": "Comments" },
20 | "content_of_instagram": { "message": "Photos" },
21 | "content_of_soundcloud": { "message": "Tracks" },
22 | "content_of_twitter": { "message": "Tweets" },
23 | "content_of_youtube": { "message": "Videos" },
24 | "dislikes": { "message": "Dislikes" },
25 | "empty_account_id_of_imgur": { "message": "anon" },
26 | "empty_account_id_of_reddit": { "message": "[deleted]" },
27 | "err_401_cta": { "message": "Sign In to Continue" },
28 | "err_401_name": { "message": "Sign in Required" },
29 | "err_401_text": { "message": "Sorry, but you must sign into HoverCards with your $1 account to see this!" },
30 | "err_403_name": { "message": "Private" },
31 | "err_403_text": { "message": "Sorry, it seems like you're not allowed to look at this." },
32 | "err_404_name": { "message": "Not Found" },
33 | "err_404_text": { "message": "HoverCards couldn't find anything here!" },
34 | "err_429_cta": { "message": "Sign In for a higher limit" },
35 | "err_429_name": { "message": "Too many requests" },
36 | "err_429_text": { "message": "HoverCards has reached its maximum requests for $1! Try again later!" },
37 | "err_500_name": { "message": "HoverCards Issue" },
38 | "err_500_text": { "message": "HoverCards is having problems right now. Try again later!" },
39 | "err_502_name": { "message": "$1 Issues" },
40 | "err_502_text": { "message": "$1 is having problems right now. Try again later!" },
41 | "followers": { "message": "Followers" },
42 | "followers_of_youtube": { "message": "Subscribers" },
43 | "following": { "message": "Following" },
44 | "hovercards_of_account": { "message": "User hovercards" },
45 | "hovercards_of_imgur_content": { "message": "Photo hovercards" },
46 | "hovercards_of_instagram_content": { "message": "Photo hovercards" },
47 | "hovercards_of_reddit_content": { "message": "Post hovercards" },
48 | "hovercards_of_soundcloud_content": { "message": "Track hovercards" },
49 | "hovercards_of_twitter_content": { "message": "Tweet hovercards" },
50 | "hovercards_of_youtube_content": { "message": "Video hovercards" },
51 | "likes": { "message": "Likes" },
52 | "link_karma_of_reddit": { "message": "Link Karma" },
53 | "name_of_imgur": { "message": "Imgur" },
54 | "name_of_instagram": { "message": "Instagram" },
55 | "name_of_reddit": { "message": "Reddit" },
56 | "name_of_soundcloud": { "message": "SoundCloud" },
57 | "name_of_twitter": { "message": "Twitter" },
58 | "name_of_youtube": { "message": "YouTube" },
59 | "replies": { "message": "Replies" },
60 | "reposts_of_twitter": { "message": "Retweets" },
61 | "save_options": { "message": "Save My Options" },
62 | "score": { "message": "Points" },
63 | "score_ratio": { "message": "Upvoted" },
64 | "show_these": { "message": "Show these!" },
65 | "views": { "message": "Views" },
66 | "views_of_soundcloud": { "message": "Plays" },
67 | "x_seconds": { "message": "$1s" },
68 | "x_minutes": { "message": "$1m" },
69 | "x_hours": { "message": "$1h" },
70 | "x_days": { "message": "$1d" },
71 | "x_months": { "message": "$1M" },
72 | "x_years": { "message": "$1y" },
73 | "x_thousand": { "message": "$1k" },
74 | "x_million": { "message": "$1m" },
75 | "x_billion": { "message": "$1b" },
76 | "x_trillion": { "message": "$1t" }
77 | }
78 |
--------------------------------------------------------------------------------
/integrations/index.background.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var errors = require('feathers-errors');
3 | var querystring = require('querystring');
4 | var Response = require('http-browserify/lib/response');
5 |
6 | var browser = require('../extension/browser');
7 | var config = require('./config');
8 | var report = require('../report');
9 |
10 | // FIXME https://github.com/substack/http-browserify/pull/10
11 | Response.prototype.setEncoding = Response.prototype.setEncoding || _.noop;
12 |
13 | var ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
14 |
15 | // HACK These are the integrations that have environment==='client' || authenticated_environment==='client'
16 | var clientIntegrations = {
17 | instagram: require('./instagram'),
18 | reddit: require('./reddit'),
19 | soundcloud: require('./soundcloud')
20 | };
21 |
22 | var integrations = {};
23 | var serverEndpoint = 'http://' + (process.env.NODE_ENV === 'production' ? 'hover.cards' : 'localhost:5100') + '/v2/';
24 |
25 | // Migrate auth to new auth
26 | var newAuthKeys = _.chain(config.integrations)
27 | .pick(_.property('authenticatable'))
28 | .keys()
29 | .map(function(integration) {
30 | return [integration + '_user', 'authentication.' + integration];
31 | })
32 | .object()
33 | .value();
34 | browser.storage.sync.get(_.keys(newAuthKeys))
35 | .then(function(items) {
36 | return Promise.all(_.map(newAuthKeys, function(newKey, oldKey) {
37 | if (!items[oldKey]) {
38 | return;
39 | }
40 |
41 | return Promise.all([
42 | browser.storage.sync.remove(oldKey),
43 | browser.storage.sync.set({ [newKey]: items[oldKey] })
44 | ]);
45 | }));
46 | })
47 | .catch(report.captureException);
48 |
49 | browser.storage.onChanged.addListener(function(changes, areaName) {
50 | if (areaName !== 'sync') {
51 | return;
52 | }
53 | _.keys(changes).forEach(function(key) {
54 | key = key.match(/^authentication\.(.+)/);
55 | if (!key) {
56 | return;
57 | }
58 | delete integrations[key[1]];
59 | });
60 | });
61 |
62 | module.exports = function(request) {
63 | var integrationConfig = config.integrations[request.api];
64 |
65 | return Promise.all([
66 | browser.storage.local.get('device_id')
67 | .then(_.property('device_id'))
68 | .then(function(device_id) {
69 | if (device_id) {
70 | return device_id;
71 | }
72 |
73 | device_id = _.times(25, _.partial(_.sample, ALPHANUMERIC, null)).join('');
74 | return browser.storage.local.set({ device_id: device_id })
75 | .then(_.constant(device_id));
76 | }),
77 | browser.storage.sync.get('authentication.' + request.api)
78 | .then(_.property('authentication.' + request.api))
79 | ])
80 | .then(function(storage) {
81 | switch ((storage[1] && integrationConfig.authenticated_environment) || integrationConfig.environment) {
82 | case 'client':
83 | // FIXME shouldn't need config to be passed
84 | integrations[request.api] = integrations[request.api] || clientIntegrations[request.api](Object.assign({ device_id: storage[0], user: storage[1] }, integrationConfig));
85 | return integrations[request.api][request.type](request);
86 | case 'server':
87 | default:
88 | var url = serverEndpoint;
89 | switch (request.type) {
90 | case 'content':
91 | case 'account':
92 | url += [request.api, request.type, request.id].join('/');
93 | break;
94 | case 'discussion':
95 | if (request.for) {
96 | url += [request.for.api, 'content', request.for.id, 'discussion', request.api].join('/');
97 | var for_api = request.for.api;
98 | request = _.clone(request);
99 | request.for = _.pick(request.for, 'as', 'account');
100 | request.for.account = _.pick(request.for.account, 'id', 'as');
101 | if (!_.contains(['soundcloud', 'twitter'], for_api) || _.isEmpty(request.for.account)) {
102 | delete request.for.account;
103 | }
104 | if (_.isEmpty(request.for)) {
105 | delete request.for;
106 | }
107 | } else {
108 | url += [request.api, 'content', request.id, 'discussion'].join('/');
109 | }
110 | break;
111 | case 'account_content':
112 | url += [request.api, 'account', request.id, 'content'].join('/');
113 | break;
114 | default:
115 | break;
116 | }
117 | var request_api = request.api;
118 | request = _.pick(request, 'as', 'account', 'for');
119 | request.account = _.pick(request.account, 'id', 'as', 'account');
120 | if (!_.contains(['soundcloud', 'twitter'], request_api) || _.isEmpty(request.account)) {
121 | delete request.account;
122 | }
123 | return fetch(url + '?' + querystring.stringify(request), {
124 | headers: _.omit(
125 | {
126 | device_id: storage[0],
127 | user: storage[1]
128 | },
129 | _.isUndefined
130 | )
131 | })
132 | .then(function(response) {
133 | return response.json()
134 | .then(function(json) {
135 | if (response.ok) {
136 | return json;
137 | }
138 | var err = new errors.FeathersError(json.message, json.name, json.code, json.className, json.data);
139 | err.errors = json.errors;
140 | throw err;
141 | });
142 | });
143 | }
144 | })
145 | .then(function(entity) {
146 | return Object.assign(entity, { loaded: Date.now() });
147 | });
148 | };
149 |
--------------------------------------------------------------------------------
/webpack.config.extension.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore');
2 | var BellOnBundlerErrorPlugin = require('bell-on-bundler-error-plugin');
3 | var CleanWebpackPlugin = require('clean-webpack-plugin');
4 | var CopyWebpackPlugin = require('copy-webpack-plugin');
5 | var ExtractTextPlugin = require('extract-text-webpack-plugin');
6 | var HtmlWebpackPlugin = require('html-webpack-plugin');
7 | var StringReplacePlugin = require('string-replace-webpack-plugin');
8 | var WriteFilePlugin = require('write-file-webpack-plugin');
9 | var autoprefixer = require('autoprefixer');
10 | var increaseSpecificity = require('postcss-increase-specificity');
11 | var nested = require('postcss-nested');
12 | var path = require('path');
13 | var raw = require('postcss-raw');
14 | var safeImportant = require('postcss-safe-important');
15 | var webpack = require('webpack');
16 |
17 | module.exports = {
18 | entry: {
19 | 'background': './extension/index.background',
20 | 'manifest': './extension/manifest',
21 | 'options': './extension/index.options',
22 | 'top-frame': './extension/index.top-frame'
23 | },
24 | output: {
25 | filename: '[name].js',
26 | path: 'dist',
27 | publicPath: 'chrome-extension://' + process.env.CHROME_EXTENSION_ID + '/'
28 | },
29 | module: {
30 | loaders: [
31 | { exclude: 'node_modules', test: /\.(eot|ttf|(woff(2)?(\?v=\d\.\d\.\d)?))$/, loader: 'file?name=assets/fonts/[name].[ext]' },
32 | { exclude: 'node_modules', test: /\.(gif|png|jpe?g|svg)$/i, loaders: ['file?name=assets/images/[name].[ext]', 'image-webpack'] },
33 | { exclude: 'node_modules', test: /\.(js)$/, loader: 'babel?cacheDirectory' },
34 | { exclude: ['node_modules', path.join(__dirname, 'extension/manifest.json')], test: /\.(json)$/, loader: 'json' },
35 | {
36 | exclude: 'node_modules',
37 | test: /\.css$/,
38 | loader: ExtractTextPlugin.extract(
39 | 'style',
40 | [
41 | 'css?-autoprefixer&camelCase&modules&sourceMap&importLoaders=1' + (process.env.NODE_ENV ? '' : '&localIdentName=[name]---[local]---[hash:base64:10]'),
42 | 'postcss'
43 | ]
44 | )
45 | },
46 | {
47 | exclude: 'node_modules',
48 | test: /manifest\.json$/,
49 | loaders: [
50 | 'file?name=manifest.json',
51 | StringReplacePlugin.replace({
52 | replacements: [{
53 | pattern: /__VERSION__/ig,
54 | replacement: function() {
55 | return process.env.NODE_ENV ? process.env.npm_package_version : '0.0.1';
56 | }
57 | }]
58 | })
59 | ]
60 | }
61 | ],
62 | noParse: /node_modules\/json-schema\/lib\/validate\.js/
63 | },
64 | resolve: {
65 | extensions: extensions(
66 | ['.extension', '.browser', ''],
67 | ['.json', '.js', '.css']
68 | )
69 | },
70 | bail: process.env.NODE_ENV,
71 | devtool: process.env.NODE_ENV ? 'source-map' : 'cheap-source-map',
72 | devServer: {
73 | outputPath: 'dist',
74 | port: process.env.PORT,
75 | stats: {
76 | assets: true,
77 | cached: false, // Filters information from devServer.stats.modules
78 | cachedAssets: false, // Filters information from devServer.stats.assets
79 | children: true,
80 | chunks: false,
81 | colors: true,
82 | errorDetails: true,
83 | errors: true,
84 | hash: false,
85 | modules: true,
86 | publicPath: false,
87 | reasons: true,
88 | timings: true,
89 | version: false,
90 | warnings: false
91 | }
92 | },
93 | node: {
94 | console: true,
95 | dns: 'empty',
96 | fs: 'empty',
97 | net: 'empty',
98 | tls: 'empty'
99 | },
100 | plugins: _.compact([
101 | new webpack.EnvironmentPlugin([
102 | 'CHROME_EXTENSION_ID',
103 | 'GOOGLE_ANALYTICS_ID',
104 | 'INSTAGRAM_CLIENT_ID',
105 | 'NODE_ENV',
106 | 'REDDIT_CLIENT_ID',
107 | 'SENTRY_DSN_CLIENT',
108 | 'SOUNDCLOUD_CLIENT_ID',
109 | 'STICKYCARDS',
110 | 'npm_package_version'
111 | ]),
112 | new webpack.optimize.CommonsChunkPlugin({
113 | name: 'common',
114 | minChunks: 2,
115 | chunks: ['background', 'options', 'top-frame']
116 | }),
117 | new BellOnBundlerErrorPlugin(),
118 | new CleanWebpackPlugin(['dist']),
119 | new CopyWebpackPlugin([
120 | { from: 'assets/images/logo-*', to: 'assets/images', flatten: true },
121 | { from: 'extension/copy.json', to: '_locales/en/messages.json' }
122 | ]),
123 | new ExtractTextPlugin('[name].css'),
124 | new HtmlWebpackPlugin({
125 | title: 'HoverCard Options',
126 | filename: 'options.html',
127 | template: require('html-webpack-template'),
128 | inject: false,
129 | chunks: ['common', 'options'],
130 | appMountId: 'mount'
131 | }),
132 | new StringReplacePlugin(),
133 | new WriteFilePlugin({ log: false })
134 | ]),
135 | postcss: function() {
136 | return [
137 | nested({ bubble: ['raw'] }),
138 | autoprefixer,
139 | raw.inspect(),
140 | increaseSpecificity({ stackableRoot: ':global(.hovercards-root)', repeat: 1 }),
141 | raw.write(),
142 | safeImportant
143 | ];
144 | }
145 | };
146 |
147 | if (!process.env.NODE_ENV) {
148 | var DotenvPlugin = require('webpack-dotenv-plugin');
149 |
150 | module.exports.plugins = module.exports.plugins.concat([
151 | new DotenvPlugin()
152 | ]);
153 | }
154 |
155 | function extensions(injections, builds, extensions) {
156 | var results = [''];
157 |
158 | [].concat(injections).forEach(function(injection) {
159 | [].concat(builds).forEach(function(build) {
160 | [].concat(extensions).forEach(function(extension) {
161 | if (!injection && !build && !extension) {
162 | return;
163 | }
164 | results.push([injection, build, extension].join(''));
165 | });
166 | });
167 | });
168 | return results;
169 | }
170 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](http://hovercards.com)
2 |
3 | [](https://gitter.im/kogg/hovercards)
4 | [](https://github.com/kogg/hovercards/stargazers)
5 |
6 | # HoverCards
7 | HoverCards is a chrome extension that lets you see what's behind links from youtube, twitter, reddit, soundcloud, imgur, & instagram — all with out ever leaving the web page you're currently on.
8 |
9 | ## Chrome
10 |
11 | [](https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco)
12 | [](https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco)
13 | [](https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco/reviews)
14 | [](https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco/reviews)
15 |
16 | Install HoverCards from [the chrome webstore](https://chrome.google.com/webstore/detail/hovercards/dighmiipfpfdfbfmpodcmfdgkkcakbco). Simple.
17 |
18 | ## npm
19 |
20 | [](https://www.npmjs.com/package/hovercards)
21 | [](https://www.npmjs.com/package/hovercards)
22 |
23 | ```bash
24 | npm install -g hovercards
25 | ```
26 |
27 | You will need to load the chrome extension as an unpacked extension, which there is [a guide](https://developer.chrome.com/extensions/getstarted#unpacked) for. The extension will be in the `dist` folder.
28 |
29 | ## Development
30 |
31 | [](https://github.com/semantic-release/semantic-release)
32 | [](http://commitizen.github.io/cz-cli/)
33 | [](https://github.com/kogg/hovercards)
34 | [](https://github.com/kogg/hovercards)
35 |
36 | ```bash
37 | gem install foreman # https://devcenter.heroku.com/articles/heroku-local#run-your-app-locally-using-foreman
38 | brew install redis # https://medium.com/@petehouston/install-and-config-redis-on-mac-os-x-via-homebrew-eb8df9a4f298
39 | git clone git@github.com:kogg/hovercards.git
40 | cd hovercards
41 | echo INSTAGRAM_CLIENT_ID=41e56061c1e34fbbb16ab1d095dad78b\\nREDDIT_CLIENT_ID=0jXqEudQPqSL6w\\nSOUNDCLOUD_CLIENT_ID=78a827254bd7a5e3bba61aa18922bf2e > .env
42 | npm start
43 | ```
44 |
45 | You will need to load the chrome extension as an unpacked extension, which there is [a guide](https://developer.chrome.com/extensions/getstarted#unpacked) for. The extension will be in the `dist` folder.
46 |
47 | There are a few environment variables that should be set in `.env` to get different functionality working. For example, to get imgur working, you'll need the `IMGUR_CLIENT_ID` environment variable. Most of these aren't provided, as they are the secret API keys for the running services. :smile:
48 |
49 | Everything __except__ the content scripts are hot reloaded. This includes a local version of the [website](http://hovercards.com), which can be viewed at [localhost:5000](http://localhost:5000).
50 |
51 | ### Tests
52 |
53 | [](https://travis-ci.org/kogg/hovercards)
54 | [](https://codecov.io/gh/kogg/hovercards)
55 | [](http://hovercards.com)
56 | [](https://www.bithound.io/github/kogg/hovercards)
57 | [](https://www.bithound.io/github/kogg/hovercards)
58 | [](https://www.bithound.io/github/kogg/hovercards)
59 |
60 | Our tests are very incomplete. Currently, there are tests for the various integrations (eg. reddit, youtube, etc.) but none for the extension's logic or the website.
61 |
62 | The tests run automatically on every pull request. They also run on `master` before releasing to our website, chrome webstore, and server.
63 |
64 | ### Code Style (linting/formatting)
65 | There are included `.editorconfig`, `.eslintrc`, and `.stylelintrc` files which, on commit, should check (and attempt to fix) the code style. We have a custom set of rules, so look through those files to determine what's going on there.
66 |
67 | ## Join us!
68 | We just opened up HoverCards to the world, so we're looking for this to be a community driven project. Our documentation is very spotty and looking for love. Feel free to create issues, chat with us in [our gitter](https://gitter.im/kogg/hovercards), and throw us some pull requests of your own!
69 |
70 | 
71 |
--------------------------------------------------------------------------------