├── .eslintrc.json ├── .gitignore ├── LICENSE ├── OSSMETADATA ├── PRIVACY.md ├── README.md ├── build_extensions.sh ├── dist ├── extension_chrome │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ └── 48.png │ ├── manifest.json │ └── resources │ │ ├── images │ │ └── favicon.png │ │ ├── index.html │ │ ├── script │ │ ├── background.js │ │ ├── background.js.map │ │ ├── content.js │ │ ├── content.js.map │ │ ├── viewer.js │ │ └── viewer.js.map │ │ └── style.css ├── extension_firefox │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ └── 48.png │ ├── manifest.json │ └── resources │ │ ├── images │ │ └── favicon.png │ │ ├── index.html │ │ ├── script │ │ ├── background.js │ │ ├── background.js.map │ │ ├── content.js │ │ ├── content.js.map │ │ ├── viewer.js │ │ └── viewer.js.map │ │ └── style.css └── public │ ├── _redirects │ ├── icons │ ├── 128.png │ ├── 16.png │ ├── 256.png │ ├── 32.png │ └── 48.png │ ├── images │ ├── archive_mode.png │ ├── chrome_treeverse.gif │ ├── download_chrome.png │ ├── download_moz.png │ ├── favicon.png │ ├── logo.svg │ ├── moz_treeverse.gif │ ├── red_circles.png │ ├── right_pane.png │ ├── screenshot.png │ ├── treeverse.gif │ └── treeverse640.gif │ ├── index.html │ ├── style.css │ ├── treeverse.js │ ├── treeverse.js.map │ └── view │ └── index.html ├── extension ├── chrome │ ├── icons │ └── manifest.json ├── common │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ └── 48.png │ └── resources │ │ ├── images │ │ └── favicon.png │ │ ├── index.html │ │ └── style.css └── firefox │ ├── icons │ └── manifest.json ├── extension_chrome.zip ├── extension_firefox.zip ├── images ├── archive_mode.png ├── chrome_treeverse.gif ├── download_chrome.png ├── download_moz.png ├── logo.svg ├── moz_treeverse.gif ├── red_circles.png ├── right_pane.png ├── screenshot.png ├── treeverse.gif └── treeverse640.gif ├── lint.sh ├── package.json ├── src ├── background │ ├── chrome_action.ts │ ├── common.ts │ └── firefox_action.ts ├── content │ └── main.ts └── viewer │ ├── api.ts │ ├── feed_controller.ts │ ├── main.ts │ ├── page.ts │ ├── proxy.ts │ ├── serialize.ts │ ├── toolbar.ts │ ├── tweet_parser.ts │ ├── tweet_server.ts │ ├── tweet_tree.ts │ ├── tweet_visualization.ts │ ├── visualization_controller.ts │ └── web_entry.ts ├── tsconfig.json ├── watch.sh ├── web ├── _redirects ├── index.html └── view │ └── index.html ├── webpack.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "globals": { 7 | "chrome": false 8 | }, 9 | "extends": "eslint:recommended", 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 2018, 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-unused-vars": "off", 17 | "no-inner-declarations": [ 18 | "off" 19 | ], 20 | "indent": [ 21 | "error", 22 | 4 23 | ], 24 | "linebreak-style": [ 25 | "error", 26 | "unix" 27 | ], 28 | "quotes": [ 29 | "error", 30 | "single" 31 | ], 32 | "semi": [ 33 | "error", 34 | "never" 35 | ] 36 | } 37 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | #extension_*.zip 3 | #extension_common/resources/script 4 | #extension_chrome/resources/script 5 | #extension_firefox/resources/script 6 | .vscode 7 | #dist 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Paul Butler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=active 2 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy 2 | 3 | Treeverse runs entirely in your browser. No data is collected or tracked by Treeverse directly when you use or install it. Browser extension installs may be tracked by Google and Mozilla, and the data requests made to Twitter may be tracked by Twitter. 4 | 5 | When you create a sharable link, the data that you are sharing is sent to a server so that it can be made available to other users. Access to the shared Treeverse link is logged to prevent abuse. 6 | 7 | Additionally, when Treeverse runs it loads a font hosted by Google Fonts (https://fonts.google.com/). Google may track this download. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![NetflixOSS Lifecycle](https://img.shields.io/osslifecycle/paulgb/Treeverse.svg) 2 | 3 | ![Treeverse Icon](extension/common/icons/32.png) Treeverse 4 | ========= 5 | 6 | Treeverse is a tool for visualizing and navigating Twitter conversation threads. 7 | 8 | It is available as a browser extension for Chrome and Firefox. 9 | 10 | Installation 11 | ------------ 12 | 13 | ### Chrome Users: 14 | 15 | 16 | Download Treeverse for Chrome 17 | 18 | 19 | ### Firefox Users: 20 | 21 | 22 | Download Treeverse for Firefox 23 | 24 | 25 | Introduction 26 | ------------ 27 | 28 | After installing Treeverse for your browser, open Twitter and click on the tweet that you would like to visualize the conversation of (or try [this one](https://twitter.com/paulgb/status/977652162137395201).) 29 | 30 | If you’re using Chrome, the icon for Treeverse should turn from grey to blue in your browser. Click it to enter Treeverse. 31 | 32 | Opening Treeverse in Chrome 33 | 34 | If you're using Firefox, the icon will be hidden until you open a tweet, and then it will appear in the address bar. 35 | 36 | Opening Treeverse in Firefox 37 | 38 | Exploring the Conversation 39 | -------------------------- 40 | 41 | ![Screenshot of Treeverse.](images/treeverse640.gif) 42 | 43 | Conversations are visualized as a tree. Each box is an individual tweet, and 44 | an line between two boxes indicates that the lower one is a reply to the upper 45 | one. The color of the line indicates the time duration between the two tweets 46 | (red is faster, blue is slower.) 47 | 48 | As you hover over nodes, the reply-chain preceeding that tweet appears on the right-side 49 | pane. By clicking a node, you can freeze the UI on that tweet in order to interact with 50 | the right-side pane. By clicking anywhere in the tree window, you can un-freeze the tweet 51 | and return to the normal hover behavior. 52 | 53 | ![Right pane in action.](images/right_pane.png) 54 | 55 | Some tweets will appear with a red circle with white ellipses inside them, either overlayed 56 | on them or as a separate node. This means that 57 | there are more replies to that tweet that haven't been loaded. Double-clicking a node will 58 | load additional replies to that tweet. 59 | 60 | ![More tweets indicator.](images/red_circles.png) 61 | 62 | Privacy 63 | ------- 64 | 65 | Treeverse runs entirely in your browser. No data is collected or tracked by Treeverse directly 66 | when you use or install it. Browser extension installs may be tracked by Google and Mozilla, and the data 67 | requests made to Twitter may be tracked by Twitter. 68 | 69 | When you create a sharable link, the data is sent to a server so that it can be made available to other 70 | users. Access to the shared link server may be tracked to prevent abuse. 71 | 72 | Additionally, when Treeverse runs it loads a font hosted by Google Fonts (https://fonts.google.com/). Google may track this download. 73 | 74 | Bugs & Contact 75 | -------------- 76 | 77 | Tweet [@paulgb](https://twitter.com/paulgb) or [report on GitHub](https://github.com/paulgb/treeverse/issues). 78 | 79 | Credits 80 | ------- 81 | 82 | Icon created by [Eli Schiff](http://www.elischiff.com/). 83 | 84 | Treeverse would not be possible without the excellent [d3.js](https://d3js.org/). 85 | Styling is powered by [Semantic UI](http://semantic-ui.com/). 86 | -------------------------------------------------------------------------------- /build_extensions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | rm -f extension_*.zip 6 | 7 | ./node_modules/.bin/webpack 8 | 9 | cd dist/extension_chrome; zip -r ../../extension_chrome.zip *; cd ../../ 10 | 11 | cd dist/extension_firefox; zip -r ../../extension_firefox.zip *; cd ../../ 12 | -------------------------------------------------------------------------------- /dist/extension_chrome/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_chrome/icons/128.png -------------------------------------------------------------------------------- /dist/extension_chrome/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_chrome/icons/16.png -------------------------------------------------------------------------------- /dist/extension_chrome/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_chrome/icons/256.png -------------------------------------------------------------------------------- /dist/extension_chrome/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_chrome/icons/32.png -------------------------------------------------------------------------------- /dist/extension_chrome/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_chrome/icons/48.png -------------------------------------------------------------------------------- /dist/extension_chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Treeverse", 4 | "icons": { 5 | "16": "icons/16.png", 6 | "32": "icons/32.png", 7 | "48": "icons/48.png", 8 | "128": "icons/128.png" 9 | }, 10 | "page_action": { 11 | "default_icon": { 12 | "16": "icons/16.png", 13 | "32": "icons/32.png", 14 | "48": "icons/48.png", 15 | "128": "icons/128.png" 16 | }, 17 | "default_title": "Treeverse" 18 | }, 19 | "content_scripts": [ 20 | { 21 | "matches": ["https://twitter.com/*"], 22 | "js": ["resources/script/viewer.js"] 23 | } 24 | ], 25 | "description": "", 26 | "version": "4.0", 27 | "background": { 28 | "scripts": [ 29 | "resources/script/background.js" 30 | ] 31 | }, 32 | "permissions": [ 33 | "activeTab", 34 | "declarativeContent", 35 | "contextMenus", 36 | "https://api.twitter.com/", 37 | "https://mobile.twitter.com/", 38 | "https://treeverse.app/" 39 | ], 40 | "web_accessible_resources": [ 41 | "resources/*" 42 | ] 43 | } -------------------------------------------------------------------------------- /dist/extension_chrome/resources/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_chrome/resources/images/favicon.png -------------------------------------------------------------------------------- /dist/extension_chrome/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Treeverse 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /dist/extension_chrome/resources/script/background.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | /******/ 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | /******/ 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) { 10 | /******/ return installedModules[moduleId].exports; 11 | /******/ } 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ i: moduleId, 15 | /******/ l: false, 16 | /******/ exports: {} 17 | /******/ }; 18 | /******/ 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | /******/ 22 | /******/ // Flag the module as loaded 23 | /******/ module.l = true; 24 | /******/ 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | /******/ 29 | /******/ 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | /******/ 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | /******/ 36 | /******/ // define getter function for harmony exports 37 | /******/ __webpack_require__.d = function(exports, name, getter) { 38 | /******/ if(!__webpack_require__.o(exports, name)) { 39 | /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); 40 | /******/ } 41 | /******/ }; 42 | /******/ 43 | /******/ // define __esModule on exports 44 | /******/ __webpack_require__.r = function(exports) { 45 | /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { 46 | /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); 47 | /******/ } 48 | /******/ Object.defineProperty(exports, '__esModule', { value: true }); 49 | /******/ }; 50 | /******/ 51 | /******/ // create a fake namespace object 52 | /******/ // mode & 1: value is a module id, require it 53 | /******/ // mode & 2: merge all properties of value into the ns 54 | /******/ // mode & 4: return value when already ns object 55 | /******/ // mode & 8|1: behave like require 56 | /******/ __webpack_require__.t = function(value, mode) { 57 | /******/ if(mode & 1) value = __webpack_require__(value); 58 | /******/ if(mode & 8) return value; 59 | /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; 60 | /******/ var ns = Object.create(null); 61 | /******/ __webpack_require__.r(ns); 62 | /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); 63 | /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); 64 | /******/ return ns; 65 | /******/ }; 66 | /******/ 67 | /******/ // getDefaultExport function for compatibility with non-harmony modules 68 | /******/ __webpack_require__.n = function(module) { 69 | /******/ var getter = module && module.__esModule ? 70 | /******/ function getDefault() { return module['default']; } : 71 | /******/ function getModuleExports() { return module; }; 72 | /******/ __webpack_require__.d(getter, 'a', getter); 73 | /******/ return getter; 74 | /******/ }; 75 | /******/ 76 | /******/ // Object.prototype.hasOwnProperty.call 77 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 78 | /******/ 79 | /******/ // __webpack_public_path__ 80 | /******/ __webpack_require__.p = ""; 81 | /******/ 82 | /******/ 83 | /******/ // Load entry module and return exports 84 | /******/ return __webpack_require__(__webpack_require__.s = "./background/chrome_action.ts"); 85 | /******/ }) 86 | /************************************************************************/ 87 | /******/ ({ 88 | 89 | /***/ "./background/chrome_action.ts": 90 | /*!*************************************!*\ 91 | !*** ./background/chrome_action.ts ***! 92 | \*************************************/ 93 | /*! no static exports found */ 94 | /***/ (function(module, exports, __webpack_require__) { 95 | 96 | "use strict"; 97 | 98 | Object.defineProperty(exports, "__esModule", { value: true }); 99 | const common_1 = __webpack_require__(/*! ./common */ "./background/common.ts"); 100 | chrome.pageAction.onClicked.addListener(common_1.clickAction); 101 | chrome.runtime.onInstalled.addListener(() => { 102 | chrome.declarativeContent.onPageChanged.removeRules(undefined, () => { 103 | chrome.declarativeContent.onPageChanged.addRules([ 104 | { 105 | conditions: [ 106 | new chrome.declarativeContent.PageStateMatcher({ 107 | pageUrl: { 108 | urlMatches: common_1.matchTweetURL 109 | } 110 | }) 111 | ], 112 | actions: [new chrome.declarativeContent.ShowPageAction()] 113 | } 114 | ]); 115 | }); 116 | }); 117 | chrome.runtime.onMessage.addListener(common_1.onMessageFromContentScript); 118 | 119 | 120 | /***/ }), 121 | 122 | /***/ "./background/common.ts": 123 | /*!******************************!*\ 124 | !*** ./background/common.ts ***! 125 | \******************************/ 126 | /*! no static exports found */ 127 | /***/ (function(module, exports, __webpack_require__) { 128 | 129 | "use strict"; 130 | 131 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 132 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 133 | return new (P || (P = Promise))(function (resolve, reject) { 134 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 135 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 136 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 137 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 138 | }); 139 | }; 140 | Object.defineProperty(exports, "__esModule", { value: true }); 141 | exports.matchTweetURL = 'https?://(?:mobile\\.)?twitter.com/(.+)/status/(\\d+)'; 142 | exports.matchTweetURLRegex = new RegExp(exports.matchTweetURL); 143 | const tweetToLoad = {}; 144 | function onMessageFromContentScript(request, sender, _sendResponse) { 145 | switch (request.message) { 146 | case 'share': 147 | // Handle share button click. The payload is the tree structure. 148 | fetch('https://treeverse.app/share', { 149 | method: 'POST', 150 | body: JSON.stringify(request.payload), 151 | headers: { 152 | 'Content-Type': 'application/json' 153 | }, 154 | }).then((response) => response.text()) 155 | .then((response) => chrome.tabs.create({ url: response })); 156 | break; 157 | case 'ready': 158 | if (tweetToLoad.value) { 159 | launchTreeverse(sender.tab.id, tweetToLoad.value); 160 | tweetToLoad.value = null; 161 | } 162 | break; 163 | } 164 | } 165 | exports.onMessageFromContentScript = onMessageFromContentScript; 166 | function launchTreeverse(tabId, tweetId) { 167 | chrome.tabs.sendMessage(tabId, { 168 | 'action': 'launch', 169 | 'tweetId': tweetId 170 | }); 171 | } 172 | exports.launchTreeverse = launchTreeverse; 173 | function getTweetIdFromURL(url) { 174 | let match = exports.matchTweetURLRegex.exec(url); 175 | if (match) { 176 | return match[2]; 177 | } 178 | } 179 | exports.getTweetIdFromURL = getTweetIdFromURL; 180 | function injectScripts(tabId, tweetId) { 181 | return __awaiter(this, void 0, void 0, function* () { 182 | let state = yield new Promise((resolve) => { 183 | chrome.tabs.executeScript(tabId, { 184 | code: `(typeof Treeverse !== 'undefined') ? Treeverse.PROXY.state : 'missing'` 185 | }, resolve); 186 | }); 187 | console.log('state', state); 188 | switch (state[0]) { 189 | case 'ready': 190 | launchTreeverse(tabId, tweetId); 191 | break; 192 | case 'listening': 193 | case 'waiting': 194 | case 'missing': 195 | default: 196 | console.log(`Treeverse in non-ready state ${state[0]}`); 197 | tweetToLoad.value = tweetId; 198 | // Force the tab to reload. 199 | chrome.tabs.reload(tabId); 200 | // Ensure the tab loads. 201 | setTimeout(() => { 202 | if (tweetToLoad.value !== null) { 203 | alert(`Treeverse was unable to access the tweet data needed to launch. 204 | 205 | If you report this error, please mention that the last proxy state was ${state[0]}`); 206 | } 207 | }, 2000); 208 | } 209 | }); 210 | } 211 | exports.injectScripts = injectScripts; 212 | function clickAction(tab) { 213 | const tweetId = getTweetIdFromURL(tab.url); 214 | injectScripts(tab.id, tweetId); 215 | } 216 | exports.clickAction = clickAction; 217 | 218 | 219 | /***/ }) 220 | 221 | /******/ }); 222 | //# sourceMappingURL=background.js.map -------------------------------------------------------------------------------- /dist/extension_chrome/resources/script/background.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./background/chrome_action.ts","webpack:///./background/common.ts"],"names":[],"mappings":";QAAA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;;QAEA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;;;QAGA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA,0CAA0C,gCAAgC;QAC1E;QACA;;QAEA;QACA;QACA;QACA,wDAAwD,kBAAkB;QAC1E;QACA,iDAAiD,cAAc;QAC/D;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA,yCAAyC,iCAAiC;QAC1E,gHAAgH,mBAAmB,EAAE;QACrI;QACA;;QAEA;QACA;QACA;QACA,2BAA2B,0BAA0B,EAAE;QACvD,iCAAiC,eAAe;QAChD;QACA;QACA;;QAEA;QACA,sDAAsD,+DAA+D;;QAErH;QACA;;;QAGA;QACA;;;;;;;;;;;;;AClFa;AACb,8CAA8C,cAAc;AAC5D,iBAAiB,mBAAO,CAAC,wCAAU;AACnC;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB;AACrB;AACA;AACA;AACA;AACA,KAAK;AACL,CAAC;AACD;;;;;;;;;;;;;ACpBa;AACb;AACA,2BAA2B,+DAA+D,gBAAgB,EAAE,EAAE;AAC9G;AACA,mCAAmC,MAAM,6BAA6B,EAAE,YAAY,WAAW,EAAE;AACjG,kCAAkC,MAAM,iCAAiC,EAAE,YAAY,WAAW,EAAE;AACpG,+BAA+B,qFAAqF;AACpH;AACA,KAAK;AACL;AACA,8CAA8C,cAAc;AAC5D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,iBAAiB;AACjB,aAAa;AACb,wDAAwD,gBAAgB;AACxE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK;AACL;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb,SAAS;AACT;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4DAA4D,SAAS;AACrE;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,yEAAyE,SAAS;AAClF;AACA,iBAAiB;AACjB;AACA,KAAK;AACL;AACA;AACA;AACA;AACA;AACA;AACA","file":"/extension_chrome/resources/script/background.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = \"./background/chrome_action.ts\");\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst common_1 = require(\"./common\");\nchrome.pageAction.onClicked.addListener(common_1.clickAction);\nchrome.runtime.onInstalled.addListener(() => {\n chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {\n chrome.declarativeContent.onPageChanged.addRules([\n {\n conditions: [\n new chrome.declarativeContent.PageStateMatcher({\n pageUrl: {\n urlMatches: common_1.matchTweetURL\n }\n })\n ],\n actions: [new chrome.declarativeContent.ShowPageAction()]\n }\n ]);\n });\n});\nchrome.runtime.onMessage.addListener(common_1.onMessageFromContentScript);\n","\"use strict\";\nvar __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.matchTweetURL = 'https?://(?:mobile\\\\.)?twitter.com/(.+)/status/(\\\\d+)';\nexports.matchTweetURLRegex = new RegExp(exports.matchTweetURL);\nconst tweetToLoad = {};\nfunction onMessageFromContentScript(request, sender, _sendResponse) {\n switch (request.message) {\n case 'share':\n // Handle share button click. The payload is the tree structure.\n fetch('https://treeverse.app/share', {\n method: 'POST',\n body: JSON.stringify(request.payload),\n headers: {\n 'Content-Type': 'application/json'\n },\n }).then((response) => response.text())\n .then((response) => chrome.tabs.create({ url: response }));\n break;\n case 'ready':\n if (tweetToLoad.value) {\n launchTreeverse(sender.tab.id, tweetToLoad.value);\n tweetToLoad.value = null;\n }\n break;\n }\n}\nexports.onMessageFromContentScript = onMessageFromContentScript;\nfunction launchTreeverse(tabId, tweetId) {\n chrome.tabs.sendMessage(tabId, {\n 'action': 'launch',\n 'tweetId': tweetId\n });\n}\nexports.launchTreeverse = launchTreeverse;\nfunction getTweetIdFromURL(url) {\n let match = exports.matchTweetURLRegex.exec(url);\n if (match) {\n return match[2];\n }\n}\nexports.getTweetIdFromURL = getTweetIdFromURL;\nfunction injectScripts(tabId, tweetId) {\n return __awaiter(this, void 0, void 0, function* () {\n let state = yield new Promise((resolve) => {\n chrome.tabs.executeScript(tabId, {\n code: `(typeof Treeverse !== 'undefined') ? Treeverse.PROXY.state : 'missing'`\n }, resolve);\n });\n console.log('state', state);\n switch (state[0]) {\n case 'ready':\n launchTreeverse(tabId, tweetId);\n break;\n case 'listening':\n case 'waiting':\n case 'missing':\n default:\n console.log(`Treeverse in non-ready state ${state[0]}`);\n tweetToLoad.value = tweetId;\n // Force the tab to reload.\n chrome.tabs.reload(tabId);\n // Ensure the tab loads.\n setTimeout(() => {\n if (tweetToLoad.value !== null) {\n alert(`Treeverse was unable to access the tweet data needed to launch.\n\nIf you report this error, please mention that the last proxy state was ${state[0]}`);\n }\n }, 2000);\n }\n });\n}\nexports.injectScripts = injectScripts;\nfunction clickAction(tab) {\n const tweetId = getTweetIdFromURL(tab.url);\n injectScripts(tab.id, tweetId);\n}\nexports.clickAction = clickAction;\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /dist/extension_chrome/resources/script/content.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | /******/ 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | /******/ 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) { 10 | /******/ return installedModules[moduleId].exports; 11 | /******/ } 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ i: moduleId, 15 | /******/ l: false, 16 | /******/ exports: {} 17 | /******/ }; 18 | /******/ 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | /******/ 22 | /******/ // Flag the module as loaded 23 | /******/ module.l = true; 24 | /******/ 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | /******/ 29 | /******/ 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | /******/ 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | /******/ 36 | /******/ // define getter function for harmony exports 37 | /******/ __webpack_require__.d = function(exports, name, getter) { 38 | /******/ if(!__webpack_require__.o(exports, name)) { 39 | /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); 40 | /******/ } 41 | /******/ }; 42 | /******/ 43 | /******/ // define __esModule on exports 44 | /******/ __webpack_require__.r = function(exports) { 45 | /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { 46 | /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); 47 | /******/ } 48 | /******/ Object.defineProperty(exports, '__esModule', { value: true }); 49 | /******/ }; 50 | /******/ 51 | /******/ // create a fake namespace object 52 | /******/ // mode & 1: value is a module id, require it 53 | /******/ // mode & 2: merge all properties of value into the ns 54 | /******/ // mode & 4: return value when already ns object 55 | /******/ // mode & 8|1: behave like require 56 | /******/ __webpack_require__.t = function(value, mode) { 57 | /******/ if(mode & 1) value = __webpack_require__(value); 58 | /******/ if(mode & 8) return value; 59 | /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; 60 | /******/ var ns = Object.create(null); 61 | /******/ __webpack_require__.r(ns); 62 | /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); 63 | /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); 64 | /******/ return ns; 65 | /******/ }; 66 | /******/ 67 | /******/ // getDefaultExport function for compatibility with non-harmony modules 68 | /******/ __webpack_require__.n = function(module) { 69 | /******/ var getter = module && module.__esModule ? 70 | /******/ function getDefault() { return module['default']; } : 71 | /******/ function getModuleExports() { return module; }; 72 | /******/ __webpack_require__.d(getter, 'a', getter); 73 | /******/ return getter; 74 | /******/ }; 75 | /******/ 76 | /******/ // Object.prototype.hasOwnProperty.call 77 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 78 | /******/ 79 | /******/ // __webpack_public_path__ 80 | /******/ __webpack_require__.p = ""; 81 | /******/ 82 | /******/ 83 | /******/ // Load entry module and return exports 84 | /******/ return __webpack_require__(__webpack_require__.s = "./content/main.ts"); 85 | /******/ }) 86 | /************************************************************************/ 87 | /******/ ({ 88 | 89 | /***/ "./content/main.ts": 90 | /*!*************************!*\ 91 | !*** ./content/main.ts ***! 92 | \*************************/ 93 | /*! no static exports found */ 94 | /***/ (function(module, exports) { 95 | 96 | function init() { 97 | let oldSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader; 98 | let treeverseAuth = {}; 99 | window.XMLHttpRequest.prototype.setRequestHeader = function (h, v) { 100 | if (h === 'x-csrf-token' || h === 'authorization') { 101 | treeverseAuth[h] = v; 102 | window.postMessage({ 103 | action: 'state', 104 | state: 'ready', 105 | }, '*'); 106 | } 107 | oldSetRequestHeader.apply(this, [h, v]); 108 | }; 109 | window.addEventListener("message", (message) => { 110 | if (message.data.action === 'fetch') { 111 | fetch(message.data.url, { 112 | credentials: 'include', 113 | headers: treeverseAuth 114 | }).then((x) => x.json()).then((x) => { 115 | window.postMessage({ 116 | action: 'result', 117 | key: message.data.key, 118 | result: x 119 | }, '*'); 120 | }); 121 | } 122 | }, false); 123 | window.postMessage({ 124 | action: 'state', 125 | state: 'listening', 126 | }, '*'); 127 | } 128 | init(); 129 | 130 | 131 | /***/ }) 132 | 133 | /******/ }); 134 | //# sourceMappingURL=content.js.map -------------------------------------------------------------------------------- /dist/extension_chrome/resources/script/content.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./content/main.ts"],"names":[],"mappings":";QAAA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;;QAEA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;;;QAGA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA,0CAA0C,gCAAgC;QAC1E;QACA;;QAEA;QACA;QACA;QACA,wDAAwD,kBAAkB;QAC1E;QACA,iDAAiD,cAAc;QAC/D;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA,yCAAyC,iCAAiC;QAC1E,gHAAgH,mBAAmB,EAAE;QACrI;QACA;;QAEA;QACA;QACA;QACA,2BAA2B,0BAA0B,EAAE;QACvD,iCAAiC,eAAe;QAChD;QACA;QACA;;QAEA;QACA,sDAAsD,+DAA+D;;QAErH;QACA;;;QAGA;QACA;;;;;;;;;;;;AClFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA;AACA;AACA;AACA,iBAAiB;AACjB,aAAa;AACb;AACA,KAAK;AACL;AACA;AACA;AACA,KAAK;AACL;AACA","file":"/extension_chrome/resources/script/content.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = \"./content/main.ts\");\n","function init() {\n let oldSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader;\n let treeverseAuth = {};\n window.XMLHttpRequest.prototype.setRequestHeader = function (h, v) {\n if (h === 'x-csrf-token' || h === 'authorization') {\n treeverseAuth[h] = v;\n window.postMessage({\n action: 'state',\n state: 'ready',\n }, '*');\n }\n oldSetRequestHeader.apply(this, [h, v]);\n };\n window.addEventListener(\"message\", (message) => {\n if (message.data.action === 'fetch') {\n fetch(message.data.url, {\n credentials: 'include',\n headers: treeverseAuth\n }).then((x) => x.json()).then((x) => {\n window.postMessage({\n action: 'result',\n key: message.data.key,\n result: x\n }, '*');\n });\n }\n }, false);\n window.postMessage({\n action: 'state',\n state: 'listening',\n }, '*');\n}\ninit();\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /dist/extension_chrome/resources/style.css: -------------------------------------------------------------------------------- 1 | /* Outer page container */ 2 | body { 3 | margin: 0; 4 | font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; 5 | font-size: 14px; 6 | color: #333; 7 | } 8 | 9 | #root { 10 | display: flex; 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | } 17 | 18 | a { 19 | cursor: pointer; 20 | color: #449; 21 | text-decoration: none; 22 | } 23 | 24 | a:hover { 25 | color: #66a; 26 | } 27 | 28 | /* Tree containers */ 29 | #tree { 30 | width: 100%; 31 | height: 100%; 32 | background-color: #333; 33 | } 34 | 35 | #treeContainer { 36 | flex-grow: 1; 37 | } 38 | 39 | .selected rect { 40 | stroke: #f55; 41 | stroke-width: 4px; 42 | } 43 | 44 | /* Sidebar and info box styles. */ 45 | 46 | #sidebar { 47 | background: #eee; 48 | overflow-x: hidden; 49 | flex-basis: 500px; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | 54 | #infoBox { 55 | padding: 2px 14px; 56 | background-color: #fff; 57 | box-shadow: 0 1px 10px #ccc; 58 | } 59 | 60 | /* Feed-related elements */ 61 | #feedContainer { 62 | overflow-y: scroll; 63 | flex-grow: 1; 64 | } 65 | 66 | #feedInner { 67 | padding: 18px; 68 | } 69 | 70 | /* Tweet content styles */ 71 | .text { 72 | white-space: pre-wrap; 73 | } 74 | 75 | .text a.twitter-atreply s { 76 | text-decoration: none; 77 | } 78 | 79 | .text .Emoji, .text .twitter-hashflag-container img { 80 | height: 1.25em; 81 | vertical-align: -0.3em; 82 | } 83 | 84 | .text .u-hidden { 85 | display: none; 86 | } 87 | 88 | .text b { 89 | font-weight: normal; 90 | } 91 | 92 | .u-hiddenVisually { 93 | display: none; 94 | } 95 | 96 | .dropzone { 97 | padding: 10px; 98 | border: 1px dashed #aaf; 99 | background-color: #f4f4f4; 100 | text-align: center; 101 | color: #888; 102 | } 103 | 104 | .rtl { 105 | text-align: right; 106 | direction: rtl; 107 | unicode-bidi: embed; 108 | } 109 | 110 | 111 | /******************************* 112 | Standard 113 | *******************************/ 114 | 115 | 116 | /*-------------- 117 | Comments 118 | ---------------*/ 119 | 120 | .ui.comments { 121 | margin: 1.5em 0em; 122 | max-width: 650px; 123 | } 124 | .ui.comments:first-child { 125 | margin-top: 0em; 126 | } 127 | .ui.comments:last-child { 128 | margin-bottom: 0em; 129 | } 130 | 131 | /*-------------- 132 | Comment 133 | ---------------*/ 134 | 135 | .ui.comments .comment { 136 | position: relative; 137 | background: none; 138 | margin: 0.5em 0em 0em; 139 | padding: 0.5em 0em 0em; 140 | border: none; 141 | border-top: none; 142 | line-height: 1.2; 143 | } 144 | .ui.comments .comment:first-child { 145 | margin-top: 0em; 146 | padding-top: 0em; 147 | } 148 | 149 | /*-------------- 150 | Avatar 151 | ---------------*/ 152 | 153 | .ui.comments .comment .avatar { 154 | display: block; 155 | width: 2.5em; 156 | height: auto; 157 | float: left; 158 | margin: 0.2em 0em 0em; 159 | } 160 | .ui.comments .comment img.avatar, 161 | .ui.comments .comment .avatar img { 162 | display: block; 163 | margin: 0em auto; 164 | width: 100%; 165 | height: 100%; 166 | border-radius: 0.25rem; 167 | } 168 | 169 | /*-------------- 170 | Content 171 | ---------------*/ 172 | 173 | .ui.comments .comment > .content { 174 | display: block; 175 | } 176 | 177 | /* If there is an avatar move content over */ 178 | .ui.comments .comment > .avatar ~ .content { 179 | margin-left: 3.5em; 180 | } 181 | 182 | /*-------------- 183 | Author 184 | ---------------*/ 185 | 186 | .ui.comments .comment .author { 187 | font-size: 1em; 188 | color: rgba(0, 0, 0, 0.87); 189 | font-weight: bold; 190 | } 191 | .ui.comments .comment a.author { 192 | cursor: pointer; 193 | } 194 | .ui.comments .comment a.author:hover { 195 | color: #1e70bf; 196 | } 197 | 198 | /*-------------- 199 | Metadata 200 | ---------------*/ 201 | 202 | .ui.comments .comment .metadata { 203 | display: inline-block; 204 | margin-left: 0.5em; 205 | color: rgba(0, 0, 0, 0.4); 206 | font-size: 0.875em; 207 | } 208 | .ui.comments .comment .metadata > * { 209 | display: inline-block; 210 | margin: 0em 0.5em 0em 0em; 211 | } 212 | .ui.comments .comment .metadata > :last-child { 213 | margin-right: 0em; 214 | } 215 | 216 | /*-------------------- 217 | Comment Text 218 | ---------------------*/ 219 | 220 | .ui.comments .comment .text { 221 | margin: 0.25em 0em 0.5em; 222 | font-size: 1em; 223 | word-wrap: break-word; 224 | color: rgba(0, 0, 0, 0.87); 225 | line-height: 1.3; 226 | } 227 | 228 | /*-------------------- 229 | Button 230 | ---------------------*/ 231 | 232 | .ui.button { 233 | cursor: pointer; 234 | display: inline-block; 235 | min-height: 1em; 236 | outline: none; 237 | border: none; 238 | vertical-align: baseline; 239 | background: #E0E1E2 none; 240 | color: rgba(0, 0, 0, 0.6); 241 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 242 | margin: 0em 0.25em 0em 0em; 243 | padding: 0.78571429em 1.5em 0.78571429em; 244 | text-transform: none; 245 | text-shadow: none; 246 | font-weight: bold; 247 | line-height: 1em; 248 | font-style: normal; 249 | text-align: center; 250 | text-decoration: none; 251 | border-radius: 0.28571429rem; 252 | -webkit-box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 253 | box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 254 | -webkit-user-select: none; 255 | -moz-user-select: none; 256 | -ms-user-select: none; 257 | user-select: none; 258 | -webkit-transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 259 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 260 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease; 261 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 262 | will-change: ''; 263 | -webkit-tap-highlight-color: transparent; 264 | } 265 | 266 | 267 | .ui.primary.buttons .button, 268 | .ui.primary.button { 269 | background-color: #2185D0; 270 | color: #FFFFFF; 271 | text-shadow: none; 272 | background-image: none; 273 | } 274 | .ui.primary.button { 275 | -webkit-box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 276 | box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 277 | } 278 | .ui.primary.buttons .button:hover, 279 | .ui.primary.button:hover { 280 | background-color: #1678c2; 281 | color: #FFFFFF; 282 | text-shadow: none; 283 | } 284 | -------------------------------------------------------------------------------- /dist/extension_firefox/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_firefox/icons/128.png -------------------------------------------------------------------------------- /dist/extension_firefox/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_firefox/icons/16.png -------------------------------------------------------------------------------- /dist/extension_firefox/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_firefox/icons/256.png -------------------------------------------------------------------------------- /dist/extension_firefox/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_firefox/icons/32.png -------------------------------------------------------------------------------- /dist/extension_firefox/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_firefox/icons/48.png -------------------------------------------------------------------------------- /dist/extension_firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Treeverse", 4 | "icons": { 5 | "16": "icons/16.png", 6 | "32": "icons/32.png", 7 | "48": "icons/48.png", 8 | "128": "icons/128.png" 9 | }, 10 | "page_action": { 11 | "browser_style": true, 12 | "default_icon": { 13 | "19": "icons/16.png", 14 | "38": "icons/32.png" 15 | }, 16 | "default_title": "Treeverse" 17 | }, 18 | "description": "", 19 | "version": "4.0", 20 | "background": { 21 | "scripts": [ 22 | "resources/script/background.js" 23 | ] 24 | }, 25 | "permissions": [ 26 | "tabs", 27 | "contextMenus", 28 | "https://*.twitter.com/", 29 | "https://treeverse.app/" 30 | ], 31 | "web_accessible_resources": [ 32 | "resources/*" 33 | ] 34 | } -------------------------------------------------------------------------------- /dist/extension_firefox/resources/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/extension_firefox/resources/images/favicon.png -------------------------------------------------------------------------------- /dist/extension_firefox/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Treeverse 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /dist/extension_firefox/resources/script/background.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | /******/ 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | /******/ 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) { 10 | /******/ return installedModules[moduleId].exports; 11 | /******/ } 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ i: moduleId, 15 | /******/ l: false, 16 | /******/ exports: {} 17 | /******/ }; 18 | /******/ 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | /******/ 22 | /******/ // Flag the module as loaded 23 | /******/ module.l = true; 24 | /******/ 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | /******/ 29 | /******/ 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | /******/ 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | /******/ 36 | /******/ // define getter function for harmony exports 37 | /******/ __webpack_require__.d = function(exports, name, getter) { 38 | /******/ if(!__webpack_require__.o(exports, name)) { 39 | /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); 40 | /******/ } 41 | /******/ }; 42 | /******/ 43 | /******/ // define __esModule on exports 44 | /******/ __webpack_require__.r = function(exports) { 45 | /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { 46 | /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); 47 | /******/ } 48 | /******/ Object.defineProperty(exports, '__esModule', { value: true }); 49 | /******/ }; 50 | /******/ 51 | /******/ // create a fake namespace object 52 | /******/ // mode & 1: value is a module id, require it 53 | /******/ // mode & 2: merge all properties of value into the ns 54 | /******/ // mode & 4: return value when already ns object 55 | /******/ // mode & 8|1: behave like require 56 | /******/ __webpack_require__.t = function(value, mode) { 57 | /******/ if(mode & 1) value = __webpack_require__(value); 58 | /******/ if(mode & 8) return value; 59 | /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; 60 | /******/ var ns = Object.create(null); 61 | /******/ __webpack_require__.r(ns); 62 | /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); 63 | /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); 64 | /******/ return ns; 65 | /******/ }; 66 | /******/ 67 | /******/ // getDefaultExport function for compatibility with non-harmony modules 68 | /******/ __webpack_require__.n = function(module) { 69 | /******/ var getter = module && module.__esModule ? 70 | /******/ function getDefault() { return module['default']; } : 71 | /******/ function getModuleExports() { return module; }; 72 | /******/ __webpack_require__.d(getter, 'a', getter); 73 | /******/ return getter; 74 | /******/ }; 75 | /******/ 76 | /******/ // Object.prototype.hasOwnProperty.call 77 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 78 | /******/ 79 | /******/ // __webpack_public_path__ 80 | /******/ __webpack_require__.p = ""; 81 | /******/ 82 | /******/ 83 | /******/ // Load entry module and return exports 84 | /******/ return __webpack_require__(__webpack_require__.s = "./background/firefox_action.ts"); 85 | /******/ }) 86 | /************************************************************************/ 87 | /******/ ({ 88 | 89 | /***/ "./background/common.ts": 90 | /*!******************************!*\ 91 | !*** ./background/common.ts ***! 92 | \******************************/ 93 | /*! no static exports found */ 94 | /***/ (function(module, exports, __webpack_require__) { 95 | 96 | "use strict"; 97 | 98 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 99 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 100 | return new (P || (P = Promise))(function (resolve, reject) { 101 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 102 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 103 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 104 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 105 | }); 106 | }; 107 | Object.defineProperty(exports, "__esModule", { value: true }); 108 | exports.matchTweetURL = 'https?://(?:mobile\\.)?twitter.com/(.+)/status/(\\d+)'; 109 | exports.matchTweetURLRegex = new RegExp(exports.matchTweetURL); 110 | const tweetToLoad = {}; 111 | function onMessageFromContentScript(request, sender, _sendResponse) { 112 | switch (request.message) { 113 | case 'share': 114 | // Handle share button click. The payload is the tree structure. 115 | fetch('https://treeverse.app/share', { 116 | method: 'POST', 117 | body: JSON.stringify(request.payload), 118 | headers: { 119 | 'Content-Type': 'application/json' 120 | }, 121 | }).then((response) => response.text()) 122 | .then((response) => chrome.tabs.create({ url: response })); 123 | break; 124 | case 'ready': 125 | if (tweetToLoad.value) { 126 | launchTreeverse(sender.tab.id, tweetToLoad.value); 127 | tweetToLoad.value = null; 128 | } 129 | break; 130 | } 131 | } 132 | exports.onMessageFromContentScript = onMessageFromContentScript; 133 | function launchTreeverse(tabId, tweetId) { 134 | chrome.tabs.sendMessage(tabId, { 135 | 'action': 'launch', 136 | 'tweetId': tweetId 137 | }); 138 | } 139 | exports.launchTreeverse = launchTreeverse; 140 | function getTweetIdFromURL(url) { 141 | let match = exports.matchTweetURLRegex.exec(url); 142 | if (match) { 143 | return match[2]; 144 | } 145 | } 146 | exports.getTweetIdFromURL = getTweetIdFromURL; 147 | function injectScripts(tabId, tweetId) { 148 | return __awaiter(this, void 0, void 0, function* () { 149 | let state = yield new Promise((resolve) => { 150 | chrome.tabs.executeScript(tabId, { 151 | code: `(typeof Treeverse !== 'undefined') ? Treeverse.PROXY.state : 'missing'` 152 | }, resolve); 153 | }); 154 | console.log('state', state); 155 | switch (state[0]) { 156 | case 'ready': 157 | launchTreeverse(tabId, tweetId); 158 | break; 159 | case 'listening': 160 | case 'waiting': 161 | case 'missing': 162 | default: 163 | console.log(`Treeverse in non-ready state ${state[0]}`); 164 | tweetToLoad.value = tweetId; 165 | // Force the tab to reload. 166 | chrome.tabs.reload(tabId); 167 | // Ensure the tab loads. 168 | setTimeout(() => { 169 | if (tweetToLoad.value !== null) { 170 | alert(`Treeverse was unable to access the tweet data needed to launch. 171 | 172 | If you report this error, please mention that the last proxy state was ${state[0]}`); 173 | } 174 | }, 2000); 175 | } 176 | }); 177 | } 178 | exports.injectScripts = injectScripts; 179 | function clickAction(tab) { 180 | const tweetId = getTweetIdFromURL(tab.url); 181 | injectScripts(tab.id, tweetId); 182 | } 183 | exports.clickAction = clickAction; 184 | 185 | 186 | /***/ }), 187 | 188 | /***/ "./background/firefox_action.ts": 189 | /*!**************************************!*\ 190 | !*** ./background/firefox_action.ts ***! 191 | \**************************************/ 192 | /*! no static exports found */ 193 | /***/ (function(module, exports, __webpack_require__) { 194 | 195 | "use strict"; 196 | 197 | Object.defineProperty(exports, "__esModule", { value: true }); 198 | const common_1 = __webpack_require__(/*! ./common */ "./background/common.ts"); 199 | chrome.pageAction.onClicked.addListener(common_1.clickAction); 200 | chrome.tabs.onUpdated.addListener((id, changeInfo, tab) => { 201 | if (!changeInfo.url) { 202 | return; 203 | } 204 | else if (changeInfo.url.match(common_1.matchTweetURL)) { 205 | chrome.pageAction.show(tab.id); 206 | } 207 | else { 208 | chrome.pageAction.hide(tab.id); 209 | } 210 | }); 211 | chrome.runtime.onMessage.addListener(common_1.onMessageFromContentScript); 212 | 213 | 214 | /***/ }) 215 | 216 | /******/ }); 217 | //# sourceMappingURL=background.js.map -------------------------------------------------------------------------------- /dist/extension_firefox/resources/script/background.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./background/common.ts","webpack:///./background/firefox_action.ts"],"names":[],"mappings":";QAAA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;;QAEA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;;;QAGA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA,0CAA0C,gCAAgC;QAC1E;QACA;;QAEA;QACA;QACA;QACA,wDAAwD,kBAAkB;QAC1E;QACA,iDAAiD,cAAc;QAC/D;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA,yCAAyC,iCAAiC;QAC1E,gHAAgH,mBAAmB,EAAE;QACrI;QACA;;QAEA;QACA;QACA;QACA,2BAA2B,0BAA0B,EAAE;QACvD,iCAAiC,eAAe;QAChD;QACA;QACA;;QAEA;QACA,sDAAsD,+DAA+D;;QAErH;QACA;;;QAGA;QACA;;;;;;;;;;;;;AClFa;AACb;AACA,2BAA2B,+DAA+D,gBAAgB,EAAE,EAAE;AAC9G;AACA,mCAAmC,MAAM,6BAA6B,EAAE,YAAY,WAAW,EAAE;AACjG,kCAAkC,MAAM,iCAAiC,EAAE,YAAY,WAAW,EAAE;AACpG,+BAA+B,qFAAqF;AACpH;AACA,KAAK;AACL;AACA,8CAA8C,cAAc;AAC5D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,iBAAiB;AACjB,aAAa;AACb,wDAAwD,gBAAgB;AACxE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK;AACL;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb,SAAS;AACT;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4DAA4D,SAAS;AACrE;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,yEAAyE,SAAS;AAClF;AACA,iBAAiB;AACjB;AACA,KAAK;AACL;AACA;AACA;AACA;AACA;AACA;AACA;;;;;;;;;;;;;ACtFa;AACb,8CAA8C,cAAc;AAC5D,iBAAiB,mBAAO,CAAC,wCAAU;AACnC;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACD","file":"/extension_firefox/resources/script/background.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = \"./background/firefox_action.ts\");\n","\"use strict\";\nvar __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.matchTweetURL = 'https?://(?:mobile\\\\.)?twitter.com/(.+)/status/(\\\\d+)';\nexports.matchTweetURLRegex = new RegExp(exports.matchTweetURL);\nconst tweetToLoad = {};\nfunction onMessageFromContentScript(request, sender, _sendResponse) {\n switch (request.message) {\n case 'share':\n // Handle share button click. The payload is the tree structure.\n fetch('https://treeverse.app/share', {\n method: 'POST',\n body: JSON.stringify(request.payload),\n headers: {\n 'Content-Type': 'application/json'\n },\n }).then((response) => response.text())\n .then((response) => chrome.tabs.create({ url: response }));\n break;\n case 'ready':\n if (tweetToLoad.value) {\n launchTreeverse(sender.tab.id, tweetToLoad.value);\n tweetToLoad.value = null;\n }\n break;\n }\n}\nexports.onMessageFromContentScript = onMessageFromContentScript;\nfunction launchTreeverse(tabId, tweetId) {\n chrome.tabs.sendMessage(tabId, {\n 'action': 'launch',\n 'tweetId': tweetId\n });\n}\nexports.launchTreeverse = launchTreeverse;\nfunction getTweetIdFromURL(url) {\n let match = exports.matchTweetURLRegex.exec(url);\n if (match) {\n return match[2];\n }\n}\nexports.getTweetIdFromURL = getTweetIdFromURL;\nfunction injectScripts(tabId, tweetId) {\n return __awaiter(this, void 0, void 0, function* () {\n let state = yield new Promise((resolve) => {\n chrome.tabs.executeScript(tabId, {\n code: `(typeof Treeverse !== 'undefined') ? Treeverse.PROXY.state : 'missing'`\n }, resolve);\n });\n console.log('state', state);\n switch (state[0]) {\n case 'ready':\n launchTreeverse(tabId, tweetId);\n break;\n case 'listening':\n case 'waiting':\n case 'missing':\n default:\n console.log(`Treeverse in non-ready state ${state[0]}`);\n tweetToLoad.value = tweetId;\n // Force the tab to reload.\n chrome.tabs.reload(tabId);\n // Ensure the tab loads.\n setTimeout(() => {\n if (tweetToLoad.value !== null) {\n alert(`Treeverse was unable to access the tweet data needed to launch.\n\nIf you report this error, please mention that the last proxy state was ${state[0]}`);\n }\n }, 2000);\n }\n });\n}\nexports.injectScripts = injectScripts;\nfunction clickAction(tab) {\n const tweetId = getTweetIdFromURL(tab.url);\n injectScripts(tab.id, tweetId);\n}\nexports.clickAction = clickAction;\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst common_1 = require(\"./common\");\nchrome.pageAction.onClicked.addListener(common_1.clickAction);\nchrome.tabs.onUpdated.addListener((id, changeInfo, tab) => {\n if (!changeInfo.url) {\n return;\n }\n else if (changeInfo.url.match(common_1.matchTweetURL)) {\n chrome.pageAction.show(tab.id);\n }\n else {\n chrome.pageAction.hide(tab.id);\n }\n});\nchrome.runtime.onMessage.addListener(common_1.onMessageFromContentScript);\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /dist/extension_firefox/resources/script/content.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | /******/ 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | /******/ 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) { 10 | /******/ return installedModules[moduleId].exports; 11 | /******/ } 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ i: moduleId, 15 | /******/ l: false, 16 | /******/ exports: {} 17 | /******/ }; 18 | /******/ 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | /******/ 22 | /******/ // Flag the module as loaded 23 | /******/ module.l = true; 24 | /******/ 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | /******/ 29 | /******/ 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | /******/ 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | /******/ 36 | /******/ // define getter function for harmony exports 37 | /******/ __webpack_require__.d = function(exports, name, getter) { 38 | /******/ if(!__webpack_require__.o(exports, name)) { 39 | /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); 40 | /******/ } 41 | /******/ }; 42 | /******/ 43 | /******/ // define __esModule on exports 44 | /******/ __webpack_require__.r = function(exports) { 45 | /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { 46 | /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); 47 | /******/ } 48 | /******/ Object.defineProperty(exports, '__esModule', { value: true }); 49 | /******/ }; 50 | /******/ 51 | /******/ // create a fake namespace object 52 | /******/ // mode & 1: value is a module id, require it 53 | /******/ // mode & 2: merge all properties of value into the ns 54 | /******/ // mode & 4: return value when already ns object 55 | /******/ // mode & 8|1: behave like require 56 | /******/ __webpack_require__.t = function(value, mode) { 57 | /******/ if(mode & 1) value = __webpack_require__(value); 58 | /******/ if(mode & 8) return value; 59 | /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; 60 | /******/ var ns = Object.create(null); 61 | /******/ __webpack_require__.r(ns); 62 | /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); 63 | /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); 64 | /******/ return ns; 65 | /******/ }; 66 | /******/ 67 | /******/ // getDefaultExport function for compatibility with non-harmony modules 68 | /******/ __webpack_require__.n = function(module) { 69 | /******/ var getter = module && module.__esModule ? 70 | /******/ function getDefault() { return module['default']; } : 71 | /******/ function getModuleExports() { return module; }; 72 | /******/ __webpack_require__.d(getter, 'a', getter); 73 | /******/ return getter; 74 | /******/ }; 75 | /******/ 76 | /******/ // Object.prototype.hasOwnProperty.call 77 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 78 | /******/ 79 | /******/ // __webpack_public_path__ 80 | /******/ __webpack_require__.p = ""; 81 | /******/ 82 | /******/ 83 | /******/ // Load entry module and return exports 84 | /******/ return __webpack_require__(__webpack_require__.s = "./content/main.ts"); 85 | /******/ }) 86 | /************************************************************************/ 87 | /******/ ({ 88 | 89 | /***/ "./content/main.ts": 90 | /*!*************************!*\ 91 | !*** ./content/main.ts ***! 92 | \*************************/ 93 | /*! no static exports found */ 94 | /***/ (function(module, exports) { 95 | 96 | function init() { 97 | let oldSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader; 98 | let treeverseAuth = {}; 99 | window.XMLHttpRequest.prototype.setRequestHeader = function (h, v) { 100 | if (h === 'x-csrf-token' || h === 'authorization') { 101 | treeverseAuth[h] = v; 102 | window.postMessage({ 103 | action: 'state', 104 | state: 'ready', 105 | }, '*'); 106 | } 107 | oldSetRequestHeader.apply(this, [h, v]); 108 | }; 109 | window.addEventListener("message", (message) => { 110 | if (message.data.action === 'fetch') { 111 | fetch(message.data.url, { 112 | credentials: 'include', 113 | headers: treeverseAuth 114 | }).then((x) => x.json()).then((x) => { 115 | window.postMessage({ 116 | action: 'result', 117 | key: message.data.key, 118 | result: x 119 | }, '*'); 120 | }); 121 | } 122 | }, false); 123 | window.postMessage({ 124 | action: 'state', 125 | state: 'listening', 126 | }, '*'); 127 | } 128 | init(); 129 | 130 | 131 | /***/ }) 132 | 133 | /******/ }); 134 | //# sourceMappingURL=content.js.map -------------------------------------------------------------------------------- /dist/extension_firefox/resources/script/content.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./content/main.ts"],"names":[],"mappings":";QAAA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;;QAEA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;;;QAGA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA,0CAA0C,gCAAgC;QAC1E;QACA;;QAEA;QACA;QACA;QACA,wDAAwD,kBAAkB;QAC1E;QACA,iDAAiD,cAAc;QAC/D;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA,yCAAyC,iCAAiC;QAC1E,gHAAgH,mBAAmB,EAAE;QACrI;QACA;;QAEA;QACA;QACA;QACA,2BAA2B,0BAA0B,EAAE;QACvD,iCAAiC,eAAe;QAChD;QACA;QACA;;QAEA;QACA,sDAAsD,+DAA+D;;QAErH;QACA;;;QAGA;QACA;;;;;;;;;;;;AClFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA;AACA;AACA;AACA,iBAAiB;AACjB,aAAa;AACb;AACA,KAAK;AACL;AACA;AACA;AACA,KAAK;AACL;AACA","file":"/extension_firefox/resources/script/content.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = \"./content/main.ts\");\n","function init() {\n let oldSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader;\n let treeverseAuth = {};\n window.XMLHttpRequest.prototype.setRequestHeader = function (h, v) {\n if (h === 'x-csrf-token' || h === 'authorization') {\n treeverseAuth[h] = v;\n window.postMessage({\n action: 'state',\n state: 'ready',\n }, '*');\n }\n oldSetRequestHeader.apply(this, [h, v]);\n };\n window.addEventListener(\"message\", (message) => {\n if (message.data.action === 'fetch') {\n fetch(message.data.url, {\n credentials: 'include',\n headers: treeverseAuth\n }).then((x) => x.json()).then((x) => {\n window.postMessage({\n action: 'result',\n key: message.data.key,\n result: x\n }, '*');\n });\n }\n }, false);\n window.postMessage({\n action: 'state',\n state: 'listening',\n }, '*');\n}\ninit();\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /dist/extension_firefox/resources/style.css: -------------------------------------------------------------------------------- 1 | /* Outer page container */ 2 | body { 3 | margin: 0; 4 | font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; 5 | font-size: 14px; 6 | color: #333; 7 | } 8 | 9 | #root { 10 | display: flex; 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | } 17 | 18 | a { 19 | cursor: pointer; 20 | color: #449; 21 | text-decoration: none; 22 | } 23 | 24 | a:hover { 25 | color: #66a; 26 | } 27 | 28 | /* Tree containers */ 29 | #tree { 30 | width: 100%; 31 | height: 100%; 32 | background-color: #333; 33 | } 34 | 35 | #treeContainer { 36 | flex-grow: 1; 37 | } 38 | 39 | .selected rect { 40 | stroke: #f55; 41 | stroke-width: 4px; 42 | } 43 | 44 | /* Sidebar and info box styles. */ 45 | 46 | #sidebar { 47 | background: #eee; 48 | overflow-x: hidden; 49 | flex-basis: 500px; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | 54 | #infoBox { 55 | padding: 2px 14px; 56 | background-color: #fff; 57 | box-shadow: 0 1px 10px #ccc; 58 | } 59 | 60 | /* Feed-related elements */ 61 | #feedContainer { 62 | overflow-y: scroll; 63 | flex-grow: 1; 64 | } 65 | 66 | #feedInner { 67 | padding: 18px; 68 | } 69 | 70 | /* Tweet content styles */ 71 | .text { 72 | white-space: pre-wrap; 73 | } 74 | 75 | .text a.twitter-atreply s { 76 | text-decoration: none; 77 | } 78 | 79 | .text .Emoji, .text .twitter-hashflag-container img { 80 | height: 1.25em; 81 | vertical-align: -0.3em; 82 | } 83 | 84 | .text .u-hidden { 85 | display: none; 86 | } 87 | 88 | .text b { 89 | font-weight: normal; 90 | } 91 | 92 | .u-hiddenVisually { 93 | display: none; 94 | } 95 | 96 | .dropzone { 97 | padding: 10px; 98 | border: 1px dashed #aaf; 99 | background-color: #f4f4f4; 100 | text-align: center; 101 | color: #888; 102 | } 103 | 104 | .rtl { 105 | text-align: right; 106 | direction: rtl; 107 | unicode-bidi: embed; 108 | } 109 | 110 | 111 | /******************************* 112 | Standard 113 | *******************************/ 114 | 115 | 116 | /*-------------- 117 | Comments 118 | ---------------*/ 119 | 120 | .ui.comments { 121 | margin: 1.5em 0em; 122 | max-width: 650px; 123 | } 124 | .ui.comments:first-child { 125 | margin-top: 0em; 126 | } 127 | .ui.comments:last-child { 128 | margin-bottom: 0em; 129 | } 130 | 131 | /*-------------- 132 | Comment 133 | ---------------*/ 134 | 135 | .ui.comments .comment { 136 | position: relative; 137 | background: none; 138 | margin: 0.5em 0em 0em; 139 | padding: 0.5em 0em 0em; 140 | border: none; 141 | border-top: none; 142 | line-height: 1.2; 143 | } 144 | .ui.comments .comment:first-child { 145 | margin-top: 0em; 146 | padding-top: 0em; 147 | } 148 | 149 | /*-------------- 150 | Avatar 151 | ---------------*/ 152 | 153 | .ui.comments .comment .avatar { 154 | display: block; 155 | width: 2.5em; 156 | height: auto; 157 | float: left; 158 | margin: 0.2em 0em 0em; 159 | } 160 | .ui.comments .comment img.avatar, 161 | .ui.comments .comment .avatar img { 162 | display: block; 163 | margin: 0em auto; 164 | width: 100%; 165 | height: 100%; 166 | border-radius: 0.25rem; 167 | } 168 | 169 | /*-------------- 170 | Content 171 | ---------------*/ 172 | 173 | .ui.comments .comment > .content { 174 | display: block; 175 | } 176 | 177 | /* If there is an avatar move content over */ 178 | .ui.comments .comment > .avatar ~ .content { 179 | margin-left: 3.5em; 180 | } 181 | 182 | /*-------------- 183 | Author 184 | ---------------*/ 185 | 186 | .ui.comments .comment .author { 187 | font-size: 1em; 188 | color: rgba(0, 0, 0, 0.87); 189 | font-weight: bold; 190 | } 191 | .ui.comments .comment a.author { 192 | cursor: pointer; 193 | } 194 | .ui.comments .comment a.author:hover { 195 | color: #1e70bf; 196 | } 197 | 198 | /*-------------- 199 | Metadata 200 | ---------------*/ 201 | 202 | .ui.comments .comment .metadata { 203 | display: inline-block; 204 | margin-left: 0.5em; 205 | color: rgba(0, 0, 0, 0.4); 206 | font-size: 0.875em; 207 | } 208 | .ui.comments .comment .metadata > * { 209 | display: inline-block; 210 | margin: 0em 0.5em 0em 0em; 211 | } 212 | .ui.comments .comment .metadata > :last-child { 213 | margin-right: 0em; 214 | } 215 | 216 | /*-------------------- 217 | Comment Text 218 | ---------------------*/ 219 | 220 | .ui.comments .comment .text { 221 | margin: 0.25em 0em 0.5em; 222 | font-size: 1em; 223 | word-wrap: break-word; 224 | color: rgba(0, 0, 0, 0.87); 225 | line-height: 1.3; 226 | } 227 | 228 | /*-------------------- 229 | Button 230 | ---------------------*/ 231 | 232 | .ui.button { 233 | cursor: pointer; 234 | display: inline-block; 235 | min-height: 1em; 236 | outline: none; 237 | border: none; 238 | vertical-align: baseline; 239 | background: #E0E1E2 none; 240 | color: rgba(0, 0, 0, 0.6); 241 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 242 | margin: 0em 0.25em 0em 0em; 243 | padding: 0.78571429em 1.5em 0.78571429em; 244 | text-transform: none; 245 | text-shadow: none; 246 | font-weight: bold; 247 | line-height: 1em; 248 | font-style: normal; 249 | text-align: center; 250 | text-decoration: none; 251 | border-radius: 0.28571429rem; 252 | -webkit-box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 253 | box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 254 | -webkit-user-select: none; 255 | -moz-user-select: none; 256 | -ms-user-select: none; 257 | user-select: none; 258 | -webkit-transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 259 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 260 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease; 261 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 262 | will-change: ''; 263 | -webkit-tap-highlight-color: transparent; 264 | } 265 | 266 | 267 | .ui.primary.buttons .button, 268 | .ui.primary.button { 269 | background-color: #2185D0; 270 | color: #FFFFFF; 271 | text-shadow: none; 272 | background-image: none; 273 | } 274 | .ui.primary.button { 275 | -webkit-box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 276 | box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 277 | } 278 | .ui.primary.buttons .button:hover, 279 | .ui.primary.button:hover { 280 | background-color: #1678c2; 281 | color: #FFFFFF; 282 | text-shadow: none; 283 | } 284 | -------------------------------------------------------------------------------- /dist/public/_redirects: -------------------------------------------------------------------------------- 1 | /view/* /view/index.html 200 2 | /json/* https://s3.amazonaws.com/treeverse/:splat.json 200 3 | /share https://1l8hy2eaaj.execute-api.us-east-1.amazonaws.com/default/treeverse_post 200 -------------------------------------------------------------------------------- /dist/public/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/icons/128.png -------------------------------------------------------------------------------- /dist/public/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/icons/16.png -------------------------------------------------------------------------------- /dist/public/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/icons/256.png -------------------------------------------------------------------------------- /dist/public/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/icons/32.png -------------------------------------------------------------------------------- /dist/public/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/icons/48.png -------------------------------------------------------------------------------- /dist/public/images/archive_mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/archive_mode.png -------------------------------------------------------------------------------- /dist/public/images/chrome_treeverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/chrome_treeverse.gif -------------------------------------------------------------------------------- /dist/public/images/download_chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/download_chrome.png -------------------------------------------------------------------------------- /dist/public/images/download_moz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/download_moz.png -------------------------------------------------------------------------------- /dist/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/favicon.png -------------------------------------------------------------------------------- /dist/public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 62 | 67 | 72 | 77 | 82 | 87 | 96 | 101 | 110 | 115 | 124 | 133 | 142 | 151 | 160 | 169 | 180 | 188 | 196 | 207 | 214 | 225 | 236 | 247 | 258 | 269 | 277 | 284 | 291 | 300 | 311 | 319 | 327 | 335 | 342 | 353 | 364 | 375 | 386 | 395 | 403 | 412 | 421 | 430 | 438 | 449 | 461 | 470 | 478 | 486 | 495 | 507 | 519 | 531 | 543 | 555 | 556 | 557 | -------------------------------------------------------------------------------- /dist/public/images/moz_treeverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/moz_treeverse.gif -------------------------------------------------------------------------------- /dist/public/images/red_circles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/red_circles.png -------------------------------------------------------------------------------- /dist/public/images/right_pane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/right_pane.png -------------------------------------------------------------------------------- /dist/public/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/screenshot.png -------------------------------------------------------------------------------- /dist/public/images/treeverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/treeverse.gif -------------------------------------------------------------------------------- /dist/public/images/treeverse640.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/dist/public/images/treeverse640.gif -------------------------------------------------------------------------------- /dist/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Treeverse 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 |
19 |

Treeverse icon Treeverse

20 |

21 | Treeverse is a tool for visualizing and navigating Twitter conversation threads. 22 |

23 |

24 | It is available as a browser extension for Chrome and Firefox. 25 |

26 |

Installation

27 |

For Chrome Users

28 | 29 | 30 | Download Treeverse for Chrome 31 | 32 | 33 |

For Firefox Users

34 | 35 | 36 | Download Treeverse for Firefox 37 | 38 | 39 |

Introduction

40 | 41 |

After installing Treeverse for your browser, open Twitter and click on the tweet that you would like to 42 | visualize the conversation of (or try this one.)

43 | 44 |

If you’re using Chrome, the icon for Treeverse should turn from grey to blue in your browser. Click it to enter Treeverse.

45 | 46 | Opening Treeverse in Chrome 47 | 48 |

If you're using Firefox, the icon will be hidden until you open a tweet, and then it will appear in the address bar.

49 | 50 | Opening Treeverse in Firefox 51 | 52 |

Exploring the Conversation

53 | Screenshot of Treeverse 54 | 55 |

Conversations are visualized as a tree. Each box is an individual tweet, and 56 | an line between two boxes indicates that the lower one is a reply to the upper 57 | one. The color of the line indicates the time duration between the two tweets 58 | (red is faster, blue is slower.)

59 | 60 |

As you hover over nodes, the reply-chain preceeding that tweet appears on the right-side 61 | pane. By clicking a node, you can freeze the UI on that tweet in order to interact with 62 | the right-side pane. By clicking anywhere in the tree window, you can un-freeze the tweet 63 | and return to the normal hover behavior.

64 | 65 | Right pane in action 66 | 67 |

Some tweets will appear with a red circle with white ellipses inside them, either overlayed 68 | on them or as a separate node. This means that 69 | there are more replies to that tweet that haven't been loaded. Double-clicking a node will 70 | load additional replies to that tweet.

71 | 72 | More tweets indicator 73 | 74 |

Privacy

75 | 76 |

Treeverse runs entirely in your browser. No data is collected or tracked by Treeverse directly 77 | when you install it or explore a tree. The extension only communicates with the Treeverse server 78 | if you click the “Create sharable link” button, in which case the current tree 79 | to be sent to a server so that it can be made available to others. Access to the shared link server 80 | may be tracked to prevent abuse.

81 | 82 |

Browser extension installs may be tracked by Google and Mozilla, and the data 83 | requests made to Twitter may be tracked by Twitter. Additionally, when Treeverse runs it loads a 84 | font hosted by Google Fonts. Google may track this download.

85 | 86 |

License

87 | 88 |

Treeverse is distributed under an MIT license. 89 | The code is available on GitHub.

90 | 91 |

Bugs & Contact

92 | 93 | Tweet @paulgb or report on GitHub. 94 | 95 |

Credits

96 | 97 |

Icon created by Eli Schiff.

98 | 99 |

Treeverse would not be possible without the excellent d3.js. 100 | Styling is powered by Semantic UI.

101 | 102 |
103 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /dist/public/style.css: -------------------------------------------------------------------------------- 1 | /* Outer page container */ 2 | body { 3 | margin: 0; 4 | font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; 5 | font-size: 14px; 6 | color: #333; 7 | } 8 | 9 | #root { 10 | display: flex; 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | } 17 | 18 | a { 19 | cursor: pointer; 20 | color: #449; 21 | text-decoration: none; 22 | } 23 | 24 | a:hover { 25 | color: #66a; 26 | } 27 | 28 | /* Tree containers */ 29 | #tree { 30 | width: 100%; 31 | height: 100%; 32 | background-color: #333; 33 | } 34 | 35 | #treeContainer { 36 | flex-grow: 1; 37 | } 38 | 39 | .selected rect { 40 | stroke: #f55; 41 | stroke-width: 4px; 42 | } 43 | 44 | /* Sidebar and info box styles. */ 45 | 46 | #sidebar { 47 | background: #eee; 48 | overflow-x: hidden; 49 | flex-basis: 500px; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | 54 | #infoBox { 55 | padding: 2px 14px; 56 | background-color: #fff; 57 | box-shadow: 0 1px 10px #ccc; 58 | } 59 | 60 | /* Feed-related elements */ 61 | #feedContainer { 62 | overflow-y: scroll; 63 | flex-grow: 1; 64 | } 65 | 66 | #feedInner { 67 | padding: 18px; 68 | } 69 | 70 | /* Tweet content styles */ 71 | .text { 72 | white-space: pre-wrap; 73 | } 74 | 75 | .text a.twitter-atreply s { 76 | text-decoration: none; 77 | } 78 | 79 | .text .Emoji, .text .twitter-hashflag-container img { 80 | height: 1.25em; 81 | vertical-align: -0.3em; 82 | } 83 | 84 | .text .u-hidden { 85 | display: none; 86 | } 87 | 88 | .text b { 89 | font-weight: normal; 90 | } 91 | 92 | .u-hiddenVisually { 93 | display: none; 94 | } 95 | 96 | .dropzone { 97 | padding: 10px; 98 | border: 1px dashed #aaf; 99 | background-color: #f4f4f4; 100 | text-align: center; 101 | color: #888; 102 | } 103 | 104 | .rtl { 105 | text-align: right; 106 | direction: rtl; 107 | unicode-bidi: embed; 108 | } 109 | 110 | 111 | /******************************* 112 | Standard 113 | *******************************/ 114 | 115 | 116 | /*-------------- 117 | Comments 118 | ---------------*/ 119 | 120 | .ui.comments { 121 | margin: 1.5em 0em; 122 | max-width: 650px; 123 | } 124 | .ui.comments:first-child { 125 | margin-top: 0em; 126 | } 127 | .ui.comments:last-child { 128 | margin-bottom: 0em; 129 | } 130 | 131 | /*-------------- 132 | Comment 133 | ---------------*/ 134 | 135 | .ui.comments .comment { 136 | position: relative; 137 | background: none; 138 | margin: 0.5em 0em 0em; 139 | padding: 0.5em 0em 0em; 140 | border: none; 141 | border-top: none; 142 | line-height: 1.2; 143 | } 144 | .ui.comments .comment:first-child { 145 | margin-top: 0em; 146 | padding-top: 0em; 147 | } 148 | 149 | /*-------------- 150 | Avatar 151 | ---------------*/ 152 | 153 | .ui.comments .comment .avatar { 154 | display: block; 155 | width: 2.5em; 156 | height: auto; 157 | float: left; 158 | margin: 0.2em 0em 0em; 159 | } 160 | .ui.comments .comment img.avatar, 161 | .ui.comments .comment .avatar img { 162 | display: block; 163 | margin: 0em auto; 164 | width: 100%; 165 | height: 100%; 166 | border-radius: 0.25rem; 167 | } 168 | 169 | /*-------------- 170 | Content 171 | ---------------*/ 172 | 173 | .ui.comments .comment > .content { 174 | display: block; 175 | } 176 | 177 | /* If there is an avatar move content over */ 178 | .ui.comments .comment > .avatar ~ .content { 179 | margin-left: 3.5em; 180 | } 181 | 182 | /*-------------- 183 | Author 184 | ---------------*/ 185 | 186 | .ui.comments .comment .author { 187 | font-size: 1em; 188 | color: rgba(0, 0, 0, 0.87); 189 | font-weight: bold; 190 | } 191 | .ui.comments .comment a.author { 192 | cursor: pointer; 193 | } 194 | .ui.comments .comment a.author:hover { 195 | color: #1e70bf; 196 | } 197 | 198 | /*-------------- 199 | Metadata 200 | ---------------*/ 201 | 202 | .ui.comments .comment .metadata { 203 | display: inline-block; 204 | margin-left: 0.5em; 205 | color: rgba(0, 0, 0, 0.4); 206 | font-size: 0.875em; 207 | } 208 | .ui.comments .comment .metadata > * { 209 | display: inline-block; 210 | margin: 0em 0.5em 0em 0em; 211 | } 212 | .ui.comments .comment .metadata > :last-child { 213 | margin-right: 0em; 214 | } 215 | 216 | /*-------------------- 217 | Comment Text 218 | ---------------------*/ 219 | 220 | .ui.comments .comment .text { 221 | margin: 0.25em 0em 0.5em; 222 | font-size: 1em; 223 | word-wrap: break-word; 224 | color: rgba(0, 0, 0, 0.87); 225 | line-height: 1.3; 226 | } 227 | 228 | /*-------------------- 229 | Button 230 | ---------------------*/ 231 | 232 | .ui.button { 233 | cursor: pointer; 234 | display: inline-block; 235 | min-height: 1em; 236 | outline: none; 237 | border: none; 238 | vertical-align: baseline; 239 | background: #E0E1E2 none; 240 | color: rgba(0, 0, 0, 0.6); 241 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 242 | margin: 0em 0.25em 0em 0em; 243 | padding: 0.78571429em 1.5em 0.78571429em; 244 | text-transform: none; 245 | text-shadow: none; 246 | font-weight: bold; 247 | line-height: 1em; 248 | font-style: normal; 249 | text-align: center; 250 | text-decoration: none; 251 | border-radius: 0.28571429rem; 252 | -webkit-box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 253 | box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 254 | -webkit-user-select: none; 255 | -moz-user-select: none; 256 | -ms-user-select: none; 257 | user-select: none; 258 | -webkit-transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 259 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 260 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease; 261 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 262 | will-change: ''; 263 | -webkit-tap-highlight-color: transparent; 264 | } 265 | 266 | 267 | .ui.primary.buttons .button, 268 | .ui.primary.button { 269 | background-color: #2185D0; 270 | color: #FFFFFF; 271 | text-shadow: none; 272 | background-image: none; 273 | } 274 | .ui.primary.button { 275 | -webkit-box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 276 | box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 277 | } 278 | .ui.primary.buttons .button:hover, 279 | .ui.primary.button:hover { 280 | background-color: #1678c2; 281 | color: #FFFFFF; 282 | text-shadow: none; 283 | } 284 | -------------------------------------------------------------------------------- /dist/public/view/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Treeverse 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /extension/chrome/icons: -------------------------------------------------------------------------------- 1 | ../extension_common/icons -------------------------------------------------------------------------------- /extension/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Treeverse", 4 | "icons": { 5 | "16": "icons/16.png", 6 | "32": "icons/32.png", 7 | "48": "icons/48.png", 8 | "128": "icons/128.png" 9 | }, 10 | "page_action": { 11 | "default_icon": { 12 | "16": "icons/16.png", 13 | "32": "icons/32.png", 14 | "48": "icons/48.png", 15 | "128": "icons/128.png" 16 | }, 17 | "default_title": "Treeverse" 18 | }, 19 | "content_scripts": [ 20 | { 21 | "matches": ["https://twitter.com/*"], 22 | "js": ["resources/script/viewer.js"] 23 | } 24 | ], 25 | "description": "", 26 | "version": "4.0", 27 | "background": { 28 | "scripts": [ 29 | "resources/script/background.js" 30 | ] 31 | }, 32 | "permissions": [ 33 | "activeTab", 34 | "declarativeContent", 35 | "contextMenus", 36 | "https://api.twitter.com/", 37 | "https://mobile.twitter.com/", 38 | "https://treeverse.app/" 39 | ], 40 | "web_accessible_resources": [ 41 | "resources/*" 42 | ] 43 | } -------------------------------------------------------------------------------- /extension/common/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension/common/icons/128.png -------------------------------------------------------------------------------- /extension/common/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension/common/icons/16.png -------------------------------------------------------------------------------- /extension/common/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension/common/icons/256.png -------------------------------------------------------------------------------- /extension/common/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension/common/icons/32.png -------------------------------------------------------------------------------- /extension/common/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension/common/icons/48.png -------------------------------------------------------------------------------- /extension/common/resources/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension/common/resources/images/favicon.png -------------------------------------------------------------------------------- /extension/common/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Treeverse 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /extension/common/resources/style.css: -------------------------------------------------------------------------------- 1 | /* Outer page container */ 2 | body { 3 | margin: 0; 4 | font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; 5 | font-size: 14px; 6 | color: #333; 7 | } 8 | 9 | #root { 10 | display: flex; 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | } 17 | 18 | a { 19 | cursor: pointer; 20 | color: #449; 21 | text-decoration: none; 22 | } 23 | 24 | a:hover { 25 | color: #66a; 26 | } 27 | 28 | /* Tree containers */ 29 | #tree { 30 | width: 100%; 31 | height: 100%; 32 | background-color: #333; 33 | } 34 | 35 | #treeContainer { 36 | flex-grow: 1; 37 | } 38 | 39 | .selected rect { 40 | stroke: #f55; 41 | stroke-width: 4px; 42 | } 43 | 44 | /* Sidebar and info box styles. */ 45 | 46 | #sidebar { 47 | background: #eee; 48 | overflow-x: hidden; 49 | flex-basis: 500px; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | 54 | #infoBox { 55 | padding: 2px 14px; 56 | background-color: #fff; 57 | box-shadow: 0 1px 10px #ccc; 58 | } 59 | 60 | /* Feed-related elements */ 61 | #feedContainer { 62 | overflow-y: scroll; 63 | flex-grow: 1; 64 | } 65 | 66 | #feedInner { 67 | padding: 18px; 68 | } 69 | 70 | /* Tweet content styles */ 71 | .text { 72 | white-space: pre-wrap; 73 | } 74 | 75 | .text a.twitter-atreply s { 76 | text-decoration: none; 77 | } 78 | 79 | .text .Emoji, .text .twitter-hashflag-container img { 80 | height: 1.25em; 81 | vertical-align: -0.3em; 82 | } 83 | 84 | .text .u-hidden { 85 | display: none; 86 | } 87 | 88 | .text b { 89 | font-weight: normal; 90 | } 91 | 92 | .u-hiddenVisually { 93 | display: none; 94 | } 95 | 96 | .dropzone { 97 | padding: 10px; 98 | border: 1px dashed #aaf; 99 | background-color: #f4f4f4; 100 | text-align: center; 101 | color: #888; 102 | } 103 | 104 | .rtl { 105 | text-align: right; 106 | direction: rtl; 107 | unicode-bidi: embed; 108 | } 109 | 110 | 111 | /******************************* 112 | Standard 113 | *******************************/ 114 | 115 | 116 | /*-------------- 117 | Comments 118 | ---------------*/ 119 | 120 | .ui.comments { 121 | margin: 1.5em 0em; 122 | max-width: 650px; 123 | } 124 | .ui.comments:first-child { 125 | margin-top: 0em; 126 | } 127 | .ui.comments:last-child { 128 | margin-bottom: 0em; 129 | } 130 | 131 | /*-------------- 132 | Comment 133 | ---------------*/ 134 | 135 | .ui.comments .comment { 136 | position: relative; 137 | background: none; 138 | margin: 0.5em 0em 0em; 139 | padding: 0.5em 0em 0em; 140 | border: none; 141 | border-top: none; 142 | line-height: 1.2; 143 | } 144 | .ui.comments .comment:first-child { 145 | margin-top: 0em; 146 | padding-top: 0em; 147 | } 148 | 149 | /*-------------- 150 | Avatar 151 | ---------------*/ 152 | 153 | .ui.comments .comment .avatar { 154 | display: block; 155 | width: 2.5em; 156 | height: auto; 157 | float: left; 158 | margin: 0.2em 0em 0em; 159 | } 160 | .ui.comments .comment img.avatar, 161 | .ui.comments .comment .avatar img { 162 | display: block; 163 | margin: 0em auto; 164 | width: 100%; 165 | height: 100%; 166 | border-radius: 0.25rem; 167 | } 168 | 169 | /*-------------- 170 | Content 171 | ---------------*/ 172 | 173 | .ui.comments .comment > .content { 174 | display: block; 175 | } 176 | 177 | /* If there is an avatar move content over */ 178 | .ui.comments .comment > .avatar ~ .content { 179 | margin-left: 3.5em; 180 | } 181 | 182 | /*-------------- 183 | Author 184 | ---------------*/ 185 | 186 | .ui.comments .comment .author { 187 | font-size: 1em; 188 | color: rgba(0, 0, 0, 0.87); 189 | font-weight: bold; 190 | } 191 | .ui.comments .comment a.author { 192 | cursor: pointer; 193 | } 194 | .ui.comments .comment a.author:hover { 195 | color: #1e70bf; 196 | } 197 | 198 | /*-------------- 199 | Metadata 200 | ---------------*/ 201 | 202 | .ui.comments .comment .metadata { 203 | display: inline-block; 204 | margin-left: 0.5em; 205 | color: rgba(0, 0, 0, 0.4); 206 | font-size: 0.875em; 207 | } 208 | .ui.comments .comment .metadata > * { 209 | display: inline-block; 210 | margin: 0em 0.5em 0em 0em; 211 | } 212 | .ui.comments .comment .metadata > :last-child { 213 | margin-right: 0em; 214 | } 215 | 216 | /*-------------------- 217 | Comment Text 218 | ---------------------*/ 219 | 220 | .ui.comments .comment .text { 221 | margin: 0.25em 0em 0.5em; 222 | font-size: 1em; 223 | word-wrap: break-word; 224 | color: rgba(0, 0, 0, 0.87); 225 | line-height: 1.3; 226 | } 227 | 228 | /*-------------------- 229 | Button 230 | ---------------------*/ 231 | 232 | .ui.button { 233 | cursor: pointer; 234 | display: inline-block; 235 | min-height: 1em; 236 | outline: none; 237 | border: none; 238 | vertical-align: baseline; 239 | background: #E0E1E2 none; 240 | color: rgba(0, 0, 0, 0.6); 241 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 242 | margin: 0em 0.25em 0em 0em; 243 | padding: 0.78571429em 1.5em 0.78571429em; 244 | text-transform: none; 245 | text-shadow: none; 246 | font-weight: bold; 247 | line-height: 1em; 248 | font-style: normal; 249 | text-align: center; 250 | text-decoration: none; 251 | border-radius: 0.28571429rem; 252 | -webkit-box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 253 | box-shadow: 0px 0px 0px 1px transparent inset, 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 254 | -webkit-user-select: none; 255 | -moz-user-select: none; 256 | -ms-user-select: none; 257 | user-select: none; 258 | -webkit-transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 259 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 260 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease; 261 | transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease, background 0.1s ease, -webkit-box-shadow 0.1s ease; 262 | will-change: ''; 263 | -webkit-tap-highlight-color: transparent; 264 | } 265 | 266 | 267 | .ui.primary.buttons .button, 268 | .ui.primary.button { 269 | background-color: #2185D0; 270 | color: #FFFFFF; 271 | text-shadow: none; 272 | background-image: none; 273 | } 274 | .ui.primary.button { 275 | -webkit-box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 276 | box-shadow: 0px 0em 0px 0px rgba(34, 36, 38, 0.15) inset; 277 | } 278 | .ui.primary.buttons .button:hover, 279 | .ui.primary.button:hover { 280 | background-color: #1678c2; 281 | color: #FFFFFF; 282 | text-shadow: none; 283 | } 284 | -------------------------------------------------------------------------------- /extension/firefox/icons: -------------------------------------------------------------------------------- 1 | ../extension_common/icons -------------------------------------------------------------------------------- /extension/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Treeverse", 4 | "icons": { 5 | "16": "icons/16.png", 6 | "32": "icons/32.png", 7 | "48": "icons/48.png", 8 | "128": "icons/128.png" 9 | }, 10 | "page_action": { 11 | "browser_style": true, 12 | "default_icon": { 13 | "19": "icons/16.png", 14 | "38": "icons/32.png" 15 | }, 16 | "default_title": "Treeverse" 17 | }, 18 | "description": "", 19 | "version": "4.0", 20 | "background": { 21 | "scripts": [ 22 | "resources/script/background.js" 23 | ] 24 | }, 25 | "permissions": [ 26 | "tabs", 27 | "contextMenus", 28 | "https://*.twitter.com/", 29 | "https://treeverse.app/" 30 | ], 31 | "web_accessible_resources": [ 32 | "resources/*" 33 | ] 34 | } -------------------------------------------------------------------------------- /extension_chrome.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension_chrome.zip -------------------------------------------------------------------------------- /extension_firefox.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/extension_firefox.zip -------------------------------------------------------------------------------- /images/archive_mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/archive_mode.png -------------------------------------------------------------------------------- /images/chrome_treeverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/chrome_treeverse.gif -------------------------------------------------------------------------------- /images/download_chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/download_chrome.png -------------------------------------------------------------------------------- /images/download_moz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/download_moz.png -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 62 | 67 | 72 | 77 | 82 | 87 | 96 | 101 | 110 | 115 | 124 | 133 | 142 | 151 | 160 | 169 | 180 | 188 | 196 | 207 | 214 | 225 | 236 | 247 | 258 | 269 | 277 | 284 | 291 | 300 | 311 | 319 | 327 | 335 | 342 | 353 | 364 | 375 | 386 | 395 | 403 | 412 | 421 | 430 | 438 | 449 | 461 | 470 | 478 | 486 | 495 | 507 | 519 | 531 | 543 | 555 | 556 | 557 | -------------------------------------------------------------------------------- /images/moz_treeverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/moz_treeverse.gif -------------------------------------------------------------------------------- /images/red_circles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/red_circles.png -------------------------------------------------------------------------------- /images/right_pane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/right_pane.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/screenshot.png -------------------------------------------------------------------------------- /images/treeverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/treeverse.gif -------------------------------------------------------------------------------- /images/treeverse640.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houshuang/Treeverse/e030d61230cd2a9b5b3d12cce4798f8d78912855/images/treeverse640.gif -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./node_modules/.bin/eslint src --ext ts --fix 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "treeverse", 3 | "devDependencies": { 4 | "@types/chrome": "^0.0.104", 5 | "@types/d3": "^5.7.2", 6 | "copy-webpack-plugin": "^5.1.1", 7 | "ts-loader": "^6.2.2", 8 | "typescript": "^3.8.3", 9 | "webpack": "^4.42.1", 10 | "webpack-cli": "^3.3.11", 11 | "webpack-dev-server": "^3.10.3" 12 | }, 13 | "version": "0.0.1", 14 | "description": "Treeverse Chrome Extension", 15 | "author": "Paul Butler", 16 | "license": "MIT", 17 | "dependencies": { 18 | "d3": "^5.15.1", 19 | "lodash": "^4.17.15" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/background/chrome_action.ts: -------------------------------------------------------------------------------- 1 | import { matchTweetURL, clickAction, onMessageFromContentScript } from './common' 2 | 3 | chrome.pageAction.onClicked.addListener(clickAction) 4 | 5 | chrome.runtime.onInstalled.addListener(() => { 6 | (chrome).declarativeContent.onPageChanged.removeRules(undefined, () => { 7 | (chrome).declarativeContent.onPageChanged.addRules([ 8 | { 9 | conditions: [ 10 | new (chrome).declarativeContent.PageStateMatcher({ 11 | pageUrl: { 12 | urlMatches: matchTweetURL 13 | } 14 | }) 15 | ], 16 | actions: [new (chrome).declarativeContent.ShowPageAction()] 17 | } 18 | ]) 19 | }) 20 | }) 21 | 22 | chrome.runtime.onMessage.addListener(onMessageFromContentScript) 23 | -------------------------------------------------------------------------------- /src/background/common.ts: -------------------------------------------------------------------------------- 1 | export let matchTweetURL = 'https?://(?:mobile\\.)?twitter.com/(.+)/status/(\\d+)' 2 | export let matchTweetURLRegex = new RegExp(matchTweetURL) 3 | 4 | const tweetToLoad: {value?: string} = {} 5 | 6 | export function onMessageFromContentScript(request, sender, _sendResponse) { 7 | switch (request.message) { 8 | case 'share': 9 | // Handle share button click. The payload is the tree structure. 10 | fetch('https://treeverse.app/share', { 11 | method: 'POST', 12 | body: JSON.stringify(request.payload), 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | }).then((response) => response.text()) 17 | .then((response) => chrome.tabs.create({ url: response })) 18 | break; 19 | 20 | case 'ready': 21 | if (tweetToLoad.value) { 22 | launchTreeverse(sender.tab.id, tweetToLoad.value) 23 | tweetToLoad.value = null 24 | } 25 | 26 | break; 27 | } 28 | } 29 | 30 | export function launchTreeverse(tabId: number, tweetId: string) { 31 | chrome.tabs.sendMessage(tabId, { 32 | 'action': 'launch', 33 | 'tweetId': tweetId 34 | }) 35 | } 36 | 37 | export function getTweetIdFromURL(url: string): string { 38 | let match = matchTweetURLRegex.exec(url) 39 | if (match) { 40 | return match[2] 41 | } 42 | } 43 | 44 | export async function injectScripts(tabId: number, tweetId: string) { 45 | let state = await new Promise((resolve) => { 46 | chrome.tabs.executeScript(tabId, { 47 | code: `(typeof Treeverse !== 'undefined') ? Treeverse.PROXY.state : 'missing'` 48 | }, resolve) 49 | }) 50 | 51 | console.log('state', state) 52 | 53 | switch (state[0]) { 54 | case 'ready': 55 | launchTreeverse(tabId, tweetId) 56 | break; 57 | case 'listening': 58 | case 'waiting': 59 | case 'missing': 60 | default: 61 | console.log(`Treeverse in non-ready state ${state[0]}`) 62 | tweetToLoad.value = tweetId 63 | 64 | // Force the tab to reload. 65 | chrome.tabs.reload(tabId) 66 | 67 | // Ensure the tab loads. 68 | setTimeout(() => { 69 | if (tweetToLoad.value !== null) { 70 | alert(`Treeverse was unable to access the tweet data needed to launch. 71 | 72 | If you report this error, please mention that the last proxy state was ${state[0]}`) 73 | } 74 | }, 2000) 75 | } 76 | } 77 | 78 | export function clickAction(tab: chrome.tabs.Tab) { 79 | const tweetId = getTweetIdFromURL(tab.url) 80 | injectScripts(tab.id, tweetId) 81 | } -------------------------------------------------------------------------------- /src/background/firefox_action.ts: -------------------------------------------------------------------------------- 1 | import { matchTweetURL, clickAction, onMessageFromContentScript } from './common' 2 | 3 | chrome.pageAction.onClicked.addListener(clickAction) 4 | 5 | chrome.tabs.onUpdated.addListener((id, changeInfo, tab) => { 6 | if (!changeInfo.url) { 7 | return 8 | } else if (changeInfo.url.match(matchTweetURL)) { 9 | chrome.pageAction.show(tab.id) 10 | } else { 11 | chrome.pageAction.hide(tab.id) 12 | } 13 | }) 14 | 15 | chrome.runtime.onMessage.addListener(onMessageFromContentScript) 16 | -------------------------------------------------------------------------------- /src/content/main.ts: -------------------------------------------------------------------------------- 1 | function init() { 2 | let oldSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader 3 | let treeverseAuth = {} 4 | 5 | window.XMLHttpRequest.prototype.setRequestHeader = function (h, v) { 6 | if (h === 'x-csrf-token' || h === 'authorization') { 7 | treeverseAuth[h] = v 8 | 9 | window.postMessage({ 10 | action: 'state', 11 | state: 'ready', 12 | }, '*') 13 | } 14 | oldSetRequestHeader.apply(this, [h, v]) 15 | } 16 | 17 | window.addEventListener("message", (message) => { 18 | if (message.data.action === 'fetch') { 19 | fetch(message.data.url, { 20 | credentials: 'include', 21 | headers: treeverseAuth 22 | }).then((x) => x.json()).then((x) => { 23 | window.postMessage({ 24 | action: 'result', 25 | key: message.data.key, 26 | result: x 27 | }, '*') 28 | }) 29 | } 30 | }, false) 31 | 32 | window.postMessage({ 33 | action: 'state', 34 | state: 'listening', 35 | }, '*') 36 | } 37 | 38 | init() 39 | -------------------------------------------------------------------------------- /src/viewer/api.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface APIResponse { 3 | globalObjects: { 4 | tweets: { 5 | [id: string]: { 6 | conversation_id_str: string, 7 | created_at: string, 8 | display_text_range: [number], 9 | favorite_count: number, 10 | //text: string, 11 | full_text: string, 12 | id_str: string, 13 | in_reply_to_status_id_str: string, 14 | lang: string, 15 | possibly_sensitive_editable: boolean, 16 | reply_count: number, 17 | retweet_count: number, 18 | source: string, 19 | user_id_str: string 20 | entities: any 21 | } 22 | }, 23 | users: { 24 | [id: string]: { 25 | name: string, 26 | screen_name: string, 27 | profile_image_url_https: string, 28 | } 29 | } 30 | }, 31 | timeline: { 32 | instructions: [ 33 | { 34 | addEntries: { 35 | entries: [ 36 | { 37 | entryId: string, 38 | content: { 39 | operation: { 40 | cursor: { 41 | cursorType: string, 42 | value: string 43 | } 44 | }, 45 | item: { 46 | content: { 47 | tweet: { 48 | displayType: string, 49 | id: string 50 | } 51 | } 52 | } 53 | } 54 | } 55 | ] 56 | } 57 | } 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/viewer/feed_controller.ts: -------------------------------------------------------------------------------- 1 | import { PointNode } from './visualization_controller' 2 | import * as d3 from 'd3' 3 | import { TweetNode } from './tweet_tree' 4 | 5 | /** 6 | * Controller for the "feed" display that shows the conversation 7 | * leading up to the selected tweet. 8 | */ 9 | export class FeedController { 10 | private container: HTMLElement; 11 | 12 | constructor(container: HTMLElement) { 13 | this.container = container 14 | } 15 | 16 | async exitComments(comments: d3.Selection): Promise { 17 | return new Promise((resolve) => { 18 | if (comments.exit().size() == 0) { 19 | resolve() 20 | return 21 | } 22 | comments 23 | .exit() 24 | .transition().duration(100) 25 | .on('end', () => resolve()) 26 | .style('opacity', 0) 27 | .remove() 28 | }) 29 | } 30 | 31 | async enterComments(comments): Promise { 32 | return new Promise((resolve) => { 33 | if (comments.enter().size() == 0) { 34 | resolve() 35 | return 36 | } 37 | comments 38 | .enter() 39 | .append('div') 40 | .classed('comment', true) 41 | .each(function (this: Element, datum: PointNode) { 42 | let tweet = datum.data.tweet 43 | let div = d3.select(this) 44 | 45 | div 46 | .append('a') 47 | .classed('avatar', true) 48 | .append('img') 49 | .attr('src', tweet.avatar) 50 | .style('height', 'auto') 51 | .style('max-width', '35px') 52 | .style('width', 'auto') 53 | .style('max-height', '35px') 54 | 55 | let content = div 56 | .append('div') 57 | .classed('content', true) 58 | 59 | content 60 | .append('span') 61 | .classed('author', true) 62 | .html(`${tweet.name} (@${tweet.username})`) 63 | 64 | let body = content 65 | .append('div') 66 | .classed('text', true) 67 | .classed('rtl', tweet.rtl) 68 | .html(tweet.bodyHtml) 69 | 70 | body.append('a') 71 | .html(' →') 72 | .attr('href', tweet.getUrl()) 73 | 74 | if (tweet.images) { 75 | let imgWidth = 100 / tweet.images.length 76 | content.append('div') 77 | .classed('extra images', true) 78 | .selectAll('img') 79 | .data(tweet.images) 80 | .enter() 81 | .append('img') 82 | .attr('width', (d) => `${imgWidth}%`) 83 | .attr('src', (d) => d) 84 | } 85 | }) 86 | .style('opacity', 0) 87 | .style('display', 'none') 88 | .transition().duration(100) 89 | .style('display', 'block') 90 | .style('opacity', 1) 91 | .on('start', () => resolve()) 92 | }) 93 | } 94 | 95 | async setFeed(node: PointNode) { 96 | let ancestors = node.ancestors() 97 | ancestors.reverse() 98 | 99 | let comments = d3 100 | .select(this.container.getElementsByClassName('comments')[0]) 101 | .selectAll('div.comment') 102 | .data(ancestors, (d: d3.HierarchyPointNode) => d.data.getId()) 103 | 104 | await this.exitComments(comments) 105 | await this.enterComments(comments) 106 | 107 | d3.transition(null).tween('scroll', 108 | () => { 109 | let interp = d3.interpolateNumber(this.container.scrollTop, this.container.scrollHeight) 110 | return (t) => this.container.scrollTop = interp(t) 111 | } 112 | ) 113 | 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/viewer/main.ts: -------------------------------------------------------------------------------- 1 | import { VisualizationController } from './visualization_controller' 2 | import { createPage } from './page' 3 | import {ContentProxy} from './proxy' 4 | 5 | /** 6 | * Contains entry points for bootstrapping the visualization for 7 | * different modes. 8 | */ 9 | export namespace Treeverse { 10 | export const PROXY = new ContentProxy() 11 | PROXY.inject() 12 | 13 | chrome.runtime.onMessage.addListener( 14 | function(request, _sender, _sendResponse) { 15 | var baseUrl = chrome.extension.getURL('resources') 16 | 17 | if (request.action === 'launch') { 18 | Treeverse.initialize(baseUrl, request.tweetId) 19 | } 20 | }) 21 | 22 | export function initialize(baseUrl: string, tweetId: string) { 23 | fetch(baseUrl + '/index.html').then((response) => response.text()).then((html) => { 24 | let parser = new DOMParser() 25 | let doc = parser.parseFromString(html, 'text/html') 26 | 27 | let baseEl = doc.createElement('base') 28 | baseEl.setAttribute('href', baseUrl + '/resources') 29 | doc.head.prepend(baseEl) 30 | 31 | window.history.pushState('', '', '') 32 | 33 | window.addEventListener('popstate', function (e) { 34 | window.location.reload() 35 | }) 36 | 37 | document.getElementsByTagName('head')[0].replaceWith(doc.head) 38 | document.getElementsByTagName('body')[0].replaceWith(doc.body) 39 | 40 | createPage(document.getElementById('root')) 41 | 42 | let controller = new VisualizationController(Treeverse.PROXY) 43 | controller.fetchTweets(tweetId) 44 | }) 45 | } 46 | } 47 | 48 | (window as any).Treeverse = Treeverse -------------------------------------------------------------------------------- /src/viewer/page.ts: -------------------------------------------------------------------------------- 1 | export function createPage(container: HTMLElement) { 2 | let pageHTML = ` 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | Colors 18 | represent reply times: 19 | 5 minutes 20 | 10 minutes 21 | 1 hour 22 | 3 hours+ 23 |
24 |
25 | 44 | ` 45 | 46 | container.innerHTML = pageHTML 47 | } 48 | -------------------------------------------------------------------------------- /src/viewer/proxy.ts: -------------------------------------------------------------------------------- 1 | enum Action { 2 | state = 'state', 3 | result = 'result', 4 | fetch = 'fetch' 5 | } 6 | 7 | enum State { 8 | ready = 'ready', 9 | listening = 'listening', 10 | waiting = 'waiting' 11 | } 12 | 13 | interface FetchRequest { 14 | url: string, 15 | key: string, 16 | action: 'fetch' 17 | } 18 | 19 | interface FetchResponse { 20 | result: any, 21 | key: string, 22 | action: Action.result 23 | } 24 | 25 | interface StateResponse { 26 | action: Action.state, 27 | state: State 28 | } 29 | 30 | export class ContentProxy { 31 | callbacks: Map void> = new Map() 32 | state: State = State.waiting 33 | 34 | async delegatedFetch(url): Promise { 35 | return new Promise((resolve: (FetchResponse) => void) => { 36 | this.callbacks.set(url, resolve) 37 | const request: FetchRequest = { 38 | action: Action.fetch, 39 | key: url, 40 | url: url 41 | } 42 | window.postMessage(request, '*') 43 | }) 44 | } 45 | 46 | async inject() { 47 | let scr = document.createElement('script') 48 | scr.setAttribute('src', chrome.runtime.getURL("resources/script/content.js")) 49 | 50 | window.addEventListener("message", (message) => { 51 | switch (message.data.action) { 52 | case Action.state: 53 | const actionData = message.data as StateResponse 54 | this.state = actionData.state 55 | 56 | if (this.state === 'ready') { 57 | chrome.runtime.sendMessage({message: 'ready'}); 58 | } 59 | 60 | break; 61 | case Action.result: 62 | const resultData = message.data as FetchResponse 63 | const callback = this.callbacks.get(resultData.key) 64 | callback(resultData.result) 65 | break; 66 | } 67 | }, false) 68 | document.body.appendChild(scr); 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /src/viewer/serialize.ts: -------------------------------------------------------------------------------- 1 | import { Tweet } from './tweet_parser' 2 | import { TweetNode } from './tweet_tree' 3 | 4 | export class SerializedTweetNode { 5 | tweet: Tweet; 6 | children: SerializedTweetNode[] = []; 7 | 8 | static fromTweetNode(tn: TweetNode) { 9 | let stn = new SerializedTweetNode() 10 | stn.tweet = tn.tweet 11 | tn.children.forEach((v: TweetNode) => { 12 | stn.children.push(SerializedTweetNode.fromTweetNode(v)) 13 | }) 14 | return stn 15 | } 16 | 17 | static toTweetNode(obj) { 18 | let tweet = new Tweet() 19 | Object.assign(tweet, obj.tweet) 20 | let tn = new TweetNode(tweet); 21 | (obj.children).forEach((child) => { 22 | tn.children.set(child.tweet.id, SerializedTweetNode.toTweetNode(child)) 23 | }) 24 | tn.fullyLoaded = true 25 | return tn 26 | } 27 | } -------------------------------------------------------------------------------- /src/viewer/toolbar.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | 3 | export class Toolbar { 4 | container: HTMLElement; 5 | 6 | constructor(element: HTMLElement) { 7 | this.container = element 8 | } 9 | 10 | addButton(label: string, onClicked: () => void): HTMLButtonElement { 11 | return d3.select(this.container) 12 | .append('button') 13 | .text(label) 14 | .classed('ui primary button', true) 15 | .style('margin-bottom', '10px') 16 | .on('click', onClicked) 17 | .node() as HTMLButtonElement 18 | } 19 | } -------------------------------------------------------------------------------- /src/viewer/tweet_parser.ts: -------------------------------------------------------------------------------- 1 | import { APIResponse } from './api' 2 | 3 | /** 4 | * Contains information about an individual tweet. 5 | */ 6 | export class Tweet { 7 | /** Unique identifier of the tweet. */ 8 | id: string; 9 | /** Handle of user who posted tweet. */ 10 | username: string; 11 | /** Screen name of user who posted tweet. */ 12 | name: string; 13 | /** HTML body of the tweet content. */ 14 | bodyHtml: string; 15 | bodyText: string; 16 | /** URL of the avatar image for the user who posted the tweet. */ 17 | avatar: string; 18 | /** Time of the tweet in milliseconds since epoch. */ 19 | time: number; 20 | /** Number of replies (public and private) to the tweet. */ 21 | replies: number; 22 | /** Whether to render the tweet as right-to-left. */ 23 | rtl: boolean; 24 | parent: string; 25 | entities: any; 26 | 27 | images: string[] = []; 28 | 29 | /** 30 | * Returns a URL to this tweet on Twitter. 31 | */ 32 | getUrl() { 33 | return `https://twitter.com/${this.username}/status/${this.id}` 34 | } 35 | 36 | /** 37 | * Returns a URL to the profile that posted this tweet on Twitter. 38 | */ 39 | getUserUrl() { 40 | return `https://twitter.com/${this.username}` 41 | } 42 | } 43 | 44 | export interface TweetSet { 45 | rootTweet: string 46 | tweets: Tweet[] 47 | cursor: string 48 | } 49 | 50 | /** 51 | * Functions for parsing a response from the twitter API into Tweet and 52 | * TweetContext objects. 53 | */ 54 | export namespace TweetParser { 55 | export function parseCursor(response: APIResponse): string { 56 | let cursor = null 57 | for (let entry of response.timeline.instructions[0].addEntries.entries) { 58 | if (entry.content.operation && entry.content.operation.cursor) { 59 | if (entry.content.operation.cursor.cursorType === 'Bottom' || 60 | entry.content.operation.cursor.cursorType === 'ShowMoreThreads') { 61 | cursor = entry.content.operation.cursor.value 62 | } 63 | } 64 | } 65 | return cursor 66 | } 67 | 68 | export function parseTweets(response: APIResponse): Tweet[] { 69 | let tweets = [] 70 | let users = new Map() 71 | 72 | for (let userId in response.globalObjects.users) { 73 | let user = response.globalObjects.users[userId] 74 | users.set(userId, { 75 | handle: user.screen_name, 76 | name: user.name, 77 | avatar: user.profile_image_url_https 78 | }) 79 | } 80 | 81 | for (let tweetId in response.globalObjects.tweets) { 82 | let entry = response.globalObjects.tweets[tweetId] 83 | let tweet = new Tweet() 84 | let user = users.get(entry.user_id_str) 85 | 86 | tweet.id = entry.id_str 87 | tweet.bodyText = entry.full_text 88 | tweet.bodyHtml = entry.full_text 89 | tweet.name = user.name 90 | tweet.username = user.handle 91 | tweet.avatar = user.avatar 92 | tweet.parent = entry.in_reply_to_status_id_str 93 | tweet.time = new Date(entry.created_at).getTime() 94 | tweet.replies = entry.reply_count 95 | tweet.entities = entry.entities 96 | 97 | tweets.push(tweet) 98 | } 99 | return tweets 100 | } 101 | 102 | export function parseResponse(rootTweet: string, response: APIResponse): TweetSet { 103 | const tweets = parseTweets(response) 104 | const cursor = parseCursor(response) 105 | return { tweets, cursor, rootTweet } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/viewer/tweet_server.ts: -------------------------------------------------------------------------------- 1 | import { TweetParser, TweetSet } from './tweet_parser' 2 | import {ContentProxy} from './proxy' 3 | 4 | function getUrlForTweetId(tweetId: string, cursor: string): string { 5 | let params = new URLSearchParams({ 6 | include_reply_count: '1', 7 | tweet_mode: 'extended' 8 | }) 9 | 10 | if (cursor !== null && cursor !== undefined) { 11 | params.set('cursor', cursor) 12 | } 13 | 14 | return `https://api.twitter.com/2/timeline/conversation/${tweetId}.json?${params.toString()}` 15 | } 16 | 17 | /** 18 | * Interfaces with Twitter API server. 19 | */ 20 | export class TweetServer { 21 | constructor(private proxy: ContentProxy) { } 22 | 23 | /** 24 | * Requests the TweetContext for a given tweet and returns a promise. 25 | */ 26 | async requestTweets(tweetId: string, cursor: string): Promise { 27 | let response = await this.asyncGet(tweetId, cursor) 28 | 29 | return TweetParser.parseResponse(tweetId, response as any) 30 | } 31 | 32 | async asyncGet(tweetId: string, cursor: string) { 33 | let url = getUrlForTweetId(tweetId, cursor); 34 | 35 | return this.proxy.delegatedFetch(url) 36 | } 37 | } -------------------------------------------------------------------------------- /src/viewer/tweet_tree.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import { TweetSet, Tweet } from './tweet_parser' 3 | 4 | 5 | export class TweetTree { 6 | root: TweetNode 7 | index: Map 8 | 9 | private constructor() { } 10 | 11 | static fromTweetSet(tweetSet: TweetSet): TweetTree { 12 | let tree = new TweetTree() 13 | 14 | tree.index = new Map() 15 | let { tweets, rootTweet } = tweetSet 16 | 17 | for (let tweet of tweets) { 18 | if (tweet.id == rootTweet) { 19 | tree.root = new TweetNode(tweet) 20 | tree.index.set(tweet.id, tree.root) 21 | break 22 | } 23 | } 24 | 25 | tree.addTweets(tweetSet) 26 | 27 | return tree 28 | } 29 | 30 | static fromRoot(root: TweetNode) { 31 | let tree = new TweetTree() 32 | 33 | tree.root = root 34 | return tree 35 | } 36 | 37 | setCursor(tweetId: string, cursor: string) { 38 | this.index.get(tweetId).cursor = cursor 39 | } 40 | 41 | addTweets(tweetSet: TweetSet) { 42 | let count = 0 43 | let { tweets, rootTweet, cursor } = tweetSet 44 | 45 | tweets.sort((a, b) => parseInt(a.id) - parseInt(b.id)) 46 | 47 | for (let tweet of tweets) { 48 | if (!this.index.has(tweet.id)) { 49 | count += 1 50 | let node = new TweetNode(tweet) 51 | if (tweet.parent && this.index.has(tweet.parent)) { 52 | this.index.get(tweet.parent).children.set(tweet.id, node) 53 | } 54 | this.index.set(tweet.id, node) 55 | } 56 | } 57 | 58 | if (cursor) { 59 | this.index.get(rootTweet).cursor = cursor 60 | } else { 61 | this.index.get(rootTweet).fullyLoaded = true 62 | } 63 | return count 64 | } 65 | 66 | toHierarchy() { 67 | return d3.hierarchy(this.root, (d: TweetNode) => Array.from(d.children.values())) 68 | } 69 | } 70 | 71 | /** 72 | * A tree node representing an individual tweet. 73 | */ 74 | export class TweetNode { 75 | children: Map; 76 | 77 | tweet: Tweet; 78 | cursor: string; 79 | fullyLoaded: boolean; 80 | 81 | constructor(tweet: Tweet) { 82 | this.children = new Map() 83 | this.tweet = tweet 84 | } 85 | 86 | getId() { 87 | return this.tweet.id 88 | } 89 | 90 | /** 91 | * Return false iff this tweet has more replies that we know about. 92 | */ 93 | hasMore(): boolean { 94 | // The fully loaded flag takes precedence because sometimes the 95 | // reply count from twitter is greater than the number of tweets 96 | // we actually get back from the API. This is probably because of 97 | // replies from private accounts. 98 | if (this.fullyLoaded) return false 99 | if (this.cursor) return true 100 | return this.children.size < this.tweet.replies 101 | } 102 | } -------------------------------------------------------------------------------- /src/viewer/tweet_visualization.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import { PointNode } from './visualization_controller' 3 | import { TweetNode, TweetTree } from './tweet_tree' 4 | 5 | type D3Selector = d3.Selection; 6 | 7 | /** 8 | * Renders the main tree visualization. 9 | */ 10 | export class TweetVisualization { 11 | private container: D3Selector; 12 | private treeGroup: D3Selector; 13 | private nodes: D3Selector; 14 | private edges: D3Selector; 15 | private zoom: d3.ZoomBehavior; 16 | private listeners: d3.Dispatch; 17 | private colorScale: d3.ScalePower; 18 | private selected: d3.HierarchyPointNode; 19 | private xscale: number; 20 | private yscale: number; 21 | private layout: d3.HierarchyPointNode; 22 | 23 | constructor(svgElement: HTMLElement) { 24 | this.buildTree(svgElement) 25 | this.listeners = d3.dispatch('hover', 'click', 'dblclick') 26 | 27 | let timeIntervals = [ 28 | 300, 29 | 600, 30 | 3600, 31 | 10800 32 | ] 33 | let timeColors = [ 34 | '#FA5050', 35 | '#E9FA50', 36 | '#F5F1D3', 37 | '#47D8F5' 38 | ] 39 | 40 | this.colorScale = d3.scaleSqrt() 41 | .domain(timeIntervals) 42 | .range(timeColors) 43 | } 44 | 45 | on(eventType, callback) { 46 | this.listeners.on(eventType, callback) 47 | } 48 | 49 | private colorEdge(edgeTarget: d3.HierarchyNode) { 50 | let data = edgeTarget.data 51 | let timeDelta = (data.tweet.time - (edgeTarget.parent.data).tweet.time) / 1000 52 | return this.colorScale(timeDelta).toString() 53 | } 54 | 55 | private static treeWidth(hierarchy: d3.HierarchyNode) { 56 | let widths = new Map() 57 | hierarchy.each((node) => { 58 | widths.set(node.depth, (widths.get(node.depth) || 0) + 1) 59 | }) 60 | 61 | return Math.max.apply(null, Array.from(widths.values())) 62 | } 63 | 64 | private buildTree(container: HTMLElement) { 65 | this.container = d3.select(container) 66 | this.treeGroup = this.container.append('g') 67 | 68 | this.edges = this.treeGroup.append('g') 69 | this.nodes = this.treeGroup.append('g') 70 | 71 | this.container.on('click', () => { this.selected = null; this.redraw() }) 72 | 73 | // Set up zoom functionality. 74 | this.zoom = d3.zoom() 75 | .scaleExtent([0, 2]) 76 | .on('zoom', () => { 77 | let x = d3.event.transform.x 78 | let y = d3.event.transform.y 79 | let scale = d3.event.transform.k 80 | 81 | this.treeGroup.attr('transform', `translate(${x} ${y}) scale(${scale})`) 82 | }) 83 | this.container.call(this.zoom) 84 | 85 | d3.select('body').on('keydown', () => { 86 | if (!this.selected) { 87 | return 88 | } 89 | switch (d3.event.code) { 90 | case 'ArrowDown': 91 | if (this.selected.children && this.selected.children.length > 0) { 92 | this.selected = this.selected.children[0] 93 | } 94 | break 95 | case 'ArrowUp': 96 | if (this.selected.parent) { 97 | this.selected = this.selected.parent 98 | } 99 | break 100 | case 'ArrowLeft': 101 | if (this.selected.parent) { 102 | let i = this.selected.parent.children.indexOf(this.selected) 103 | if (i > 0) { 104 | this.selected = this.selected.parent.children[i - 1] 105 | } 106 | } 107 | break 108 | case 'ArrowRight': 109 | if (this.selected.parent) { 110 | let i = this.selected.parent.children.indexOf(this.selected) 111 | if (i >= 0 && i < this.selected.parent.children.length - 1) { 112 | this.selected = this.selected.parent.children[i + 1] 113 | } 114 | } 115 | break 116 | case 'Space': 117 | this.listeners.call('dblclick', null, this.selected.data) 118 | break 119 | default: 120 | return 121 | } 122 | this.redraw() 123 | this.listeners.call('hover', null, this.selected) 124 | }) 125 | } 126 | 127 | zoomToFit() { 128 | let clientRect = this.container.node().getBoundingClientRect() 129 | let zoomLevel = Math.min(clientRect.height / this.yscale, clientRect.width / this.xscale, 1) 130 | 131 | this.container.transition().call(this.zoom.transform, d3.zoomIdentity.translate( 132 | Math.max(0, (clientRect.width - this.xscale * zoomLevel) / 2), 133 | Math.max(20, (clientRect.height - this.yscale * zoomLevel) / 2) 134 | ).scale(zoomLevel)) 135 | } 136 | 137 | setTreeData(tree: TweetTree) { 138 | let hierarchy = tree.toHierarchy() 139 | let layout = d3.tree().separation((a, b) => a.children || b.children ? 3 : 2)(hierarchy) 140 | this.layout = >layout 141 | 142 | let maxWidth = TweetVisualization.treeWidth(hierarchy) 143 | 144 | this.xscale = maxWidth * 120 145 | this.yscale = hierarchy.height * 120 146 | 147 | this.redraw() 148 | } 149 | 150 | redraw() { 151 | if (!this.layout) { 152 | return 153 | } 154 | 155 | let edgeToPath = (d: d3.HierarchyPointNode) => { 156 | let startX = this.xscale * d.parent.x 157 | let startY = this.yscale * d.parent.y 158 | let endY = this.yscale * d.y 159 | let endX = this.xscale * d.x 160 | return `M${startX},${startY} C${startX},${startY} ${endX},${startY} ${endX},${endY}` 161 | } 162 | 163 | let duration = 200 164 | 165 | let paths = this.edges 166 | .selectAll('path') 167 | .data(this.layout.descendants().slice(1), (d: d3.HierarchyPointNode) => d.data.getId()) 168 | 169 | paths.exit().remove() 170 | paths.attr('opacity', 1).transition().duration(duration).attr('d', edgeToPath) 171 | 172 | paths 173 | .enter() 174 | .append('path') 175 | .attr('d', edgeToPath) 176 | .attr('fill', 'none') 177 | .attr('stroke-width', 2) 178 | .attr('stroke', this.colorEdge.bind(this)) 179 | .attr('opacity', 0) 180 | .transition().delay(duration) 181 | .attr('opacity', 1) 182 | 183 | let descendents = this.layout.descendants() 184 | 185 | if (this.selected) { 186 | // If a node is selected, find the node in the new tree with the same ID and select it. 187 | this.selected = descendents.find((d) => d.data.getId() == this.selected.data.getId()) 188 | } 189 | 190 | let nodes = this.nodes.selectAll('g') 191 | .data(descendents, (d: d3.HierarchyPointNode) => d.data.getId()) 192 | 193 | nodes.exit().remove() 194 | 195 | nodes.transition() 196 | .duration(duration) 197 | .attr('transform', d => `translate(${(this.xscale * d.x) - 20} ${(this.yscale * d.y) - 20})`) 198 | 199 | nodes.each((datum, i, selection) => { 200 | let data = datum.data 201 | if (!data.hasMore()) { 202 | d3.select(selection[i]).select('.has_more_icon').remove() 203 | } 204 | }) 205 | 206 | nodes 207 | .classed('selected', (d: d3.HierarchyPointNode) => d == this.selected) 208 | .attr('opacity', 1) 209 | 210 | nodes.enter() 211 | .append('g') 212 | .style('cursor', 'pointer') 213 | .on('mouseover', (e: PointNode) => { 214 | if (!this.selected) { 215 | this.listeners.call('hover', null, e) 216 | } 217 | }) 218 | .on('click', (e: PointNode) => { 219 | this.listeners.call('hover', null, e) 220 | this.selected = e 221 | this.redraw() 222 | d3.event.stopPropagation() 223 | }) 224 | .on('dblclick', (e: PointNode) => { 225 | this.listeners.call('dblclick', null, e.data) 226 | d3.event.stopPropagation() 227 | this.selected = null 228 | }) 229 | .classed('has_more', (d: d3.HierarchyPointNode) => d.data.hasMore()) 230 | .attr('transform', d => `translate(${(this.xscale * d.x) - 20} ${(this.yscale * d.y) - 20})`) 231 | .each(function (this: Element, datum: PointNode) { 232 | let group = d3.select(this) 233 | let tweet = datum.data.tweet 234 | 235 | group.append('rect') 236 | .attr('height', 40) 237 | .attr('width', 40) 238 | .attr('fill', 'white') 239 | 240 | group.append('image') 241 | .attr('xlink:href', tweet.avatar) 242 | .attr('height', 40) 243 | .attr('width', 40) 244 | 245 | group.append('rect') 246 | .attr('x', -1) 247 | .attr('y', -1) 248 | .attr('height', 42) 249 | .attr('width', 42) 250 | .attr('stroke', '#ddd') 251 | .attr('stroke-width', '3px') 252 | .attr('rx', '4px') 253 | .attr('fill', 'none') 254 | 255 | group.call((selection) => { 256 | let data = (selection.datum() as any).data 257 | if (data.hasMore()) { 258 | selection.append('use') 259 | .classed('has_more_icon', true) 260 | .attr('xlink:href', '#has_more') 261 | .attr('transform', 'scale(0.5) translate(55 55)') 262 | } 263 | }) 264 | }) 265 | .attr('opacity', 0) 266 | .transition().delay(duration) 267 | .attr('opacity', 1) 268 | } 269 | } -------------------------------------------------------------------------------- /src/viewer/visualization_controller.ts: -------------------------------------------------------------------------------- 1 | import { FeedController } from "./feed_controller"; 2 | import { TweetVisualization } from "./tweet_visualization"; 3 | import { TweetNode, TweetTree } from "./tweet_tree"; 4 | import { TweetServer } from "./tweet_server"; 5 | import { Toolbar } from "./toolbar"; 6 | import { SerializedTweetNode } from "./serialize"; 7 | import * as d3 from "d3"; 8 | import { ContentProxy } from "./proxy"; 9 | import { compact } from "lodash"; 10 | 11 | export type PointNode = d3.HierarchyPointNode; 12 | 13 | const formatTweet = (tweet, other) => { 14 | let md = tweet.bodyText; 15 | if (tweet.entities.user_mentions) { 16 | if ( 17 | tweet.entities.user_mentions.some( 18 | user => user.screen_name === "threadreaderapp" 19 | ) 20 | ) { 21 | return false; 22 | } 23 | // tweet.entities.user_mentions.forEach(user => { 24 | // md = md.split("@" + user.screen_name).join(""); 25 | // }); 26 | } 27 | if (tweet.entities.urls) { 28 | tweet.entities.urls.forEach(url => { 29 | md = md.split(url.url).join(url.expanded_url); 30 | }); 31 | } 32 | md = md.trim(); 33 | if (md === "") { 34 | return undefined; 35 | } 36 | return `${other ? `[[${tweet.name}]]: ` : ""}${md 37 | .replace(/^(\@.+?\s)+/g, "") 38 | .replace(/^[\•\-\*]/gm, "") 39 | .trim()}`; 40 | }; 41 | const formatTweets = ( 42 | node, 43 | initial = false, 44 | indent = 1, 45 | initialAuthor = "", 46 | hasSiblings = false 47 | ) => { 48 | let out = ""; 49 | if (node.tweet.username === "threadreaderapp") { 50 | return false; 51 | } 52 | 53 | const tweetTextRaw = formatTweet( 54 | node.tweet, 55 | initialAuthor === "" || node.tweet.username !== initialAuthor 56 | ); 57 | const tweetText = tweetTextRaw 58 | ? tweetTextRaw 59 | .split(/\n+/) 60 | .map(f => f.trim()) 61 | .join("\n" + " ".repeat(indent + 1) + "- ") 62 | : false; 63 | 64 | if (initial) { 65 | out = `- #[[Twitter thread]] ${tweetText} [*](${node.tweet.getUrl()})\n`; 66 | initialAuthor = node.tweet.username; 67 | } else { 68 | if (tweetText) { 69 | out = 70 | " ".repeat(indent) + 71 | "- " + 72 | tweetText + 73 | " [*](" + 74 | node.tweet.getUrl() + 75 | ")\n"; 76 | } 77 | if (node.tweet.entities.media) { 78 | node.tweet.entities.media.forEach(m => { 79 | out += 80 | " ".repeat(indent + 1) + "- " + "![](" + m.media_url_https + ")\n"; 81 | }); 82 | } 83 | } 84 | if (node.children) { 85 | const children = Array.from(node.children).filter( 86 | x => x[1].tweet.username !== "threadreaderapp" 87 | ); 88 | const multiChildren = children.length > 1; 89 | const newIndent = multiChildren || hasSiblings ? indent + 1 : indent; 90 | out += children 91 | .map(([k, v]) => 92 | formatTweets(v, false, newIndent, initialAuthor, multiChildren) 93 | ) 94 | .join("\n"); 95 | } 96 | return out; 97 | }; 98 | 99 | const expandText = "Expand All"; 100 | const cancelExpandText = "Stop Expanding"; 101 | 102 | /** 103 | * The controller for the main tree visualization. 104 | */ 105 | export class VisualizationController { 106 | private tweetTree: TweetTree; 107 | private vis: TweetVisualization; 108 | private feed: FeedController; 109 | private toolbar: Toolbar; 110 | private server: TweetServer; 111 | private expandingTimer: number; 112 | private expandButton: HTMLButtonElement; 113 | 114 | fetchTweets(tweetId: string) { 115 | this.server.requestTweets(tweetId, null).then(tweetSet => { 116 | let tweetTree = TweetTree.fromTweetSet(tweetSet); 117 | document.getElementsByTagName("title")[0].innerText = `${ 118 | tweetTree.root.tweet.username 119 | } - "${tweetTree.root.tweet.bodyText}" in Treeverse`; 120 | 121 | this.setInitialTweetData(tweetTree); 122 | }); 123 | } 124 | 125 | setInitialTweetData(tree: TweetTree) { 126 | this.tweetTree = tree; 127 | this.vis.setTreeData(tree); 128 | this.vis.zoomToFit(); 129 | } 130 | 131 | private expandNode(node: TweetNode, retry: boolean = true) { 132 | this.server.requestTweets(node.tweet.id, node.cursor).then(tweetSet => { 133 | let added = this.tweetTree.addTweets(tweetSet); 134 | if (added > 0) { 135 | this.vis.setTreeData(this.tweetTree); 136 | if (node === this.tweetTree.root) { 137 | this.vis.zoomToFit(); 138 | } 139 | } else if (retry) { 140 | this.expandNode(node, false); 141 | } 142 | }); 143 | } 144 | 145 | shareClicked() { 146 | let value = SerializedTweetNode.fromTweetNode(this.tweetTree.root); 147 | chrome.runtime.sendMessage({ payload: value, message: "share" }); 148 | } 149 | 150 | expandOne() { 151 | for (let tweetNode of this.tweetTree.index.values()) { 152 | if (tweetNode.hasMore()) { 153 | this.expandNode(tweetNode, true); 154 | return; 155 | } 156 | } 157 | this.stopExpanding(); 158 | } 159 | 160 | stopExpanding() { 161 | this.expandButton.textContent = expandText; 162 | clearInterval(this.expandingTimer); 163 | this.expandingTimer = null; 164 | } 165 | 166 | copyClipboard() { 167 | navigator.clipboard.writeText( 168 | formatTweets(this.tweetTree.root, true) || "" 169 | ); 170 | } 171 | 172 | expandAll() { 173 | if (this.expandingTimer === null) { 174 | this.expandButton.textContent = cancelExpandText; 175 | this.expandingTimer = setInterval(this.expandOne.bind(this), 1000); 176 | } else { 177 | this.stopExpanding(); 178 | } 179 | } 180 | 181 | constructor(proxy: ContentProxy = null) { 182 | const offline = proxy === null; 183 | this.server = offline ? null : new TweetServer(proxy); 184 | this.feed = new FeedController(document.getElementById("feedContainer")); 185 | this.vis = new TweetVisualization(document.getElementById("tree")); 186 | this.expandingTimer = null; 187 | 188 | this.toolbar = new Toolbar(document.getElementById("toolbar")); 189 | if (!offline) { 190 | this.toolbar.addButton( 191 | "Create shareable link", 192 | this.shareClicked.bind(this) 193 | ); 194 | this.toolbar.addButton( 195 | "Copy to clipboard", 196 | this.copyClipboard.bind(this) 197 | ); 198 | this.expandButton = this.toolbar.addButton( 199 | "Expand All", 200 | this.expandAll.bind(this) 201 | ); 202 | } 203 | 204 | this.vis.on("hover", this.feed.setFeed.bind(this.feed)); 205 | if (!offline) { 206 | this.vis.on("dblclick", this.expandNode.bind(this)); 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/viewer/web_entry.ts: -------------------------------------------------------------------------------- 1 | import { createPage } from './page' 2 | import { VisualizationController } from './visualization_controller' 3 | import { SerializedTweetNode } from './serialize' 4 | import { TweetTree } from './tweet_tree' 5 | 6 | function webEntry() { 7 | createPage(document.getElementById('root')) 8 | let controller = new VisualizationController() 9 | 10 | let parts = document.location.href.split('/') 11 | let key = parts[parts.length - 1] 12 | 13 | fetch(`https://s3.amazonaws.com/treeverse/${key}.json`).then((c) => c.json()) 14 | .then((c) => { 15 | let r = SerializedTweetNode.toTweetNode(c) 16 | let tree = TweetTree.fromRoot(r) 17 | 18 | controller.setInitialTweetData(tree) 19 | }).catch((c) => alert(c)) 20 | } 21 | 22 | webEntry() -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["es5", "es2015.promise", "dom", "es2015"], 6 | "types": ["chrome"] 7 | } 8 | } -------------------------------------------------------------------------------- /watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./node_modules/.bin/webpack --watch 4 | -------------------------------------------------------------------------------- /web/_redirects: -------------------------------------------------------------------------------- 1 | /view/* /view/index.html 200 2 | /json/* https://s3.amazonaws.com/treeverse/:splat.json 200 3 | /share https://1l8hy2eaaj.execute-api.us-east-1.amazonaws.com/default/treeverse_post 200 -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Treeverse 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 |
19 |

Treeverse icon Treeverse

20 |

21 | Treeverse is a tool for visualizing and navigating Twitter conversation threads. 22 |

23 |

24 | It is available as a browser extension for Chrome and Firefox. 25 |

26 |

Installation

27 |

For Chrome Users

28 | 29 | 30 | Download Treeverse for Chrome 31 | 32 | 33 |

For Firefox Users

34 | 35 | 36 | Download Treeverse for Firefox 37 | 38 | 39 |

Introduction

40 | 41 |

After installing Treeverse for your browser, open Twitter and click on the tweet that you would like to 42 | visualize the conversation of (or try this one.)

43 | 44 |

If you’re using Chrome, the icon for Treeverse should turn from grey to blue in your browser. Click it to enter Treeverse.

45 | 46 | Opening Treeverse in Chrome 47 | 48 |

If you're using Firefox, the icon will be hidden until you open a tweet, and then it will appear in the address bar.

49 | 50 | Opening Treeverse in Firefox 51 | 52 |

Exploring the Conversation

53 | Screenshot of Treeverse 54 | 55 |

Conversations are visualized as a tree. Each box is an individual tweet, and 56 | an line between two boxes indicates that the lower one is a reply to the upper 57 | one. The color of the line indicates the time duration between the two tweets 58 | (red is faster, blue is slower.)

59 | 60 |

As you hover over nodes, the reply-chain preceeding that tweet appears on the right-side 61 | pane. By clicking a node, you can freeze the UI on that tweet in order to interact with 62 | the right-side pane. By clicking anywhere in the tree window, you can un-freeze the tweet 63 | and return to the normal hover behavior.

64 | 65 | Right pane in action 66 | 67 |

Some tweets will appear with a red circle with white ellipses inside them, either overlayed 68 | on them or as a separate node. This means that 69 | there are more replies to that tweet that haven't been loaded. Double-clicking a node will 70 | load additional replies to that tweet.

71 | 72 | More tweets indicator 73 | 74 |

Privacy

75 | 76 |

Treeverse runs entirely in your browser. No data is collected or tracked by Treeverse directly 77 | when you install it or explore a tree. The extension only communicates with the Treeverse server 78 | if you click the “Create sharable link” button, in which case the current tree 79 | to be sent to a server so that it can be made available to others. Access to the shared link server 80 | may be tracked to prevent abuse.

81 | 82 |

Browser extension installs may be tracked by Google and Mozilla, and the data 83 | requests made to Twitter may be tracked by Twitter. Additionally, when Treeverse runs it loads a 84 | font hosted by Google Fonts. Google may track this download.

85 | 86 |

License

87 | 88 |

Treeverse is distributed under an MIT license. 89 | The code is available on GitHub.

90 | 91 |

Bugs & Contact

92 | 93 | Tweet @paulgb or report on GitHub. 94 | 95 |

Credits

96 | 97 |

Icon created by Eli Schiff.

98 | 99 |

Treeverse would not be possible without the excellent d3.js. 100 | Styling is powered by Semantic UI.

101 | 102 |
103 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /web/view/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Treeverse 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | 4 | module.exports = { 5 | context: path.resolve('src'), 6 | entry: { 7 | '/extension_chrome/resources/script/background': './background/chrome_action.ts', 8 | '/extension_chrome/resources/script/viewer': './viewer/main.ts', 9 | '/extension_chrome/resources/script/content': './content/main.ts', 10 | '/extension_firefox/resources/script/background': './background/firefox_action.ts', 11 | '/extension_firefox/resources/script/viewer': './viewer/main.ts', 12 | '/extension_firefox/resources/script/content': './content/main.ts', 13 | '/public/treeverse': './viewer/web_entry.ts', 14 | }, 15 | output: { 16 | filename: '[name].js' 17 | }, 18 | devtool: "source-map", 19 | resolve: { 20 | extensions: [".ts", ".tsx", ".js", ".json"] 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /.tsx?$/, 26 | loader: 'ts-loader', 27 | exclude: /node_modules/, 28 | } 29 | ] 30 | }, 31 | plugins: [ 32 | new CopyWebpackPlugin([ 33 | // Web site 34 | { 35 | context: '../', 36 | from: 'web', 37 | to: 'public' 38 | }, 39 | { 40 | context: '../', 41 | from: 'extension/common/resources/images', 42 | to: 'public/images' 43 | }, 44 | { 45 | context: '../', 46 | from: 'images', 47 | to: 'public/images' 48 | }, 49 | { 50 | context: '../', 51 | from: 'extension/common/icons', 52 | to: 'public/icons' 53 | }, 54 | { 55 | context: '../', 56 | from: 'extension/common/resources/style.css', 57 | to: 'public/' 58 | }, 59 | // Chrome extension 60 | { 61 | context: '../', 62 | from: 'extension/common', 63 | to: 'extension_chrome' 64 | }, 65 | { 66 | context: '../', 67 | from: 'extension/chrome/manifest.json', 68 | to: 'extension_chrome' 69 | }, 70 | // Firefox extension 71 | { 72 | context: '../', 73 | from: 'extension/common', 74 | to: 'extension_firefox/' 75 | }, 76 | { 77 | context: '../', 78 | from: 'extension/firefox/manifest.json', 79 | to: 'extension_firefox/' 80 | }, 81 | ]), 82 | ], 83 | devServer: { 84 | contentBase: path.join(__dirname, 'dist/public'), 85 | compress: true, 86 | port: 9000, 87 | historyApiFallback: { 88 | index: 'view/index.html' 89 | } 90 | }, 91 | mode: 'development' 92 | } --------------------------------------------------------------------------------