├── src ├── lib │ └── .gitkeep ├── assets │ ├── .gitkeep │ ├── favicon.ico │ └── now.json ├── components │ ├── LoadingIndicator.css │ ├── TweetList.css │ ├── LoadingIndicator.js │ ├── Header.css │ ├── Header.js │ ├── Tweet.css │ ├── TweetList.js │ └── Tweet.js ├── config.js ├── pages │ ├── Test │ │ ├── Test.css │ │ ├── index.js │ │ └── data │ │ │ ├── retweeted-and-liked.json │ │ │ ├── video.json │ │ │ ├── link-with-preview.json │ │ │ ├── hashtags.json │ │ │ ├── mentions.json │ │ │ ├── replying-to.json │ │ │ ├── image.json │ │ │ ├── verified.json │ │ │ ├── tweet-with-link.json │ │ │ ├── retweet.json │ │ │ └── retweet-with-comment.json │ ├── index.css │ ├── index.js │ └── Timeline │ │ ├── TweetStream.js │ │ └── index.js ├── pwa.js ├── style │ └── index.less ├── manifest.json ├── index.js └── index.ejs ├── .travis.yml ├── netlify.toml ├── .gitignore ├── .babelrc ├── .editorconfig ├── README.md ├── test ├── components │ ├── home │ │ └── index.test.js │ ├── header │ │ └── index.test.js │ ├── app.test.js │ └── profile │ │ └── index.test.js └── setup.js ├── .eslintrc ├── package.json └── webpack.config.babel.js /src/lib/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bvaughn/tweets/HEAD/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tweets", 3 | "alias": "tweets", 4 | "type": "static" 5 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "build" 4 | branch = "master" 5 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator.css: -------------------------------------------------------------------------------- 1 | .LoadingIndicator { 2 | padding: 1rem; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /npm-debug.log 3 | /build 4 | .DS_Store 5 | /coverage 6 | /.idea 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /src/components/TweetList.css: -------------------------------------------------------------------------------- 1 | .TweetList { 2 | height: 100%; 3 | background-color: #fff; 4 | border: 1px solid #e6ecf0; 5 | overflow: hidden; 6 | } 7 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | function config() { 2 | return { 3 | tweetsServerUrl: process.env.NODE_ENV === 'development' 4 | ? 'http://localhost:5001' 5 | : 'https://tweets-auth.now.sh', 6 | }; 7 | } 8 | 9 | export default config(); 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true, 3 | "presets": [ 4 | ["es2015", { "loose":true }], 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | ["transform-decorators-legacy"], 9 | ["transform-react-jsx", { "pragma": "h" }] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/Test/Test.css: -------------------------------------------------------------------------------- 1 | .Test { 2 | width: 590px; 3 | max-width: 100%; 4 | margin: 0 auto; 5 | } 6 | 7 | .Header { 8 | text-align: center; 9 | } 10 | 11 | .TweetWrapper { 12 | flex: 590px 0 1; 13 | background-color: #fff; 14 | border: 1px solid #e6ecf0; 15 | margin: 0.25rem; 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,.*rc,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tweets 2 | 3 | This project is a Twitter look-alike with limited functionality. The primary purpose is to illustrate a [react-virtualized](https://github.com/bvaughn/react-virtualized) + [Preact](https://github.com/developit/preact) integration. 4 | 5 | Online demo accessible at tweets.now.sh. -------------------------------------------------------------------------------- /src/components/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import styles from './LoadingIndicator.css'; 3 | 4 | export default function LoadingIndicator({ tweet }) { 5 | return ( 6 |
7 | Streaming some tweets... 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /test/components/home/index.test.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { expect } from 'chai'; 3 | 4 | import Home from '../../../src/components/home'; 5 | 6 | describe('components/home', () => { 7 | it('should show the home text', () => { 8 | const home = ; 9 | expect(home).to.contain(

Home

); 10 | expect(home).to.contain('Home component'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/pwa.js: -------------------------------------------------------------------------------- 1 | import runtime from 'offline-plugin/runtime'; 2 | 3 | runtime.install({ 4 | // When an update is ready, tell ServiceWorker to take control immediately: 5 | onUpdateReady() { 6 | console.log('update ready'); 7 | runtime.applyUpdate(); 8 | }, 9 | // Reload to get the new version: 10 | onUpdated() { 11 | console.log('updated'); 12 | location.reload(); 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/style/index.less: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | padding: 0; 5 | margin: 0; 6 | font-family: 'Helvetica Neue', arial, sans-serif; 7 | font-weight: 400; 8 | color: #444; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | background-color: #f5f8fa; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | #app { 19 | height: 100%; 20 | } 21 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | import chai from 'chai'; 3 | import assertJsx, { options } from 'preact-jsx-chai'; 4 | 5 | // when checking VDOM assertions, don't compare functions, just nodes and attributes: 6 | options.functions = false; 7 | 8 | // activate the JSX assertion extension: 9 | chai.use(assertJsx); 10 | 11 | global.sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); 12 | -------------------------------------------------------------------------------- /src/pages/index.css: -------------------------------------------------------------------------------- 1 | .Outer { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .Header { 8 | flex: 75px 0 0; 9 | } 10 | 11 | .Body { 12 | flex-grow: 1; 13 | display: flex; 14 | overflow-y: auto; 15 | position: relative; 16 | } 17 | 18 | .BodyWrapper { 19 | position: absolute; 20 | width: 100%; 21 | height: 100%; 22 | } 23 | 24 | .Content { 25 | height: 100%; 26 | width: 590px; 27 | max-width: 100%; 28 | margin: 0 auto; 29 | } -------------------------------------------------------------------------------- /test/components/header/index.test.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { expect } from 'chai'; 3 | 4 | import Header from '../../../src/components/header'; 5 | 6 | describe('components/Header', () => { 7 | it('should show the correct navigation links', () => { 8 | const header =
; 9 | expect(header).to.contain(Home); 10 | expect(header).to.contain(Me); 11 | expect(header).to.contain(John); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Preact PWA", 3 | "short_name": "Preact PWA", 4 | "start_url": "./", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#fff", 8 | "theme_color": "#673ab8", 9 | "icons": [{ 10 | "src": "./assets/icons/android-chrome-192x192.png", 11 | "type": "image/png", 12 | "sizes": "192x192" 13 | }, 14 | { 15 | "src": "./assets/icons/android-chrome-512x512.png", 16 | "type": "image/png", 17 | "sizes": "512x512" 18 | }] 19 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // import 'promise-polyfill'; 2 | // import 'isomorphic-fetch'; 3 | import { h, render } from 'preact'; 4 | import './style'; 5 | 6 | let root; 7 | function init() { 8 | let App = require('./pages').default; 9 | root = render(, document.body, root); 10 | } 11 | 12 | // register ServiceWorker via OfflinePlugin, for prod only: 13 | if (process.env.NODE_ENV === 'production') { 14 | require('./pwa'); 15 | } 16 | 17 | // in development, set up HMR: 18 | if (module.hot) { 19 | //require('preact/devtools'); // turn this on if you want to enable React DevTools! 20 | module.hot.accept('./pages', () => requestAnimationFrame(init)); 21 | } 22 | 23 | init(); 24 | -------------------------------------------------------------------------------- /src/components/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | line-height: 44px; 3 | background-color: white; 4 | border-bottom: 1px solid rgba(0,0,0,0.15); 5 | color: #66757f; 6 | } 7 | 8 | .HeaderAlignment { 9 | position: relative; 10 | width: 890px; 11 | max-width: 100%; 12 | padding: 0 1rem; 13 | overflow-y: hidden; 14 | margin: 0 auto; 15 | } 16 | 17 | .Right { 18 | float: right; 19 | text-align: left; 20 | } 21 | 22 | .ShowMediaIconActive { 23 | color: #17bf63; 24 | } 25 | 26 | .IconButton { 27 | line-height: 75px; 28 | padding: 1.5rem 1rem; 29 | cursor: pointer; 30 | text-decoration: none; 31 | color: inherit; 32 | } 33 | .IconButton:hover { 34 | color: #0084B4; 35 | } 36 | 37 | .SignInButton { 38 | margin-left: 1rem; 39 | } -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import Router from 'preact-router'; 3 | import Header from 'components/Header'; 4 | import Test from './Test'; 5 | import Timeline from './Timeline'; 6 | import styles from './index.css'; 7 | 8 | export default class Application extends Component { 9 | state = { 10 | authenticated: false 11 | }; 12 | 13 | render() { 14 | const { authenticated } = this.state; 15 | 16 | return ( 17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 |
32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { h } from 'preact'; 3 | import styles from './Header.css'; 4 | import config from '../config'; 5 | 6 | export default ({ 7 | authenticated 8 | }) => ( 9 |
10 |
11 | 15 | 16 | 17 | 18 |
19 | {authenticated && 20 | 24 | 25 | } 26 | {!authenticated && 27 | 31 | 32 | } 33 |
34 |
35 |
36 | ); -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <% for (var chunk in htmlWebpackPlugin.files.css) { %> 5 | 6 | <% } %> 7 | <% for (var chunk in htmlWebpackPlugin.files.chunks) { %> 8 | 9 | <% } %> 10 | 11 | Tweets 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/components/app.test.js: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import { route } from 'preact-router'; 3 | import { expect } from 'chai'; 4 | 5 | import App from '../../src/components/app'; 6 | 7 | describe('App', () => { 8 | let scratch; 9 | 10 | beforeAll(() => { 11 | scratch = document.createElement('div'); 12 | (document.body || document.documentElement).appendChild(scratch); 13 | }); 14 | 15 | beforeEach(() => { 16 | scratch.innerHTML = ''; 17 | }); 18 | 19 | afterAll(() => { 20 | scratch.parentNode.removeChild(scratch); 21 | scratch = null; 22 | }); 23 | 24 | describe('routing', () => { 25 | it('should render the homepage', () => { 26 | render(, scratch); 27 | 28 | expect(scratch.innerHTML).to.contain('Home'); 29 | }); 30 | 31 | it('should render /profile', async () => { 32 | render(, scratch); 33 | route('/profile'); 34 | 35 | await sleep(1); 36 | 37 | expect(scratch.innerHTML).to.contain('Profile: me'); 38 | }); 39 | 40 | it('should render /profile/:user', async () => { 41 | render(, scratch); 42 | route('/profile/john'); 43 | 44 | await sleep(1); 45 | 46 | expect(scratch.innerHTML).to.contain('Profile: john'); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/components/profile/index.test.js: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import { expect } from 'chai'; 3 | import { createClock } from '../../setup'; 4 | 5 | import Profile from '../../../src/components/profile'; 6 | 7 | describe('components/Profile', () => { 8 | let scratch = null; 9 | 10 | beforeEach(() => { 11 | scratch = document.createElement('div'); 12 | }); 13 | 14 | it('should show the name of the user passed to it', () => { 15 | const profile = ; 16 | expect(profile).to.contain(

Profile: Martha

); 17 | }); 18 | 19 | it('should show the passage of time', () => { 20 | // in order to test whether the component updates state 21 | // with time, we need to create mount the component and 22 | // pass time; we'll use Jest's fake timers for this 23 | jest.useFakeTimers(); 24 | 25 | // render and mount the component 26 | let component = null; 27 | render( (component = ref)} user="test" />, scratch); 28 | expect(component.state.time).to.equal(new Date().toLocaleString()); 29 | 30 | // pass time for 2 seconds 31 | jest.runTimersToTime(2000); 32 | 33 | // check if the state is updated with the new time 34 | expect(component.state.time).to.equal(new Date().toLocaleString()); 35 | 36 | // restore the native timers 37 | jest.useRealTimers(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/pages/Timeline/TweetStream.js: -------------------------------------------------------------------------------- 1 | import PubNub from 'pubnub'; 2 | import tinytime from 'tinytime'; 3 | 4 | const BATCH_SIZE = 20; 5 | const timeTemplate = tinytime('{Mo}/{DD} {h}:{mm}:{ss} {a}'); 6 | 7 | // TODO Replace this with something that consumes Twitter's OAUTH API 8 | export default class TweetStream { 9 | constructor(batchSize = BATCH_SIZE) { 10 | this._batchSize = batchSize; 11 | this._loading = false; 12 | } 13 | 14 | load(callback) { 15 | if (this._loading) { 16 | return; 17 | } 18 | 19 | this._loading = true; 20 | this._callback = callback; 21 | this._tweets = []; 22 | 23 | this._stream = new PubNub({ 24 | subscribeKey: 'sub-c-78806dd4-42a6-11e4-aed8-02ee2ddab7fe', 25 | }); 26 | this._stream.addListener({ message: this._message }); 27 | this._stream.subscribe({ channels: ['pubnub-twitter'] }); 28 | } 29 | 30 | get loading() { 31 | return this._loading; 32 | } 33 | 34 | _message = data => { 35 | if (!this._loading) { 36 | return; 37 | } 38 | 39 | // Pre-format time (once) so that onScroll doesn't have to format 40 | data.message.timestring = timeTemplate.render( 41 | new Date(data.message.created_at) 42 | ); 43 | 44 | this._tweets.push(data.message); 45 | 46 | if (this._tweets.length >= this._batchSize) { 47 | this._stream.stop(); 48 | this._stream = null; 49 | this._loading = false; 50 | this._callback(this._tweets); 51 | } 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/Test/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import tinytime from 'tinytime'; 3 | import Tweet from 'components/Tweet'; 4 | import styles from './Test.css'; 5 | 6 | const timeTemplate = tinytime('{Mo}/{DD} {h}:{mm}:{ss} {a}'); 7 | 8 | const EXAMPLES = { 9 | Hashtags: require('./data/hashtags.json'), 10 | Image: require('./data/image.json'), 11 | 'Link with Preview': require('./data/link-with-preview.json'), 12 | Mentions: require('./data/mentions.json'), 13 | 'Replying to': require('./data/replying-to.json'), 14 | Retweet: require('./data/retweet.json'), 15 | 'Retweet with Comment': require('./data/retweet-with-comment.json'), 16 | 'Retweeted & Liked': require('./data/retweeted-and-liked.json'), 17 | Verified: require('./data/verified.json'), 18 | Video: require('./data/video.json'), 19 | }; 20 | 21 | export default function Test() { 22 | return ( 23 |
24 | {Object.keys(EXAMPLES).map(key => ( 25 | 26 | ))} 27 |
28 | ); 29 | } 30 | 31 | function Demo({ title, tweet }) { 32 | // Pre-format time (once) to mimic TweetStream 33 | tweet.timestring = timeTemplate.render(new Date(tweet.created_at)); 34 | 35 | // TODO Add toggles for testing each of the boolean combinations below 36 | 37 | return ( 38 |
39 |

{title}

40 |
41 | 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Tweet.css: -------------------------------------------------------------------------------- 1 | .Tweet { 2 | padding: 9px 12px; 3 | padding-left: 60px; 4 | border-bottom: 1px solid #e6ecf0; 5 | min-height: 70px; 6 | } 7 | .Tweet:hover { 8 | background-color: #f5f8fa; 9 | } 10 | 11 | .Link { 12 | text-decoration: none; 13 | color: inherit; 14 | } 15 | .Link:hover { 16 | color: #0084B4; 17 | } 18 | 19 | .Username { 20 | color: #657786; 21 | } 22 | 23 | .ReplyingTo { 24 | font-size: 0.75rem; 25 | white-space: pre; 26 | overflow: hidden; 27 | text-overflow: ellipsis; 28 | } 29 | 30 | .ProfileImage { 31 | float: left; 32 | margin-top: 3px; 33 | margin-left: -54px; 34 | width: 48px; 35 | height: 48px; 36 | border-radius: 0.25rem; 37 | } 38 | 39 | .ImageMedia { 40 | display: block; 41 | border-radius: 0.25rem; 42 | border: 1px solid #e6ecf0; 43 | margin-top: 0.5rem; 44 | width: 100%; 45 | background-position: left center; 46 | background-repeat: no-repeat; 47 | background-size: 100% auto; 48 | } 49 | 50 | .Text { 51 | white-space: pre-wrap; 52 | } 53 | 54 | .Icons { 55 | height: 18px; 56 | margin-top: 0.5rem; 57 | color: #aab8c2; 58 | } 59 | 60 | .ReplyIcon { 61 | cursor: pointer; 62 | } 63 | .ReplyIcon:hover { 64 | color: #1da1f2; 65 | } 66 | 67 | .RetweetIcon { 68 | cursor: pointer; 69 | margin-left: 3rem; 70 | } 71 | .RetweetIcon:hover, 72 | .RetweetIconActive { 73 | color: #17bf63; 74 | } 75 | 76 | .FavoriteIcon { 77 | cursor: pointer; 78 | margin-left: 3rem; 79 | } 80 | .FavoriteIcon:hover, 81 | .FavoriteIconActive { 82 | color: #e2264d; 83 | } 84 | 85 | .VerifiedIcon { 86 | color: #1da1f2; 87 | } 88 | 89 | .Retweeted { 90 | font-size: 0.75rem; 91 | } 92 | 93 | .RetweetedIcon { 94 | color: #17bf63; 95 | float: left; 96 | margin-left: -54px; 97 | width: 48px; 98 | text-align: right; 99 | } 100 | 101 | .QuotedStatus { 102 | display: block; 103 | margin-top: 1rem; 104 | padding: 9px 12px; 105 | border: solid 1px #e6ecf0; 106 | cursor: pointer; 107 | border-radius: 0.25rem; 108 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "plugins": [ 5 | "react", 6 | "jest" 7 | ], 8 | "env": { 9 | "browser": true, 10 | "node": true, 11 | "mocha": true, 12 | "es6": true, 13 | "jest/globals": true 14 | }, 15 | "parserOptions": { 16 | "ecmaFeatures": { 17 | "modules": true, 18 | "jsx": true 19 | } 20 | }, 21 | "settings": { 22 | "react": { 23 | "pragma": "h" 24 | } 25 | }, 26 | "globals": { 27 | "sleep": 1 28 | }, 29 | "rules": { 30 | "react/jsx-no-bind": [2, { "ignoreRefs": true }], 31 | "react/jsx-no-duplicate-props": 2, 32 | "react/self-closing-comp": 2, 33 | "react/prefer-es6-class": 2, 34 | "react/no-string-refs": 2, 35 | "react/require-render-return": 2, 36 | "react/no-find-dom-node": 2, 37 | "react/no-is-mounted": 2, 38 | "react/jsx-no-comment-textnodes": 2, 39 | "react/jsx-curly-spacing": 2, 40 | "react/jsx-no-undef": 2, 41 | "react/jsx-uses-react": 2, 42 | "react/jsx-uses-vars": 2, 43 | "jest/no-disabled-tests": 1, 44 | "jest/no-focused-tests": 1, 45 | "jest/no-identical-title": 2, 46 | "no-empty": 0, 47 | "no-console": 0, 48 | "no-empty-pattern": 0, 49 | "no-cond-assign": 1, 50 | "semi": 2, 51 | "camelcase": 0, 52 | "comma-style": 2, 53 | "comma-dangle": [2, "never"], 54 | "indent": [2, "tab", {"SwitchCase": 1}], 55 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"], 56 | "no-trailing-spaces": [2, { "skipBlankLines": true }], 57 | "max-nested-callbacks": [2, 3], 58 | "no-eval": 2, 59 | "no-implied-eval": 2, 60 | "no-new-func": 2, 61 | "guard-for-in": 2, 62 | "eqeqeq": 1, 63 | "no-else-return": 2, 64 | "no-redeclare": 2, 65 | "no-dupe-keys": 2, 66 | "radix": 2, 67 | "strict": [2, "never"], 68 | "no-shadow": 0, 69 | "no-delete-var": 2, 70 | "no-undef-init": 2, 71 | "no-shadow-restricted-names": 2, 72 | "handle-callback-err": 0, 73 | "no-lonely-if": 2, 74 | "keyword-spacing": 2, 75 | "constructor-super": 2, 76 | "no-this-before-super": 2, 77 | "no-dupe-class-members": 2, 78 | "no-const-assign": 2, 79 | "prefer-spread": 2, 80 | "no-useless-concat": 2, 81 | "no-var": 2, 82 | "object-shorthand": 2, 83 | "prefer-arrow-callback": 2 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/pages/Timeline/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component, options } from 'preact'; 2 | import LoadingIndicator from 'components/LoadingIndicator'; 3 | import TweetList from 'components/TweetList'; 4 | import TweetStream from './TweetStream'; 5 | import config from '../../config'; 6 | 7 | // Use requestAnimationFrame by default but allow URL param to disable. 8 | options.debounceRendering = location.search.indexOf('raf=false') < 0 9 | ? requestAnimationFrame 10 | : null; 11 | 12 | export default class Timeline extends Component { 13 | state = { 14 | tweets: [], 15 | }; 16 | 17 | componentDidMount() { 18 | this._tweetStream = new TweetStream(); 19 | this._fetchTweets(); 20 | } 21 | 22 | render() { 23 | const { authenticated, disableMedia, tweets } = this.state; 24 | 25 | if (tweets.length === 0) { 26 | return ; 27 | } else { 28 | return ( 29 | 35 | ); 36 | } 37 | } 38 | 39 | _fetchTweets = () => { 40 | let { authenticated, tweets } = this.state; 41 | 42 | let url; 43 | if (authenticated && tweets.length) { 44 | const lastIndex = tweets.length - 1; 45 | const oldestTweet = tweets[lastIndex]; 46 | url = config.tweetsServerUrl + '/tweets/' + oldestTweet.id; 47 | } else { 48 | url = config.tweetsServerUrl + '/tweets/'; 49 | } 50 | 51 | fetch(url, { credentials: 'include' }) 52 | .then(response => { 53 | if (response.status === 401) { 54 | throw Error('Unauthorized'); 55 | } else { 56 | return response.json(); 57 | } 58 | }) 59 | .then(newTweets => { 60 | tweets = authenticated ? tweets.concat(newTweets) : newTweets; 61 | 62 | this.setState({ 63 | authenticated: true, 64 | tweets, 65 | }); 66 | }) 67 | .catch(this._fetchTweetStream); 68 | }; 69 | 70 | _fetchTweetStream = () => { 71 | if (this._tweetStream.loading) { 72 | return; 73 | } 74 | 75 | this._tweetStream.load(newTweets => { 76 | const tweets = this.state.tweets.concat(newTweets); 77 | 78 | this.setState({ tweets }); 79 | }); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/pages/Test/data/retweeted-and-liked.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 30 17:26:44 +0000 2017", 3 | "id": 858734376074346500, 4 | "id_str": "858734376074346496", 5 | "text": "does anyone with a sizable react native codebase want to try running an analysis script on it to help me understand how ppl r using it?", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [], 11 | "urls": [] 12 | }, 13 | "source": "TweetDeck", 14 | "in_reply_to_status_id": null, 15 | "in_reply_to_status_id_str": null, 16 | "in_reply_to_user_id": null, 17 | "in_reply_to_user_id_str": null, 18 | "in_reply_to_screen_name": null, 19 | "user": { 20 | "id": 606973150, 21 | "id_str": "606973150", 22 | "name": "Leland Richardson", 23 | "screen_name": "intelligibabble", 24 | "location": "San Francisco, CA", 25 | "description": "Software Engineer @Airbnb. I like learning, discussing, and diving into challenges.", 26 | "url": "http://t.co/80xVNhoS6i", 27 | "entities": { 28 | "url": { 29 | "urls": [ 30 | { 31 | "url": "http://t.co/80xVNhoS6i", 32 | "expanded_url": "http://www.intelligiblebabble.com", 33 | "display_url": "intelligiblebabble.com", 34 | "indices": [ 35 | 0, 36 | 22 37 | ] 38 | } 39 | ] 40 | }, 41 | "description": { 42 | "urls": [] 43 | } 44 | }, 45 | "protected": false, 46 | "followers_count": 3663, 47 | "friends_count": 584, 48 | "listed_count": 167, 49 | "created_at": "Wed Jun 13 07:09:07 +0000 2012", 50 | "favourites_count": 3103, 51 | "utc_offset": -14400, 52 | "time_zone": "Eastern Time (US & Canada)", 53 | "geo_enabled": false, 54 | "verified": false, 55 | "statuses_count": 5067, 56 | "lang": "en", 57 | "contributors_enabled": false, 58 | "is_translator": false, 59 | "is_translation_enabled": false, 60 | "profile_background_color": "EBEBEB", 61 | "profile_background_image_url": "http://pbs.twimg.com/profile_background_images/742059665/e04e810c3a0ce82878f3285eda8408c9.png", 62 | "profile_background_image_url_https": "https://pbs.twimg.com/profile_background_images/742059665/e04e810c3a0ce82878f3285eda8408c9.png", 63 | "profile_background_tile": true, 64 | "profile_image_url": "http://pbs.twimg.com/profile_images/729167226691969024/43UcYrwM_normal.jpg", 65 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/729167226691969024/43UcYrwM_normal.jpg", 66 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/606973150/1437081954", 67 | "profile_link_color": "990000", 68 | "profile_sidebar_border_color": "FFFFFF", 69 | "profile_sidebar_fill_color": "DDEEF6", 70 | "profile_text_color": "333333", 71 | "profile_use_background_image": true, 72 | "has_extended_profile": false, 73 | "default_profile": false, 74 | "default_profile_image": false, 75 | "following": true, 76 | "follow_request_sent": false, 77 | "notifications": false, 78 | "translator_type": "none" 79 | }, 80 | "geo": null, 81 | "coordinates": null, 82 | "place": null, 83 | "contributors": null, 84 | "is_quote_status": false, 85 | "retweet_count": 5, 86 | "favorite_count": 3, 87 | "favorited": true, 88 | "retweeted": true, 89 | "lang": "en" 90 | } 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tweets", 3 | "version": "0.0.1", 4 | "description": "Ready-to-go Preact starter project powered by webpack.", 5 | "scripts": { 6 | "dev": "cross-env NODE_ENV=development webpack-dev-server --inline --hot --progress", 7 | "start": "serve build -s -c 1", 8 | "build": "cross-env NODE_ENV=production webpack -p --progress", 9 | "prebuild": "mkdirp build && ncp src/assets build/assets", 10 | "prettier": "prettier --single-quote --trailing-comma es5 --write '{src,test}/**/*.js'", 11 | "test": "npm run -s lint && jest --coverage", 12 | "test:watch": "npm run -s test -- --watch", 13 | "lint": "eslint src test" 14 | }, 15 | "keywords": [ 16 | "preact", 17 | "boilerplate", 18 | "webpack" 19 | ], 20 | "license": "MIT", 21 | "author": "Jason Miller ", 22 | "jest": { 23 | "setupFiles": [ 24 | "./test/setup.js" 25 | ], 26 | "testURL": "http://localhost:5000", 27 | "moduleFileExtensions": [ 28 | "js", 29 | "jsx" 30 | ], 31 | "moduleDirectories": [ 32 | "node_modules" 33 | ], 34 | "moduleNameMapper": { 35 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 36 | "\\.(css|less)$": "identity-obj-proxy" 37 | }, 38 | "collectCoverageFrom": [ 39 | "src/**/*.{js,jsx}" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "autoprefixer": "^6.4.0", 44 | "babel": "^6.5.2", 45 | "babel-core": "^6.14.0", 46 | "babel-eslint": "^7.0.0", 47 | "babel-jest": "^19.0.0", 48 | "babel-loader": "^6.2.5", 49 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 50 | "babel-plugin-transform-react-jsx": "^6.8.0", 51 | "babel-preset-es2015": "^6.14.0", 52 | "babel-preset-stage-0": "^6.5.0", 53 | "babel-register": "^6.14.0", 54 | "babel-runtime": "^6.11.6", 55 | "chai": "^3.5.0", 56 | "copy-webpack-plugin": "^4.0.1", 57 | "core-js": "^2.4.1", 58 | "cross-env": "^3.1.3", 59 | "css-loader": "^0.28.0", 60 | "eslint": "^3.0.1", 61 | "eslint-plugin-jest": "^19.0.1", 62 | "eslint-plugin-react": "^6.10.3", 63 | "extract-text-webpack-plugin": "^1.0.1", 64 | "file-loader": "^0.11.1", 65 | "html-webpack-plugin": "^2.22.0", 66 | "identity-obj-proxy": "^3.0.0", 67 | "jest": "^19.0.2", 68 | "json-loader": "^0.5.4", 69 | "less": "^2.7.1", 70 | "less-loader": "^4.0.3", 71 | "mkdirp": "^0.5.1", 72 | "ncp": "^2.0.0", 73 | "offline-plugin": "^4.5.3", 74 | "postcss-loader": "^1.2.1", 75 | "preact-jsx-chai": "^2.2.1", 76 | "prettier": "^1.2.2", 77 | "raw-loader": "^0.5.1", 78 | "regenerator-runtime": "^0.10.3", 79 | "replace-bundle-webpack-plugin": "^1.0.0", 80 | "script-ext-html-webpack-plugin": "^1.3.4", 81 | "sinon": "^1.17.5", 82 | "sinon-chai": "^2.8.0", 83 | "source-map-loader": "^0.2.0", 84 | "style-loader": "^0.16.0", 85 | "url-loader": "^0.5.7", 86 | "v8-lazy-parse-webpack-plugin": "^0.3.0", 87 | "webpack": "^1.13.2", 88 | "webpack-dev-server": "^1.15.0" 89 | }, 90 | "dependencies": { 91 | "classnames": "^2.2.5", 92 | "preact": "^8.1.0", 93 | "preact-compat": "^3.13.1", 94 | "preact-render-to-string": "^3.6.0", 95 | "preact-router": "^2.4.1", 96 | "preact-shallow-compare": "^1.2.0", 97 | "promise-polyfill": "^6.0.2", 98 | "proptypes": "^0.14.3", 99 | "pubnub": "^4.8.0", 100 | "react-virtualized": "^9.7.3", 101 | "serve": "^5.1.4", 102 | "tinytime": "^0.2.5", 103 | "twitter-text": "^1.14.3", 104 | "whatwg-fetch": "^2.0.3" 105 | }, 106 | "main": "webpack.config.babel.js", 107 | "directories": { 108 | "test": "test" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/pages/Test/data/video.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sat Apr 29 16:19:10 +0000 2017", 3 | "id": 858354986794836000, 4 | "id_str": "858354986794835970", 5 | "text": "What is this I’m seeing? Debugging an app in Safari with React DevTools and editor integration! Learn more:… https://t.co/NlegamjcyN", 6 | "truncated": true, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [], 11 | "urls": [ 12 | { 13 | "url": "https://t.co/NlegamjcyN", 14 | "expanded_url": "https://twitter.com/i/web/status/858354986794835970", 15 | "display_url": "twitter.com/i/web/status/8…", 16 | "indices": [ 17 | 109, 18 | 132 19 | ] 20 | } 21 | ] 22 | }, 23 | "source": "Twitter Web Client", 24 | "in_reply_to_status_id": null, 25 | "in_reply_to_status_id_str": null, 26 | "in_reply_to_user_id": null, 27 | "in_reply_to_user_id_str": null, 28 | "in_reply_to_screen_name": null, 29 | "user": { 30 | "id": 70345946, 31 | "id_str": "70345946", 32 | "name": "Dan Abramov", 33 | "screen_name": "dan_abramov", 34 | "location": "London, England", 35 | "description": "Co-authored Redux, Create React App, React Hot Loader, React DnD. Helping improve @reactjs. Personal opinions. #juniordevforlife", 36 | "url": "https://t.co/60kty8djcd", 37 | "entities": { 38 | "url": { 39 | "urls": [ 40 | { 41 | "url": "https://t.co/60kty8djcd", 42 | "expanded_url": "http://github.com/gaearon", 43 | "display_url": "github.com/gaearon", 44 | "indices": [ 45 | 0, 46 | 23 47 | ] 48 | } 49 | ] 50 | }, 51 | "description": { 52 | "urls": [] 53 | } 54 | }, 55 | "protected": false, 56 | "followers_count": 66872, 57 | "friends_count": 743, 58 | "listed_count": 1800, 59 | "created_at": "Mon Aug 31 08:28:07 +0000 2009", 60 | "favourites_count": 36191, 61 | "utc_offset": 3600, 62 | "time_zone": "London", 63 | "geo_enabled": false, 64 | "verified": false, 65 | "statuses_count": 38408, 66 | "lang": "en", 67 | "contributors_enabled": false, 68 | "is_translator": false, 69 | "is_translation_enabled": false, 70 | "profile_background_color": "000000", 71 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 72 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 73 | "profile_background_tile": false, 74 | "profile_image_url": "http://pbs.twimg.com/profile_images/826786122638426114/PR4tsq-i_normal.jpg", 75 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/826786122638426114/PR4tsq-i_normal.jpg", 76 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/70345946/1485957001", 77 | "profile_link_color": "04071C", 78 | "profile_sidebar_border_color": "000000", 79 | "profile_sidebar_fill_color": "000000", 80 | "profile_text_color": "000000", 81 | "profile_use_background_image": false, 82 | "has_extended_profile": false, 83 | "default_profile": false, 84 | "default_profile_image": false, 85 | "following": true, 86 | "follow_request_sent": false, 87 | "notifications": false, 88 | "translator_type": "none" 89 | }, 90 | "geo": null, 91 | "coordinates": null, 92 | "place": null, 93 | "contributors": null, 94 | "is_quote_status": false, 95 | "retweet_count": 106, 96 | "favorite_count": 373, 97 | "favorited": true, 98 | "retweeted": false, 99 | "possibly_sensitive": false, 100 | "possibly_sensitive_appealable": false, 101 | "lang": "en" 102 | } 103 | -------------------------------------------------------------------------------- /src/pages/Test/data/link-with-preview.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 30 18:24:27 +0000 2017", 3 | "id": 858748899384856600, 4 | "id_str": "858748899384856583", 5 | "text": "There is no reason to use index keys :(\nhttps://t.co/TGr1rVrqpH", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [], 11 | "urls": [ 12 | { 13 | "url": "https://t.co/TGr1rVrqpH", 14 | "expanded_url": "http://stackoverflow.com/a/42775667/194340", 15 | "display_url": "stackoverflow.com/a/42775667/194…", 16 | "indices": [ 17 | 109, 18 | 132 19 | ] 20 | } 21 | ] 22 | }, 23 | "source": "Twitter Web Client", 24 | "in_reply_to_status_id": 858748290237476900, 25 | "in_reply_to_status_id_str": "858748290237476864", 26 | "in_reply_to_user_id": 16468446, 27 | "in_reply_to_user_id_str": "16468446", 28 | "in_reply_to_screen_name": "ryanflorence", 29 | "user": { 30 | "id": 16495353, 31 | "id_str": "16495353", 32 | "name": "Jason Miller 🦊⚛", 33 | "screen_name": "_developit", 34 | "location": "Dundas, Ontario, Canada", 35 | "description": "Creator of @preactjs, a 3kb react alternative. https://t.co/i0RLycIg3R \nhttps://t.co/oGOGAAmmCm", 36 | "url": "http://t.co/NY6NSQDq6W", 37 | "entities": { 38 | "url": { 39 | "urls": [ 40 | { 41 | "url": "http://t.co/NY6NSQDq6W", 42 | "expanded_url": "http://jasonformat.com", 43 | "display_url": "jasonformat.com", 44 | "indices": [ 45 | 0, 46 | 22 47 | ] 48 | } 49 | ] 50 | }, 51 | "description": { 52 | "urls": [ 53 | { 54 | "url": "https://t.co/i0RLycIg3R", 55 | "expanded_url": "http://preactjs.com", 56 | "display_url": "preactjs.com", 57 | "indices": [ 58 | 47, 59 | 70 60 | ] 61 | }, 62 | { 63 | "url": "https://t.co/oGOGAAmmCm", 64 | "expanded_url": "http://github.com/developit", 65 | "display_url": "github.com/developit", 66 | "indices": [ 67 | 72, 68 | 95 69 | ] 70 | } 71 | ] 72 | } 73 | }, 74 | "protected": false, 75 | "followers_count": 6669, 76 | "friends_count": 1431, 77 | "listed_count": 253, 78 | "created_at": "Sun Sep 28 04:36:42 +0000 2008", 79 | "favourites_count": 24866, 80 | "utc_offset": -14400, 81 | "time_zone": "Eastern Time (US & Canada)", 82 | "geo_enabled": true, 83 | "verified": false, 84 | "statuses_count": 15523, 85 | "lang": "en", 86 | "contributors_enabled": false, 87 | "is_translator": false, 88 | "is_translation_enabled": false, 89 | "profile_background_color": "1A1B1F", 90 | "profile_background_image_url": "http://pbs.twimg.com/profile_background_images/3999841/bg.gif", 91 | "profile_background_image_url_https": "https://pbs.twimg.com/profile_background_images/3999841/bg.gif", 92 | "profile_background_tile": true, 93 | "profile_image_url": "http://pbs.twimg.com/profile_images/795334749476810753/LljqR8gE_normal.jpg", 94 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/795334749476810753/LljqR8gE_normal.jpg", 95 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/16495353/1479214234", 96 | "profile_link_color": "ED4300", 97 | "profile_sidebar_border_color": "3B3B3B", 98 | "profile_sidebar_fill_color": "212121", 99 | "profile_text_color": "555555", 100 | "profile_use_background_image": true, 101 | "has_extended_profile": true, 102 | "default_profile": false, 103 | "default_profile_image": false, 104 | "following": true, 105 | "follow_request_sent": false, 106 | "notifications": false, 107 | "translator_type": "none" 108 | }, 109 | "geo": null, 110 | "coordinates": null, 111 | "place": null, 112 | "contributors": null, 113 | "is_quote_status": false, 114 | "retweet_count": 0, 115 | "favorite_count": 0, 116 | "favorited": false, 117 | "retweeted": false, 118 | "possibly_sensitive": false, 119 | "possibly_sensitive_appealable": false, 120 | "lang": "en" 121 | } 122 | -------------------------------------------------------------------------------- /src/components/TweetList.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import PropTypes from 'prop-types'; 3 | import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; 4 | import CellMeasurer, { 5 | CellMeasurerCache, 6 | } from 'react-virtualized/dist/commonjs/CellMeasurer'; 7 | import InfiniteLoader from 'react-virtualized/dist/commonjs/InfiniteLoader'; 8 | import List from 'react-virtualized/dist/commonjs/List'; 9 | import LoadingIndicator from './LoadingIndicator'; 10 | import Tweet from './Tweet'; 11 | import styles from './TweetList.css'; 12 | 13 | export default class TweetList extends Component { 14 | _cache = new CellMeasurerCache({ defaultHeight: 85, fixedWidth: true }); 15 | _mostRecentWidth = 0; 16 | _resizeAllFlag = false; 17 | 18 | static propTypes = { 19 | authenticated: PropTypes.bool.isRequired, 20 | disableMedia: PropTypes.bool.isRequired, 21 | fetchTweets: PropTypes.func.isRequired, 22 | tweet: PropTypes.object.isRequired 23 | }; 24 | 25 | componentDidUpdate(prevProps, prevState) { 26 | if ( 27 | this._resizeAllFlag || 28 | this.props.disableMedia !== prevProps.disableMedia 29 | ) { 30 | this._resizeAllFlag = false; 31 | this._cache.clearAll(); 32 | if (this._list) { 33 | this._list.recomputeRowHeights(); 34 | } 35 | } else if (this.props.tweets !== prevProps.tweets) { 36 | const index = prevProps.tweets.length; 37 | this._cache.clear(index, 0); 38 | if (this._list) { 39 | this._list.recomputeRowHeights(index); 40 | } 41 | } 42 | } 43 | 44 | render() { 45 | const { fetchTweets, tweets } = this.props; 46 | 47 | return ( 48 |
49 | 54 | {({ onRowsRendered, registerChild }) => ( 55 | 56 | {({ height, width }) => { 57 | if (this._mostRecentWidth && this._mostRecentWidth !== width) { 58 | this._resizeAllFlag = true; 59 | 60 | setTimeout(this._resizeAll, 0); 61 | } 62 | 63 | this._mostRecentWidth = width; 64 | this._registerList = registerChild; 65 | 66 | return ( 67 | 78 | ); 79 | }} 80 | 81 | )} 82 | 83 |
84 | ); 85 | } 86 | 87 | _isRowLoaded = ({ index }) => { 88 | return index < this.props.tweets.length; 89 | }; 90 | 91 | _rowRenderer = ({ index, isScrolling, key, parent, style }) => { 92 | const { authenticated, disableMedia, tweets } = this.props; 93 | 94 | let content; 95 | 96 | if (index >= tweets.length) { 97 | content = ; 98 | } else { 99 | const tweet = tweets[index]; 100 | content = ( 101 | 107 | ); 108 | } 109 | 110 | return ( 111 | 119 |
120 | {content} 121 |
122 |
123 | ); 124 | }; 125 | 126 | _resizeAll = () => { 127 | this._resizeAllFlag = false; 128 | this._cache.clearAll(); 129 | if (this._list) { 130 | this._list.recomputeRowHeights(); 131 | } 132 | }; 133 | 134 | _setListRef = ref => { 135 | this._list = ref; 136 | this._registerList(ref); 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/pages/Test/data/hashtags.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 30 14:18:55 +0000 2017", 3 | "id": 858687110764744700, 4 | "id_str": "858687110764744704", 5 | "text": "My slides from Rust ⇋ JavaScript talk: https://t.co/UMwo7pEpjD #rustlang #emscripten #asmjs #wasm #rustfest", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [ 9 | { 10 | "text": "rustlang", 11 | "indices": [ 12 | 63, 13 | 72 14 | ] 15 | }, 16 | { 17 | "text": "emscripten", 18 | "indices": [ 19 | 73, 20 | 84 21 | ] 22 | }, 23 | { 24 | "text": "asmjs", 25 | "indices": [ 26 | 85, 27 | 91 28 | ] 29 | }, 30 | { 31 | "text": "wasm", 32 | "indices": [ 33 | 92, 34 | 97 35 | ] 36 | }, 37 | { 38 | "text": "rustfest", 39 | "indices": [ 40 | 98, 41 | 107 42 | ] 43 | } 44 | ], 45 | "symbols": [], 46 | "user_mentions": [], 47 | "urls": [ 48 | { 49 | "url": "https://t.co/UMwo7pEpjD", 50 | "expanded_url": "https://www.slideshare.net/RReverser/rust-javascript", 51 | "display_url": "slideshare.net/RReverser/rust…", 52 | "indices": [ 53 | 39, 54 | 62 55 | ] 56 | } 57 | ] 58 | }, 59 | "source": "Twitter Web Client", 60 | "in_reply_to_status_id": null, 61 | "in_reply_to_status_id_str": null, 62 | "in_reply_to_user_id": null, 63 | "in_reply_to_user_id_str": null, 64 | "in_reply_to_screen_name": null, 65 | "user": { 66 | "id": 97495292, 67 | "id_str": "97495292", 68 | "name": "Ingvar Stepanyan", 69 | "screen_name": "RReverser", 70 | "location": "Ukraine ➩ London", 71 | "description": "Obsessed D2D programmer (parsers, compilers, tools & specs), speaker and performance engineer. Currently speeding your code up at @Cloudflare.", 72 | "url": "https://t.co/34lsgMWk6d", 73 | "entities": { 74 | "url": { 75 | "urls": [ 76 | { 77 | "url": "https://t.co/34lsgMWk6d", 78 | "expanded_url": "https://rreverser.com/", 79 | "display_url": "rreverser.com", 80 | "indices": [ 81 | 0, 82 | 23 83 | ] 84 | } 85 | ] 86 | }, 87 | "description": { 88 | "urls": [] 89 | } 90 | }, 91 | "protected": false, 92 | "followers_count": 3263, 93 | "friends_count": 560, 94 | "listed_count": 196, 95 | "created_at": "Thu Dec 17 18:11:55 +0000 2009", 96 | "favourites_count": 11264, 97 | "utc_offset": 3600, 98 | "time_zone": "London", 99 | "geo_enabled": true, 100 | "verified": false, 101 | "statuses_count": 22719, 102 | "lang": "en", 103 | "contributors_enabled": false, 104 | "is_translator": false, 105 | "is_translation_enabled": false, 106 | "profile_background_color": "1F1F1F", 107 | "profile_background_image_url": "http://pbs.twimg.com/profile_background_images/288087658/RSOph_hi_1.jpg", 108 | "profile_background_image_url_https": "https://pbs.twimg.com/profile_background_images/288087658/RSOph_hi_1.jpg", 109 | "profile_background_tile": true, 110 | "profile_image_url": "http://pbs.twimg.com/profile_images/692873581496152064/rYPVgOqN_normal.jpg", 111 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/692873581496152064/rYPVgOqN_normal.jpg", 112 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/97495292/1454029075", 113 | "profile_link_color": "3B94D9", 114 | "profile_sidebar_border_color": "2E0000", 115 | "profile_sidebar_fill_color": "800000", 116 | "profile_text_color": "616161", 117 | "profile_use_background_image": true, 118 | "has_extended_profile": true, 119 | "default_profile": false, 120 | "default_profile_image": false, 121 | "following": false, 122 | "follow_request_sent": false, 123 | "notifications": false, 124 | "translator_type": "none" 125 | }, 126 | "geo": null, 127 | "coordinates": null, 128 | "place": null, 129 | "contributors": null, 130 | "is_quote_status": false, 131 | "retweet_count": 29, 132 | "favorite_count": 61, 133 | "favorited": false, 134 | "retweeted": false, 135 | "possibly_sensitive": false, 136 | "possibly_sensitive_appealable": false, 137 | "lang": "en" 138 | } 139 | -------------------------------------------------------------------------------- /src/pages/Test/data/mentions.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Fri Apr 28 00:36:35 +0000 2017", 3 | "id": 857755389042896900, 4 | "id_str": "857755389042896897", 5 | "text": "I'm looking for a well structured OS project that uses React, React Router, Redux and has extensive test suite. @JakeGinnivan @dan_abramov", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [ 11 | { 12 | "screen_name": "JakeGinnivan", 13 | "name": "Jake Ginnivan", 14 | "id": 14877480, 15 | "id_str": "14877480", 16 | "indices": [ 17 | 113, 18 | 126 19 | ] 20 | }, 21 | { 22 | "screen_name": "dan_abramov", 23 | "name": "Dan Abramov", 24 | "id": 70345946, 25 | "id_str": "70345946", 26 | "indices": [ 27 | 127, 28 | 139 29 | ] 30 | } 31 | ], 32 | "urls": [] 33 | }, 34 | "source": "Twitter Web Client", 35 | "in_reply_to_status_id": null, 36 | "in_reply_to_status_id_str": null, 37 | "in_reply_to_user_id": null, 38 | "in_reply_to_user_id_str": null, 39 | "in_reply_to_screen_name": null, 40 | "user": { 41 | "id": 64386027, 42 | "id_str": "64386027", 43 | "name": "Pawel Pabich", 44 | "screen_name": "PawelPabich", 45 | "location": "Brisbane", 46 | "description": "Passionate problem solver. Beginner surfer. Dad. BBQ lover. Helping build better software @OctopusDeploy. Opinions are my own.", 47 | "url": "https://t.co/KXZ0Hsjp8f", 48 | "entities": { 49 | "url": { 50 | "urls": [ 51 | { 52 | "url": "https://t.co/KXZ0Hsjp8f", 53 | "expanded_url": "http://www.pabich.eu", 54 | "display_url": "pabich.eu", 55 | "indices": [ 56 | 0, 57 | 23 58 | ] 59 | } 60 | ] 61 | }, 62 | "description": { 63 | "urls": [] 64 | } 65 | }, 66 | "protected": false, 67 | "followers_count": 522, 68 | "friends_count": 224, 69 | "listed_count": 30, 70 | "created_at": "Mon Aug 10 11:54:11 +0000 2009", 71 | "favourites_count": 43, 72 | "utc_offset": 36000, 73 | "time_zone": "Sydney", 74 | "geo_enabled": true, 75 | "verified": false, 76 | "statuses_count": 6059, 77 | "lang": "en", 78 | "contributors_enabled": false, 79 | "is_translator": false, 80 | "is_translation_enabled": false, 81 | "profile_background_color": "C0DEED", 82 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 83 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 84 | "profile_background_tile": false, 85 | "profile_image_url": "http://pbs.twimg.com/profile_images/1482776084/PawelMainSquare_normal.jpg", 86 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/1482776084/PawelMainSquare_normal.jpg", 87 | "profile_link_color": "1DA1F2", 88 | "profile_sidebar_border_color": "C0DEED", 89 | "profile_sidebar_fill_color": "DDEEF6", 90 | "profile_text_color": "333333", 91 | "profile_use_background_image": true, 92 | "has_extended_profile": false, 93 | "default_profile": true, 94 | "default_profile_image": false, 95 | "following": false, 96 | "follow_request_sent": false, 97 | "notifications": false, 98 | "translator_type": "none" 99 | }, 100 | "geo": null, 101 | "coordinates": null, 102 | "place": { 103 | "id": "004ec16c62325149", 104 | "url": "https://api.twitter.com/1.1/geo/id/004ec16c62325149.json", 105 | "place_type": "city", 106 | "name": "Brisbane", 107 | "full_name": "Brisbane, Queensland", 108 | "country_code": "AU", 109 | "country": "Australia", 110 | "contained_within": [], 111 | "bounding_box": { 112 | "type": "Polygon", 113 | "coordinates": [ 114 | [ 115 | [ 116 | 152.668522848, 117 | -27.767440994 118 | ], 119 | [ 120 | 153.31787024, 121 | -27.767440994 122 | ], 123 | [ 124 | 153.31787024, 125 | -26.996844991 126 | ], 127 | [ 128 | 152.668522848, 129 | -26.996844991 130 | ] 131 | ] 132 | ] 133 | }, 134 | "attributes": {} 135 | }, 136 | "contributors": null, 137 | "is_quote_status": false, 138 | "retweet_count": 4, 139 | "favorite_count": 35, 140 | "favorited": false, 141 | "retweeted": false, 142 | "lang": "en" 143 | } 144 | -------------------------------------------------------------------------------- /src/pages/Test/data/replying-to.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 30 17:41:49 +0000 2017", 3 | "id": 858738172624162800, 4 | "id_str": "858738172624162816", 5 | "text": "@dan_abramov @andrewingram @AdamRackis @knitcodemonkey I think the docs are great, no reason to bring up how to \"br… https://t.co/KXcISzJz2A", 6 | "truncated": true, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [ 11 | { 12 | "screen_name": "dan_abramov", 13 | "name": "Dan Abramov", 14 | "id": 70345946, 15 | "id_str": "70345946", 16 | "indices": [ 17 | 0, 18 | 12 19 | ] 20 | }, 21 | { 22 | "screen_name": "andrewingram", 23 | "name": "andrewingram", 24 | "id": 9164512, 25 | "id_str": "9164512", 26 | "indices": [ 27 | 13, 28 | 26 29 | ] 30 | }, 31 | { 32 | "screen_name": "AdamRackis", 33 | "name": "Adam Rackis", 34 | "id": 68567860, 35 | "id_str": "68567860", 36 | "indices": [ 37 | 27, 38 | 38 39 | ] 40 | }, 41 | { 42 | "screen_name": "knitcodemonkey", 43 | "name": "Jen Luker", 44 | "id": 769375158381318100, 45 | "id_str": "769375158381318144", 46 | "indices": [ 47 | 39, 48 | 54 49 | ] 50 | } 51 | ], 52 | "urls": [ 53 | { 54 | "url": "https://t.co/KXcISzJz2A", 55 | "expanded_url": "https://twitter.com/i/web/status/858738172624162816", 56 | "display_url": "twitter.com/i/web/status/8…", 57 | "indices": [ 58 | 117, 59 | 140 60 | ] 61 | } 62 | ] 63 | }, 64 | "source": "Twitter Web Client", 65 | "in_reply_to_status_id": 858737668598976500, 66 | "in_reply_to_status_id_str": "858737668598976512", 67 | "in_reply_to_user_id": 70345946, 68 | "in_reply_to_user_id_str": "70345946", 69 | "in_reply_to_screen_name": "dan_abramov", 70 | "user": { 71 | "id": 16468446, 72 | "id_str": "16468446", 73 | "name": "Ryan Florence 👍🏼", 74 | "screen_name": "ryanflorence", 75 | "location": "Seattle, WA", 76 | "description": "Co-Author React Router, Co-Owner React Training. Junior Developer for Life.", 77 | "url": "https://t.co/4rXcvLLxP8", 78 | "entities": { 79 | "url": { 80 | "urls": [ 81 | { 82 | "url": "https://t.co/4rXcvLLxP8", 83 | "expanded_url": "http://reacttraining.com", 84 | "display_url": "reacttraining.com", 85 | "indices": [ 86 | 0, 87 | 23 88 | ] 89 | } 90 | ] 91 | }, 92 | "description": { 93 | "urls": [] 94 | } 95 | }, 96 | "protected": false, 97 | "followers_count": 22724, 98 | "friends_count": 246, 99 | "listed_count": 941, 100 | "created_at": "Fri Sep 26 14:41:39 +0000 2008", 101 | "favourites_count": 5148, 102 | "utc_offset": -21600, 103 | "time_zone": "Mountain Time (US & Canada)", 104 | "geo_enabled": false, 105 | "verified": false, 106 | "statuses_count": 32599, 107 | "lang": "en", 108 | "contributors_enabled": false, 109 | "is_translator": false, 110 | "is_translation_enabled": false, 111 | "profile_background_color": "C0DEED", 112 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 113 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 114 | "profile_background_tile": false, 115 | "profile_image_url": "http://pbs.twimg.com/profile_images/833804425822998529/Ng6B18iX_normal.jpg", 116 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/833804425822998529/Ng6B18iX_normal.jpg", 117 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/16468446/1487701699", 118 | "profile_link_color": "1DA1F2", 119 | "profile_sidebar_border_color": "C0DEED", 120 | "profile_sidebar_fill_color": "DDEEF6", 121 | "profile_text_color": "333333", 122 | "profile_use_background_image": true, 123 | "has_extended_profile": true, 124 | "default_profile": true, 125 | "default_profile_image": false, 126 | "following": true, 127 | "follow_request_sent": false, 128 | "notifications": false, 129 | "translator_type": "none" 130 | }, 131 | "geo": null, 132 | "coordinates": null, 133 | "place": null, 134 | "contributors": null, 135 | "is_quote_status": false, 136 | "retweet_count": 0, 137 | "favorite_count": 2, 138 | "favorited": false, 139 | "retweeted": false, 140 | "lang": "en" 141 | } 142 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import ExtractTextPlugin from 'extract-text-webpack-plugin'; 3 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import autoprefixer from 'autoprefixer'; 5 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 6 | import ReplacePlugin from 'replace-bundle-webpack-plugin'; 7 | import OfflinePlugin from 'offline-plugin'; 8 | import path from 'path'; 9 | import V8LazyParseWebpackPlugin from 'v8-lazy-parse-webpack-plugin'; 10 | const ENV = process.env.NODE_ENV || 'development'; 11 | 12 | const CSS_MAPS = ENV!=='production'; 13 | 14 | module.exports = { 15 | context: path.resolve(__dirname, "src"), 16 | entry: './index.js', 17 | 18 | output: { 19 | path: path.resolve(__dirname, "build"), 20 | publicPath: '/', 21 | filename: 'bundle.js' 22 | }, 23 | 24 | resolve: { 25 | extensions: ['', '.jsx', '.js', '.json', '.less'], 26 | modulesDirectories: [ 27 | path.resolve(__dirname, "src/lib"), 28 | path.resolve(__dirname, "node_modules"), 29 | 'node_modules' 30 | ], 31 | alias: { 32 | components: path.resolve(__dirname, "src/components"), // used for tests 33 | style: path.resolve(__dirname, "src/style"), 34 | 'react': 'preact-compat', 35 | 'react-dom': 'preact-compat' 36 | } 37 | }, 38 | 39 | module: { 40 | preLoaders: [ 41 | { 42 | test: /\.jsx?$/, 43 | exclude: path.resolve(__dirname, 'src'), 44 | loader: 'source-map-loader' 45 | } 46 | ], 47 | loaders: [ 48 | { 49 | test: /\.jsx?$/, 50 | exclude: /node_modules/, 51 | loader: 'babel-loader' 52 | }, 53 | { 54 | // Transform our own .(less|css) files with PostCSS and CSS-modules 55 | test: /\.(less|css)$/, 56 | include: [ 57 | path.resolve(__dirname, 'src/components'), 58 | path.resolve(__dirname, 'src/pages') 59 | ], 60 | loader: ExtractTextPlugin.extract('style?singleton', [ 61 | `css-loader?modules&importLoaders=1&sourceMap=${CSS_MAPS}`, 62 | `postcss-loader`, 63 | `less-loader?sourceMap=${CSS_MAPS}` 64 | ].join('!')) 65 | }, 66 | { 67 | test: /\.(less|css)$/, 68 | exclude: [ 69 | path.resolve(__dirname, 'src/components'), 70 | path.resolve(__dirname, 'src/pages') 71 | ], 72 | loader: ExtractTextPlugin.extract('style?singleton', [ 73 | `css-loader?sourceMap=${CSS_MAPS}`, 74 | `postcss-loader`, 75 | `less-loader?sourceMap=${CSS_MAPS}` 76 | ].join('!')) 77 | }, 78 | { 79 | test: /\.json$/, 80 | loader: 'json-loader' 81 | }, 82 | { 83 | test: /\.(xml|html|txt|md)$/, 84 | loader: 'raw-loader' 85 | }, 86 | { 87 | test: /\.(svg|woff2?|ttf|eot|jpe?g|png|gif|ico)(\?.*)?$/i, 88 | loader: ENV==='production' ? 'file-loader' : 'url-loader' 89 | } 90 | ] 91 | }, 92 | 93 | postcss: () => [ 94 | autoprefixer({ browsers: 'last 2 versions' }) 95 | ], 96 | 97 | plugins: ([ 98 | new webpack.NoErrorsPlugin(), 99 | new ExtractTextPlugin('style.css', { 100 | allChunks: true, 101 | disable: ENV!=='production' 102 | }), 103 | new webpack.DefinePlugin({ 104 | 'process.env.NODE_ENV': JSON.stringify(ENV) 105 | }), 106 | new HtmlWebpackPlugin({ 107 | template: './index.ejs', 108 | minify: { collapseWhitespace: true } 109 | }), 110 | new CopyWebpackPlugin([ 111 | { from: './manifest.json', to: './' }, 112 | { from: './assets/favicon.ico', to: './' } 113 | ]) 114 | ]).concat(ENV==='production' ? [ 115 | new V8LazyParseWebpackPlugin(), 116 | new webpack.optimize.UglifyJsPlugin({ 117 | output: { 118 | comments: false 119 | }, 120 | compress: { 121 | warnings: false, 122 | conditionals: true, 123 | unused: true, 124 | comparisons: true, 125 | sequences: true, 126 | dead_code: true, 127 | evaluate: true, 128 | if_return: true, 129 | join_vars: true, 130 | negate_iife: false 131 | } 132 | }), 133 | 134 | // strip out babel-helper invariant checks 135 | new ReplacePlugin([{ 136 | // this is actually the property name https://github.com/kimhou/replace-bundle-webpack-plugin/issues/1 137 | partten: /throw\s+(new\s+)?[a-zA-Z]+Error\s*\(/g, 138 | replacement: () => 'return;(' 139 | }]), 140 | new OfflinePlugin({ 141 | relativePaths: false, 142 | AppCache: false, 143 | excludes: ['_redirects'], 144 | ServiceWorker: { 145 | events: true 146 | }, 147 | cacheMaps: [ 148 | { 149 | match: /.*/, 150 | to: '/', 151 | requestTypes: ['navigate'] 152 | } 153 | ], 154 | publicPath: '/' 155 | }) 156 | ] : []), 157 | 158 | stats: { colors: true }, 159 | 160 | node: { 161 | global: true, 162 | process: false, 163 | Buffer: false, 164 | __filename: false, 165 | __dirname: false, 166 | setImmediate: false 167 | }, 168 | 169 | devtool: ENV==='production' ? 'source-map' : 'cheap-module-eval-source-map', 170 | 171 | devServer: { 172 | port: process.env.PORT || 5000, 173 | host: 'localhost', 174 | colors: true, 175 | publicPath: '/', 176 | contentBase: './src', 177 | historyApiFallback: true, 178 | open: true, 179 | proxy: { 180 | // OPTIONAL: proxy configuration: 181 | // '/optional-prefix/**': { // path pattern to rewrite 182 | // target: 'http://target-host.com', 183 | // pathRewrite: path => path.replace(/^\/[^\/]+\//, '') // strip first path segment 184 | // } 185 | } 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /src/pages/Test/data/image.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 30 16:44:31 +0000 2017", 3 | "id": 858723752699351000, 4 | "id_str": "858723752699351040", 5 | "text": "This Gatsby's patented \"Stop coding and play with me\" move https://t.co/WRLVjcEO5c", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [], 11 | "urls": [], 12 | "media": [ 13 | { 14 | "id": 858723739172610000, 15 | "id_str": "858723739172610049", 16 | "indices": [ 17 | 59, 18 | 82 19 | ], 20 | "media_url": "http://pbs.twimg.com/media/C-rMsOmUIAEMpNH.jpg", 21 | "media_url_https": "https://pbs.twimg.com/media/C-rMsOmUIAEMpNH.jpg", 22 | "url": "https://t.co/WRLVjcEO5c", 23 | "display_url": "pic.twitter.com/WRLVjcEO5c", 24 | "expanded_url": "https://twitter.com/brian_d_vaughn/status/858723752699351040/photo/1", 25 | "type": "photo", 26 | "sizes": { 27 | "small": { 28 | "w": 680, 29 | "h": 510, 30 | "resize": "fit" 31 | }, 32 | "medium": { 33 | "w": 1200, 34 | "h": 900, 35 | "resize": "fit" 36 | }, 37 | "thumb": { 38 | "w": 150, 39 | "h": 150, 40 | "resize": "crop" 41 | }, 42 | "large": { 43 | "w": 2048, 44 | "h": 1536, 45 | "resize": "fit" 46 | } 47 | } 48 | } 49 | ] 50 | }, 51 | "extended_entities": { 52 | "media": [ 53 | { 54 | "id": 858723739172610000, 55 | "id_str": "858723739172610049", 56 | "indices": [ 57 | 59, 58 | 82 59 | ], 60 | "media_url": "http://pbs.twimg.com/media/C-rMsOmUIAEMpNH.jpg", 61 | "media_url_https": "https://pbs.twimg.com/media/C-rMsOmUIAEMpNH.jpg", 62 | "url": "https://t.co/WRLVjcEO5c", 63 | "display_url": "pic.twitter.com/WRLVjcEO5c", 64 | "expanded_url": "https://twitter.com/brian_d_vaughn/status/858723752699351040/photo/1", 65 | "type": "photo", 66 | "sizes": { 67 | "small": { 68 | "w": 680, 69 | "h": 510, 70 | "resize": "fit" 71 | }, 72 | "medium": { 73 | "w": 1200, 74 | "h": 900, 75 | "resize": "fit" 76 | }, 77 | "thumb": { 78 | "w": 150, 79 | "h": 150, 80 | "resize": "crop" 81 | }, 82 | "large": { 83 | "w": 2048, 84 | "h": 1536, 85 | "resize": "fit" 86 | } 87 | } 88 | } 89 | ] 90 | }, 91 | "source": "Twitter for Android", 92 | "in_reply_to_status_id": null, 93 | "in_reply_to_status_id_str": null, 94 | "in_reply_to_user_id": null, 95 | "in_reply_to_user_id_str": null, 96 | "in_reply_to_screen_name": null, 97 | "user": { 98 | "id": 99973385, 99 | "id_str": "99973385", 100 | "name": "Brian Vaughn", 101 | "screen_name": "brian_d_vaughn", 102 | "location": "Mountain View, CA", 103 | "description": "Creating software & music. @reactjs core team @facebook. Author of react-virtualized. Formerly at @treasuredata and @google.", 104 | "url": "https://t.co/Cqwto8T77X", 105 | "entities": { 106 | "url": { 107 | "urls": [ 108 | { 109 | "url": "https://t.co/Cqwto8T77X", 110 | "expanded_url": "http://www.briandavidvaughn.com", 111 | "display_url": "briandavidvaughn.com", 112 | "indices": [ 113 | 0, 114 | 23 115 | ] 116 | } 117 | ] 118 | }, 119 | "description": { 120 | "urls": [] 121 | } 122 | }, 123 | "protected": false, 124 | "followers_count": 2611, 125 | "friends_count": 95, 126 | "listed_count": 105, 127 | "created_at": "Mon Dec 28 15:12:52 +0000 2009", 128 | "favourites_count": 4609, 129 | "utc_offset": -18000, 130 | "time_zone": "Central Time (US & Canada)", 131 | "geo_enabled": false, 132 | "verified": false, 133 | "statuses_count": 5060, 134 | "lang": "en", 135 | "contributors_enabled": false, 136 | "is_translator": false, 137 | "is_translation_enabled": false, 138 | "profile_background_color": "C0DEED", 139 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 140 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 141 | "profile_background_tile": false, 142 | "profile_image_url": "http://pbs.twimg.com/profile_images/788507837991313408/ZqqOwob8_normal.jpg", 143 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/788507837991313408/ZqqOwob8_normal.jpg", 144 | "profile_link_color": "1DA1F2", 145 | "profile_sidebar_border_color": "C0DEED", 146 | "profile_sidebar_fill_color": "DDEEF6", 147 | "profile_text_color": "333333", 148 | "profile_use_background_image": true, 149 | "has_extended_profile": false, 150 | "default_profile": true, 151 | "default_profile_image": false, 152 | "following": false, 153 | "follow_request_sent": false, 154 | "notifications": false, 155 | "translator_type": "none" 156 | }, 157 | "geo": null, 158 | "coordinates": null, 159 | "place": null, 160 | "contributors": null, 161 | "is_quote_status": false, 162 | "retweet_count": 0, 163 | "favorite_count": 18, 164 | "favorited": false, 165 | "retweeted": false, 166 | "possibly_sensitive": false, 167 | "possibly_sensitive_appealable": false, 168 | "lang": "en" 169 | } 170 | -------------------------------------------------------------------------------- /src/pages/Test/data/verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Fri Apr 28 21:00:50 +0000 2017", 3 | "id": 858063480682725400, 4 | "id_str": "858063480682725376", 5 | "text": "2¹²\nI like this https://t.co/qWOB9vCG1I", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [], 11 | "urls": [], 12 | "media": [ 13 | { 14 | "id": 858063094358003700, 15 | "id_str": "858063094358003712", 16 | "indices": [ 17 | 16, 18 | 39 19 | ], 20 | "media_url": "http://pbs.twimg.com/media/C-hz1o4U0AAGUkK.jpg", 21 | "media_url_https": "https://pbs.twimg.com/media/C-hz1o4U0AAGUkK.jpg", 22 | "url": "https://t.co/qWOB9vCG1I", 23 | "display_url": "pic.twitter.com/qWOB9vCG1I", 24 | "expanded_url": "https://twitter.com/rauchg/status/858063480682725376/photo/1", 25 | "type": "photo", 26 | "sizes": { 27 | "small": { 28 | "w": 680, 29 | "h": 372, 30 | "resize": "fit" 31 | }, 32 | "thumb": { 33 | "w": 150, 34 | "h": 150, 35 | "resize": "crop" 36 | }, 37 | "large": { 38 | "w": 702, 39 | "h": 384, 40 | "resize": "fit" 41 | }, 42 | "medium": { 43 | "w": 702, 44 | "h": 384, 45 | "resize": "fit" 46 | } 47 | } 48 | } 49 | ] 50 | }, 51 | "extended_entities": { 52 | "media": [ 53 | { 54 | "id": 858063094358003700, 55 | "id_str": "858063094358003712", 56 | "indices": [ 57 | 16, 58 | 39 59 | ], 60 | "media_url": "http://pbs.twimg.com/media/C-hz1o4U0AAGUkK.jpg", 61 | "media_url_https": "https://pbs.twimg.com/media/C-hz1o4U0AAGUkK.jpg", 62 | "url": "https://t.co/qWOB9vCG1I", 63 | "display_url": "pic.twitter.com/qWOB9vCG1I", 64 | "expanded_url": "https://twitter.com/rauchg/status/858063480682725376/photo/1", 65 | "type": "photo", 66 | "sizes": { 67 | "small": { 68 | "w": 680, 69 | "h": 372, 70 | "resize": "fit" 71 | }, 72 | "thumb": { 73 | "w": 150, 74 | "h": 150, 75 | "resize": "crop" 76 | }, 77 | "large": { 78 | "w": 702, 79 | "h": 384, 80 | "resize": "fit" 81 | }, 82 | "medium": { 83 | "w": 702, 84 | "h": 384, 85 | "resize": "fit" 86 | } 87 | } 88 | } 89 | ] 90 | }, 91 | "source": "Twitter Web Client", 92 | "in_reply_to_status_id": null, 93 | "in_reply_to_status_id_str": null, 94 | "in_reply_to_user_id": null, 95 | "in_reply_to_user_id_str": null, 96 | "in_reply_to_screen_name": null, 97 | "user": { 98 | "id": 15540222, 99 | "id_str": "15540222", 100 | "name": "Guillermo Rauch", 101 | "screen_name": "rauchg", 102 | "location": "SF", 103 | "description": "@zeithq", 104 | "url": "http://t.co/CCq1K8vos8", 105 | "entities": { 106 | "url": { 107 | "urls": [ 108 | { 109 | "url": "http://t.co/CCq1K8vos8", 110 | "expanded_url": "http://rauchg.com", 111 | "display_url": "rauchg.com", 112 | "indices": [ 113 | 0, 114 | 22 115 | ] 116 | } 117 | ] 118 | }, 119 | "description": { 120 | "urls": [] 121 | } 122 | }, 123 | "protected": false, 124 | "followers_count": 27407, 125 | "friends_count": 961, 126 | "listed_count": 1324, 127 | "created_at": "Tue Jul 22 22:54:37 +0000 2008", 128 | "favourites_count": 10922, 129 | "utc_offset": -25200, 130 | "time_zone": "Pacific Time (US & Canada)", 131 | "geo_enabled": true, 132 | "verified": true, 133 | "statuses_count": 16347, 134 | "lang": "en", 135 | "contributors_enabled": false, 136 | "is_translator": false, 137 | "is_translation_enabled": false, 138 | "profile_background_color": "131516", 139 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", 140 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", 141 | "profile_background_tile": true, 142 | "profile_image_url": "http://pbs.twimg.com/profile_images/721536199681318912/rflwVOZ8_normal.jpg", 143 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/721536199681318912/rflwVOZ8_normal.jpg", 144 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/15540222/1483048949", 145 | "profile_link_color": "000000", 146 | "profile_sidebar_border_color": "EEEEEE", 147 | "profile_sidebar_fill_color": "EFEFEF", 148 | "profile_text_color": "333333", 149 | "profile_use_background_image": true, 150 | "has_extended_profile": true, 151 | "default_profile": false, 152 | "default_profile_image": false, 153 | "following": true, 154 | "follow_request_sent": false, 155 | "notifications": false, 156 | "translator_type": "none" 157 | }, 158 | "geo": null, 159 | "coordinates": null, 160 | "place": { 161 | "id": "5a110d312052166f", 162 | "url": "https://api.twitter.com/1.1/geo/id/5a110d312052166f.json", 163 | "place_type": "city", 164 | "name": "San Francisco", 165 | "full_name": "San Francisco, CA", 166 | "country_code": "US", 167 | "country": "United States", 168 | "contained_within": [], 169 | "bounding_box": { 170 | "type": "Polygon", 171 | "coordinates": [ 172 | [ 173 | [ 174 | -122.514926, 175 | 37.708075 176 | ], 177 | [ 178 | -122.357031, 179 | 37.708075 180 | ], 181 | [ 182 | -122.357031, 183 | 37.833238 184 | ], 185 | [ 186 | -122.514926, 187 | 37.833238 188 | ] 189 | ] 190 | ] 191 | }, 192 | "attributes": {} 193 | }, 194 | "contributors": null, 195 | "is_quote_status": false, 196 | "retweet_count": 2, 197 | "favorite_count": 42, 198 | "favorited": true, 199 | "retweeted": false, 200 | "possibly_sensitive": false, 201 | "possibly_sensitive_appealable": false, 202 | "lang": "en" 203 | } 204 | -------------------------------------------------------------------------------- /src/pages/Test/data/tweet-with-link.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 23 15:44:25 +0000 2017", 3 | "id": 856171912724086800, 4 | "id_str": "856171912724086786", 5 | "text": "#patrimoniodetodos lee tanto como puedas,aprende,disfruta comparte un buen libro. Apaga la 📺 y el 📱 descubrirás tan… https://t.co/qlycg6vYOK", 6 | "display_text_range": [ 7 | 0, 8 | 140 9 | ], 10 | "source": "Twitter for iPhone", 11 | "truncated": true, 12 | "in_reply_to_status_id": null, 13 | "in_reply_to_status_id_str": null, 14 | "in_reply_to_user_id": null, 15 | "in_reply_to_user_id_str": null, 16 | "in_reply_to_screen_name": null, 17 | "user": { 18 | "id": 3428284210, 19 | "id_str": "3428284210", 20 | "name": "Ana", 21 | "screen_name": "berbikinacurri", 22 | "location": null, 23 | "url": null, 24 | "description": null, 25 | "protected": false, 26 | "verified": false, 27 | "followers_count": 152, 28 | "friends_count": 330, 29 | "listed_count": 2, 30 | "favourites_count": 4519, 31 | "statuses_count": 11013, 32 | "created_at": "Mon Aug 17 14:44:31 +0000 2015", 33 | "utc_offset": null, 34 | "time_zone": null, 35 | "geo_enabled": true, 36 | "lang": "es", 37 | "contributors_enabled": false, 38 | "is_translator": false, 39 | "profile_background_color": "C0DEED", 40 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 41 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 42 | "profile_background_tile": false, 43 | "profile_link_color": "1DA1F2", 44 | "profile_sidebar_border_color": "C0DEED", 45 | "profile_sidebar_fill_color": "DDEEF6", 46 | "profile_text_color": "333333", 47 | "profile_use_background_image": true, 48 | "profile_image_url": "http://pbs.twimg.com/profile_images/846816178085466113/aYuntLYC_normal.jpg", 49 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/846816178085466113/aYuntLYC_normal.jpg", 50 | "default_profile": true, 51 | "default_profile_image": false, 52 | "following": null, 53 | "follow_request_sent": null, 54 | "notifications": null 55 | }, 56 | "geo": null, 57 | "coordinates": null, 58 | "place": { 59 | "id": "c113c1a6a18446bb", 60 | "url": "https://api.twitter.com/1.1/geo/id/c113c1a6a18446bb.json", 61 | "place_type": "city", 62 | "name": "Fuenlabrada", 63 | "full_name": "Fuenlabrada, España", 64 | "country_code": "ES", 65 | "country": "España", 66 | "bounding_box": { 67 | "type": "Polygon", 68 | "coordinates": [ 69 | [ 70 | [ 71 | -3.844073, 72 | 40.249636 73 | ], 74 | [ 75 | -3.844073, 76 | 40.327769 77 | ], 78 | [ 79 | -3.739655, 80 | 40.327769 81 | ], 82 | [ 83 | -3.739655, 84 | 40.249636 85 | ] 86 | ] 87 | ] 88 | }, 89 | "attributes": {} 90 | }, 91 | "contributors": null, 92 | "is_quote_status": false, 93 | "extended_tweet": { 94 | "full_text": "#patrimoniodetodos lee tanto como puedas,aprende,disfruta comparte un buen libro. Apaga la 📺 y el 📱 descubrirás tantas cosas ... https://t.co/LwbjCQamu8", 95 | "display_text_range": [ 96 | 0, 97 | 128 98 | ], 99 | "entities": { 100 | "hashtags": [ 101 | { 102 | "text": "patrimoniodetodos", 103 | "indices": [ 104 | 0, 105 | 18 106 | ] 107 | } 108 | ], 109 | "urls": [], 110 | "user_mentions": [], 111 | "symbols": [], 112 | "media": [ 113 | { 114 | "id": 856171904817913900, 115 | "id_str": "856171904817913856", 116 | "indices": [ 117 | 129, 118 | 152 119 | ], 120 | "media_url": "http://pbs.twimg.com/media/C-G7z61XYAAmk2X.jpg", 121 | "media_url_https": "https://pbs.twimg.com/media/C-G7z61XYAAmk2X.jpg", 122 | "url": "https://t.co/LwbjCQamu8", 123 | "display_url": "pic.twitter.com/LwbjCQamu8", 124 | "expanded_url": "https://twitter.com/berbikinacurri/status/856171912724086786/photo/1", 125 | "type": "photo", 126 | "sizes": { 127 | "large": { 128 | "w": 500, 129 | "h": 511, 130 | "resize": "fit" 131 | }, 132 | "medium": { 133 | "w": 500, 134 | "h": 511, 135 | "resize": "fit" 136 | }, 137 | "thumb": { 138 | "w": 150, 139 | "h": 150, 140 | "resize": "crop" 141 | }, 142 | "small": { 143 | "w": 500, 144 | "h": 511, 145 | "resize": "fit" 146 | } 147 | } 148 | } 149 | ] 150 | }, 151 | "extended_entities": { 152 | "media": [ 153 | { 154 | "id": 856171904817913900, 155 | "id_str": "856171904817913856", 156 | "indices": [ 157 | 129, 158 | 152 159 | ], 160 | "media_url": "http://pbs.twimg.com/media/C-G7z61XYAAmk2X.jpg", 161 | "media_url_https": "https://pbs.twimg.com/media/C-G7z61XYAAmk2X.jpg", 162 | "url": "https://t.co/LwbjCQamu8", 163 | "display_url": "pic.twitter.com/LwbjCQamu8", 164 | "expanded_url": "https://twitter.com/berbikinacurri/status/856171912724086786/photo/1", 165 | "type": "photo", 166 | "sizes": { 167 | "large": { 168 | "w": 500, 169 | "h": 511, 170 | "resize": "fit" 171 | }, 172 | "medium": { 173 | "w": 500, 174 | "h": 511, 175 | "resize": "fit" 176 | }, 177 | "thumb": { 178 | "w": 150, 179 | "h": 150, 180 | "resize": "crop" 181 | }, 182 | "small": { 183 | "w": 500, 184 | "h": 511, 185 | "resize": "fit" 186 | } 187 | } 188 | } 189 | ] 190 | } 191 | }, 192 | "retweet_count": 0, 193 | "favorite_count": 0, 194 | "entities": { 195 | "hashtags": [ 196 | { 197 | "text": "patrimoniodetodos", 198 | "indices": [ 199 | 0, 200 | 18 201 | ] 202 | } 203 | ], 204 | "urls": [ 205 | { 206 | "url": "https://t.co/qlycg6vYOK", 207 | "expanded_url": "https://twitter.com/i/web/status/856171912724086786", 208 | "display_url": "twitter.com/i/web/status/8…", 209 | "indices": [ 210 | 117, 211 | 140 212 | ] 213 | } 214 | ], 215 | "user_mentions": [], 216 | "symbols": [] 217 | }, 218 | "favorited": false, 219 | "retweeted": false, 220 | "possibly_sensitive": false, 221 | "filter_level": "low", 222 | "lang": "es", 223 | "timestamp_ms": "1492962265555" 224 | } -------------------------------------------------------------------------------- /src/pages/Test/data/retweet.json: -------------------------------------------------------------------------------- 1 | { 2 | "created_at": "Sun Apr 30 18:18:40 +0000 2017", 3 | "id": 858747444057604100, 4 | "id_str": "858747444057604096", 5 | "text": "RT @jongold: One of the primary drivers for me joining Airbnb is that we introduce just enough screentime in your life to get you outside &…", 6 | "truncated": false, 7 | "entities": { 8 | "hashtags": [], 9 | "symbols": [], 10 | "user_mentions": [ 11 | { 12 | "screen_name": "jongold", 13 | "name": "@jongold", 14 | "id": 14199907, 15 | "id_str": "14199907", 16 | "indices": [ 17 | 3, 18 | 11 19 | ] 20 | } 21 | ], 22 | "urls": [] 23 | }, 24 | "source": "TweetDeck", 25 | "in_reply_to_status_id": null, 26 | "in_reply_to_status_id_str": null, 27 | "in_reply_to_user_id": null, 28 | "in_reply_to_user_id_str": null, 29 | "in_reply_to_screen_name": null, 30 | "user": { 31 | "id": 606973150, 32 | "id_str": "606973150", 33 | "name": "Leland Richardson", 34 | "screen_name": "intelligibabble", 35 | "location": "San Francisco, CA", 36 | "description": "Software Engineer @Airbnb. I like learning, discussing, and diving into challenges.", 37 | "url": "http://t.co/80xVNhoS6i", 38 | "entities": { 39 | "url": { 40 | "urls": [ 41 | { 42 | "url": "http://t.co/80xVNhoS6i", 43 | "expanded_url": "http://www.intelligiblebabble.com", 44 | "display_url": "intelligiblebabble.com", 45 | "indices": [ 46 | 0, 47 | 22 48 | ] 49 | } 50 | ] 51 | }, 52 | "description": { 53 | "urls": [] 54 | } 55 | }, 56 | "protected": false, 57 | "followers_count": 3662, 58 | "friends_count": 584, 59 | "listed_count": 167, 60 | "created_at": "Wed Jun 13 07:09:07 +0000 2012", 61 | "favourites_count": 3104, 62 | "utc_offset": -14400, 63 | "time_zone": "Eastern Time (US & Canada)", 64 | "geo_enabled": false, 65 | "verified": false, 66 | "statuses_count": 5068, 67 | "lang": "en", 68 | "contributors_enabled": false, 69 | "is_translator": false, 70 | "is_translation_enabled": false, 71 | "profile_background_color": "EBEBEB", 72 | "profile_background_image_url": "http://pbs.twimg.com/profile_background_images/742059665/e04e810c3a0ce82878f3285eda8408c9.png", 73 | "profile_background_image_url_https": "https://pbs.twimg.com/profile_background_images/742059665/e04e810c3a0ce82878f3285eda8408c9.png", 74 | "profile_background_tile": true, 75 | "profile_image_url": "http://pbs.twimg.com/profile_images/729167226691969024/43UcYrwM_normal.jpg", 76 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/729167226691969024/43UcYrwM_normal.jpg", 77 | "profile_banner_url": "https://pbs.twimg.com/profile_banners/606973150/1437081954", 78 | "profile_link_color": "990000", 79 | "profile_sidebar_border_color": "FFFFFF", 80 | "profile_sidebar_fill_color": "DDEEF6", 81 | "profile_text_color": "333333", 82 | "profile_use_background_image": true, 83 | "has_extended_profile": false, 84 | "default_profile": false, 85 | "default_profile_image": false, 86 | "following": true, 87 | "follow_request_sent": false, 88 | "notifications": false, 89 | "translator_type": "none" 90 | }, 91 | "geo": null, 92 | "coordinates": null, 93 | "place": null, 94 | "contributors": null, 95 | "retweeted_status": { 96 | "created_at": "Sun Apr 30 18:18:09 +0000 2017", 97 | "id": 858747317024735200, 98 | "id_str": "858747317024735232", 99 | "text": "One of the primary drivers for me joining Airbnb is that we introduce just enough screentime in your life to get you outside & exploring 🌎🌍🌏", 100 | "truncated": false, 101 | "entities": { 102 | "hashtags": [], 103 | "symbols": [], 104 | "user_mentions": [], 105 | "urls": [] 106 | }, 107 | "source": "Twitter for Android", 108 | "in_reply_to_status_id": 858747019862491100, 109 | "in_reply_to_status_id_str": "858747019862491136", 110 | "in_reply_to_user_id": 14199907, 111 | "in_reply_to_user_id_str": "14199907", 112 | "in_reply_to_screen_name": "jongold", 113 | "user": { 114 | "id": 14199907, 115 | "id_str": "14199907", 116 | "name": "@jongold", 117 | "screen_name": "jongold", 118 | "location": "California, USA", 119 | "description": "an equal command of technology and form • functional programming (oc)cultist • design tools, systems & AI @airbnbdesign • writing https://t.co/aXiyT3sAnQ", 120 | "url": "https://t.co/qOOlEarZ1u", 121 | "entities": { 122 | "url": { 123 | "urls": [ 124 | { 125 | "url": "https://t.co/qOOlEarZ1u", 126 | "expanded_url": "http://jon.gold", 127 | "display_url": "jon.gold", 128 | "indices": [ 129 | 0, 130 | 23 131 | ] 132 | } 133 | ] 134 | }, 135 | "description": { 136 | "urls": [ 137 | { 138 | "url": "https://t.co/aXiyT3sAnQ", 139 | "expanded_url": "http://jon.gold/txt", 140 | "display_url": "jon.gold/txt", 141 | "indices": [ 142 | 130, 143 | 153 144 | ] 145 | } 146 | ] 147 | } 148 | }, 149 | "protected": false, 150 | "followers_count": 3286, 151 | "friends_count": 1099, 152 | "listed_count": 535, 153 | "created_at": "Sun Mar 23 00:50:47 +0000 2008", 154 | "favourites_count": 48672, 155 | "utc_offset": -25200, 156 | "time_zone": "Pacific Time (US & Canada)", 157 | "geo_enabled": true, 158 | "verified": false, 159 | "statuses_count": 7038, 160 | "lang": "en", 161 | "contributors_enabled": false, 162 | "is_translator": false, 163 | "is_translation_enabled": false, 164 | "profile_background_color": "222222", 165 | "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 166 | "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 167 | "profile_background_tile": false, 168 | "profile_image_url": "http://pbs.twimg.com/profile_images/833785170285178881/loBb32g3_normal.jpg", 169 | "profile_image_url_https": "https://pbs.twimg.com/profile_images/833785170285178881/loBb32g3_normal.jpg", 170 | "profile_link_color": "C6A17D", 171 | "profile_sidebar_border_color": "FFFFFF", 172 | "profile_sidebar_fill_color": "01FFAF", 173 | "profile_text_color": "000000", 174 | "profile_use_background_image": false, 175 | "has_extended_profile": true, 176 | "default_profile": false, 177 | "default_profile_image": false, 178 | "following": false, 179 | "follow_request_sent": false, 180 | "notifications": false, 181 | "translator_type": "regular" 182 | }, 183 | "geo": null, 184 | "coordinates": null, 185 | "place": null, 186 | "contributors": null, 187 | "is_quote_status": false, 188 | "retweet_count": 1, 189 | "favorite_count": 2, 190 | "favorited": false, 191 | "retweeted": false, 192 | "lang": "en" 193 | }, 194 | "is_quote_status": false, 195 | "retweet_count": 1, 196 | "favorite_count": 0, 197 | "favorited": false, 198 | "retweeted": false, 199 | "lang": "en" 200 | } 201 | -------------------------------------------------------------------------------- /src/components/Tweet.js: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { h, Component } from 'preact'; 3 | import PropTypes from 'prop-types'; 4 | import shallowCompare from 'preact-shallow-compare'; 5 | import t from 'twitter-text'; 6 | import styles from './Tweet.css'; 7 | 8 | // TODO Support entities: https://dev.twitter.com/overview/api/entities 9 | // + inline media ~ extended_tweet.entities.media 10 | 11 | const ROUGH_ESTIMATE_IMAGE_WIDTH = 550; 12 | 13 | const AUTO_LINK_OPTIONS = { 14 | urlClass: styles.Link, 15 | listClass: styles.Link, 16 | usernameClass: styles.Link, 17 | hashtagClass: styles.Link, 18 | cashtagClass: styles.Link, 19 | }; 20 | 21 | export default class Tweet extends Component { 22 | static propTypes = { 23 | authenticated: PropTypes.bool.isRequired, 24 | disableMedia: PropTypes.bool.isRequired, 25 | isScrolling: PropTypes.bool.isRequired, 26 | tweet: PropTypes.object.isRequired 27 | }; 28 | 29 | constructor(props) { 30 | super(props); 31 | 32 | // Uncomment for debugging purposes 33 | //console.log(JSON.stringify(props.tweet, null, 2)) 34 | 35 | this.state = { 36 | mediaExpanded: false, 37 | }; 38 | } 39 | 40 | shouldComponentUpdate(nextProps, nextState) { 41 | return shallowCompare(this, nextProps, nextState); 42 | } 43 | 44 | render() { 45 | const { authenticated, disableMedia, isScrolling } = this.props; 46 | 47 | let tweet = this.props.tweet; 48 | let retweeter; 49 | if (tweet.retweeted_status) { 50 | retweeter = tweet.user; 51 | tweet = tweet.retweeted_status; 52 | } 53 | 54 | let text = tweet.extended_tweet 55 | ? tweet.extended_tweet.full_text 56 | : tweet.text; 57 | 58 | // Strip media placeholder text before converting remaining text to links, 59 | // Else the media indices will be misplaced. 60 | let media; 61 | if ( 62 | !disableMedia && 63 | tweet.extended_entities && 64 | tweet.extended_entities.media.length 65 | ) { 66 | media = tweet.extended_entities.media[0]; 67 | text = text.substr(0, media.indices[0]); 68 | } 69 | 70 | // Strip quoted status placeholder text too. 71 | const quotedStatus = tweet.quoted_status; 72 | let quotedStatusUrl; 73 | if ( 74 | quotedStatus && 75 | tweet.entities && 76 | tweet.entities.urls.length 77 | ) { 78 | quotedStatusUrl = tweet.entities.urls[0]; 79 | text = text.substr(0, quotedStatusUrl.indices[0]); 80 | } 81 | 82 | let replyingTo; 83 | if (text.indexOf('@') === 0) { 84 | const extractedMentions = t.extractMentionsWithIndices(text); 85 | const lastMention = extractedMentions[extractedMentions.length - 1]; 86 | 87 | text = text.substr(lastMention.indices[1] + 1); 88 | 89 | if (isScrolling) { 90 | replyingTo = extractedMentions.map( 91 | mention => `@${mention.screenName} ` 92 | ); 93 | } else { 94 | replyingTo = extractedMentions.reduce((reduced, mention) => { 95 | reduced.push( 96 | 100 | @{mention.screenName} 101 | 102 | ); 103 | reduced.push(' '); 104 | return reduced; 105 | }, []); 106 | } 107 | } 108 | 109 | if (!isScrolling) { 110 | text = t.autoLink(text, AUTO_LINK_OPTIONS); 111 | } 112 | 113 | // Upscale user profile images; for some reason the API sends blurry low-res pictures. 114 | let profileImageSource = tweet.user.profile_image_url_https; 115 | if (profileImageSource.indexOf('_normal.') >= 0) { 116 | profileImageSource = profileImageSource.replace('_normal.', '_bigger.'); 117 | } 118 | 119 | return ( 120 |
121 | {retweeter && 122 |
123 |