├── .gitignore ├── Licence ├── README.md ├── UI-demo ├── css │ ├── main.css │ ├── main.css.map │ └── twitter-stylesheets.css ├── index.html ├── js │ ├── main.js │ └── main.min.js └── scss │ ├── _variables.scss │ ├── main.scss │ └── twitter-stylesheets.scss ├── firefox ├── .jpmignore ├── data │ ├── createTwitterApp │ │ ├── collectTwitterAppAPICredentials.js │ │ └── fillInAppCreationForm.js │ ├── dev │ │ └── twitter-login.js │ ├── ext │ │ └── react.js │ ├── images │ │ └── Twitter_logo_blue.png │ ├── metrics-integration │ │ ├── TweetMine.ts │ │ ├── additional.css │ │ ├── components │ │ │ ├── DetailList.ts │ │ │ ├── GeneratedEngagement.ts │ │ │ ├── Histogram.ts │ │ │ ├── HumansAreNotMetricsReminder.ts │ │ │ ├── Metrics.ts │ │ │ ├── TimelineComposition.ts │ │ │ ├── TweetList.ts │ │ │ ├── TweetsPerDayEstimate.ts │ │ │ ├── TwitterAssistant.ts │ │ │ ├── TwitterAssistantTopInfo.ts │ │ │ ├── WordMass.ts │ │ │ └── makeTimelineCompositionChildren.ts │ │ ├── getRetweetOriginalTweet.ts │ │ ├── getWhosBeingConversedWith.ts │ │ ├── main.css │ │ ├── main.ts │ │ └── stem.ts │ └── panel │ │ ├── components │ │ ├── AutomatedAppCreation.js │ │ ├── ManualAppCreation.js │ │ ├── TwitterAPICredentialsForm.js │ │ └── TwitterAssistantPanel.js │ │ ├── main.js │ │ └── mainPanel.html ├── defs │ ├── ES6.d.ts │ ├── OAuth.d.ts │ ├── SimpleTwitterObjects.d.ts │ ├── TwitterAPI.d.ts │ ├── jetpack │ │ ├── jetpack-base64.d.ts │ │ ├── jetpack-chrome.d.ts │ │ ├── jetpack-content-worker.d.ts │ │ ├── jetpack-event-core.d.ts │ │ ├── jetpack-net-xhr.d.ts │ │ ├── jetpack-pagemod.d.ts │ │ ├── jetpack-panel.d.ts │ │ ├── jetpack-port.d.ts │ │ ├── jetpack-preferences-service.d.ts │ │ ├── jetpack-promise.d.ts │ │ ├── jetpack-request.d.ts │ │ ├── jetpack-self.d.ts │ │ ├── jetpack-simple-prefs.d.ts │ │ ├── jetpack-system-events.d.ts │ │ ├── jetpack-system.d.ts │ │ ├── jetpack-tabs-utils.d.ts │ │ ├── jetpack-tabs.d.ts │ │ ├── jetpack-timers.d.ts │ │ └── jetpack-ui.d.ts │ ├── natural.d.ts │ └── react-0.11.d.ts ├── doc │ └── main.md ├── lib │ ├── TwitterAPI.ts │ ├── TwitterAPIViaServer.ts │ ├── createTwitterApp.ts │ ├── getAccessToken.ts │ ├── getAddonUserInfoAndFriends.ts │ ├── getReadyForTwitterProfilePages.ts │ ├── getTimelineOverATimePeriod.ts │ ├── guessAddonUserTwitterName.ts │ ├── main.ts │ ├── makeSearchString.ts │ ├── makeSigninPanel.ts │ └── requestToken.ts ├── package.json └── test │ ├── test-TwitterAccountURLRegExp.js │ └── test-retrieveDevTwitterUserCredentials.js ├── integr-draft.html ├── log-width.html ├── logo-alpc.jpg ├── mockup ├── maquette01-theme1-expended.png ├── maquette01-theme1.png ├── maquette01-theme2-expended.png ├── maquette01-theme2.png ├── maquette01-theme3-expended.png ├── maquette01-theme3.png └── maquette02-theme1-expended.png ├── package.json ├── prefs.json ├── prefs.template.json ├── screenshot-firefox.png ├── server ├── index.ts ├── oauthCredentials.json.sample └── whatevs.d.ts └── typings ├── form-data └── form-data.d.ts ├── node └── node.d.ts └── request └── request.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | *.data 2 | *.xpi 3 | *.js.map 4 | firefox/lib/*.js 5 | firefox/data/metrics-integration/*.js 6 | firefox/data/metrics-integration/*/*.js 7 | firefox/data/metrics-integration/components/*.js 8 | twitter-assistant-content.js 9 | server/*.js 10 | server/*.json 11 | 12 | node_modules 13 | 14 | npm-debug.log 15 | 16 | notes.md -------------------------------------------------------------------------------- /Licence: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 David Bruant, Béatrice Lajous 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Twitter Assistant 2 | ============== 3 | 4 | Twitter Assistant is an addon for Firefox aiming at improving the Twitter web experience. 5 | Among other things, it provides insights about Twitter users when you're visiting their profile page. 6 | 7 | ![Firefox screenshot of Twitter Assistant](screenshot-firefox.png) 8 | 9 | 10 | ## Features 11 | 12 | * Seamless integration to the Twitter UI 13 | * A couple of metrics (% of tweets with links over the last 200 tweets for instance) 14 | * No strings attached 15 | 16 | 17 | ## How it works 18 | 19 | * Install the addon 20 | * Create a Twitter app 21 | * Feed the addon with the Twitter app credentials (API key and API secret) 22 | * That's it! ... well... go to someone's Twitter profile page to see insights about how this person uses Twitter :-) 23 | 24 | 25 | ## No strings attached 26 | 27 | There are lots of services around the Twitter API. The vast majorty of them are controlled by third-parties. Too often, this dependency is unnecessary. Possible consequences of this dependency are that the third-party can suddenly disappear (like a company going out of business or changing focus) or change its conditions anytime (like demand money or rise its prices) and the service can become unavailable or is cannot be relied on in the long run. 28 | 29 | Twitter Assistant, on the other hand, is specifically designed to prevent any such third-party dependency. With Twitter Assistant, you create a Twitter app (which Twitter initially intended for developers, but which anybody can do) and the addon only takes advantage of what the API gives access to. 30 | By design, there is no server-side component, no third-party dependency. Once the addon is installed and fed with valid credentials, it should keep working pretty much forever (modulo some risks, see below). The source code is MIT licenced for the very purpose of being easily forkable if become necessary. 31 | 32 | 33 | ## Limitations 34 | 35 | Twitter Assistant takes advantage of a Twitter app credentials you create. As such, it is subject to the API rate limits. Note that these should be rarely hit. Worst case scenario, the addon stops working for 15 minutes (time window before the API rate limit resets). 36 | 37 | 38 | ## Risks 39 | 40 | * The addon integrate with the Twitter web UI. As such, if the UI comes to change, the addon may break. This is mitigated by displaying the Twitter Assistant on the top-right corner if no better place is found. 41 | 42 | * The Twitter API authentication schema may change over time. A developer will need to update the code. All the code interacting with the API is fairly isolated, so this change should be fairly easy. 43 | 44 | * The Firefox addon API may change over time. A developer will need to update the code. No stupid risk has been taken while writing the code, it's all standard HTML, CSS, JS, so as long as the addon API changes are well documented, the evolution should be fairly easy. 45 | 46 | 47 | # Contributing 48 | 49 | * Install the [addon SDK](https://developer.mozilla.org/en-US/Add-ons/SDK/Tutorials/Installation) including [jpm](https://developer.mozilla.org/en-US/Add-ons/SDK/Tools/jpm). 50 | 51 | * Create a Twitter app 52 | * Create a `prefs.json` file based on the template and fill in the key and secret with your app's 53 | 54 | 55 | * Install [Node.js](http://nodejs.org/) 56 | 57 | ```bash 58 | git clone git@github.com:DavidBruant/Twitter-Assistant.git 59 | cd Twitter-Assistant 60 | npm install 61 | 62 | npm run compile 63 | npm run build 64 | npm run run 65 | ``` 66 | 67 | compile is to compile everything in TypeScript 68 | build is to browserify content-side 69 | 70 | `build` (using `tsify`) has this problem that if there are compilation errors, it'll only show the first one. So run `compile` before `build` 71 | 72 | 73 | ## Daily routine 74 | 75 | ``` 76 | npm run watch 77 | npm run compile 78 | ``` 79 | 80 | ### server 81 | 82 | Needs Node.js v5+ 83 | 84 | ```` 85 | npm i forever -g 86 | npm run compile 87 | forever start server/index.js 88 | ```` 89 | 90 | 91 | # Acknowledgements 92 | 93 | Thanks to [@clochix](https://twitter.com/clochix), [@davidbgk](https://twitter.com/davidbgk/), [@oncletom](https://twitter.com/oncletom) and a couple of others for the inspiration to make this addon an emancipating purely client-side piece of software. 94 | 95 | Thanks to [@glovesmore](https://twitter.com/glovesmore) for learning HTML/CSS/JS :-) 96 | 97 | Thanks to [@Twikito](https://twitter.com/Twikito) for the amazing visuals, UI and UX work I wouldn't have been able to do. 98 | 99 | Thanks to [@pascalc](https://twitter.com/pascalchevrel), [@guillaumemasson](https://twitter.com/guillaumemasson), **Yannick Gaultier** and all of those who contributed ideas, thoughts, shared feedback about this addon 100 | 101 | Thanks to [@amarlakel](https://twitter.com/amarlakel) for making me understand that there is more to social networks than what we do with them. 102 | 103 | Thanks to the [Aquitaine-Limousin-Poitou-Charentes region](http://laregion-alpc.fr/) for [awarding a 10,000€ grant](http://numerique.aquitaine.fr/Laureats-de-l-appel-a-projets-2014) to support the project at the occasion of the "appel à projet « Usages innovants des données numériques : partage, exploitation et valorisation »". 104 | 105 | ![ALPC logo](logo-alpc.jpg). 106 | 107 | This grant is being shared among David Bruant (5,000€) and Matthieu Bué (5,000€) to get the project to a robust, easy-to-use, well-designed and featurefull 1.0 version. 108 | 109 | 110 | # License 111 | 112 | MIT 113 | -------------------------------------------------------------------------------- /UI-demo/css/main.css: -------------------------------------------------------------------------------- 1 | /* ------------------------------------------------------------------ 2 | Imports 3 | ------------------------------------------------------------------ */ 4 | /* ------------------------------------------------------------------ 5 | Variables 6 | ------------------------------------------------------------------ */ 7 | /* ------------------------------------------------------------------ 8 | Twitter Assistant 9 | ------------------------------------------------------------------ */ 10 | .TA-main-container * { 11 | box-sizing: border-box; 12 | } 13 | .TA-main-container .TA-trigger { 14 | cursor: pointer; 15 | } 16 | .TA-main-container .TA-section-title { 17 | margin: 1.5em 0 .5em; 18 | font-size: 13px; 19 | color: #292f33; 20 | font-weight: bold; 21 | } 22 | .TA-main-container .TA-graduation { 23 | font-weight: bold; 24 | cursor: ew-resize; 25 | } 26 | .TA-main-container .TA-histogram { 27 | display: flex; 28 | height: 100px; 29 | flex-flow: row nowrap; 30 | align-items: stretch; 31 | } 32 | .TA-main-container .TA-histogram .TA-bar { 33 | display: flex; 34 | height: 100%; 35 | overflow: visible; 36 | flex: 1; 37 | flex-flow: column nowrap; 38 | justify-content: flex-end; 39 | align-items: stretch; 40 | } 41 | .TA-main-container .TA-histogram .TA-bar[data-value] { 42 | position: relative; 43 | z-index: 0; 44 | } 45 | .TA-main-container .TA-histogram .TA-bar[data-value]::after { 46 | position: absolute; 47 | top: calc(100% + .3em); 48 | left: 50%; 49 | padding: .1em .2em; 50 | transform: translateX(-50%); 51 | opacity: 0; 52 | content: attr(data-value); 53 | } 54 | .TA-main-container .TA-histogram .TA-bar[data-value]:hover { 55 | z-index: 1; 56 | box-shadow: 0 0 0 1px #292f33; 57 | } 58 | .TA-main-container .TA-histogram .TA-bar[data-value]:hover::after { 59 | opacity: 1; 60 | } 61 | .TA-main-container .TA-activity .TA-bar[data-value]::after { 62 | background: #e5eaeb; 63 | font-size: 10px; 64 | color: #292f33; 65 | } 66 | .TA-main-container .TA-tweets { 67 | background: #e5eaeb; 68 | } 69 | .TA-main-container .TA-links { 70 | background: #ffb463; 71 | } 72 | .TA-main-container .TA-medias { 73 | background: #ffed56; 74 | } 75 | .TA-main-container .TA-replies { 76 | background: #6cefda; 77 | } 78 | .TA-main-container .TA-retweets { 79 | background: #58a5ce; 80 | } 81 | .TA-main-container .TA-period { 82 | display: flex; 83 | flex-flow: row nowrap; 84 | align-items: stretch; 85 | } 86 | .TA-main-container .TA-period-from, 87 | .TA-main-container .TA-period-to { 88 | flex: 1; 89 | font-size: 13px; 90 | color: #8899a6; 91 | } 92 | .TA-main-container .TA-period-from { 93 | text-align: left; 94 | } 95 | .TA-main-container .TA-period-to { 96 | text-align: right; 97 | } 98 | .TA-main-container .TA-composition { 99 | display: flex; 100 | width: 100%; 101 | height: 20px; 102 | flex-flow: row nowrap; 103 | align-items: stretch; 104 | } 105 | .TA-main-container .TA-composition div { 106 | position: relative; 107 | display: flex; 108 | overflow: hidden; 109 | } 110 | .TA-main-container .TA-composition div::before { 111 | margin: auto; 112 | color: #ffffff; 113 | } 114 | .TA-main-container .TA-composition .TA-tweets::before { 115 | content: "\f029"; 116 | } 117 | .TA-main-container .TA-composition .TA-links::before { 118 | content: "\f098"; 119 | } 120 | .TA-main-container .TA-composition .TA-medias::before { 121 | content: "\f027"; 122 | } 123 | .TA-main-container .TA-composition .TA-replies::before { 124 | content: "\f151"; 125 | } 126 | .TA-main-container .TA-composition .TA-retweets::before { 127 | content: "\f152"; 128 | } 129 | .TA-main-container .TA-composition-details { 130 | position: relative; 131 | max-height: 0; 132 | margin: 0 -15px; 133 | overflow: hidden; 134 | opacity: 0; 135 | transition: all .3s ease-in-out; 136 | } 137 | .TA-main-container .TA-composition-details.TA-active { 138 | max-height: 530px; 139 | padding-top: 1em; 140 | opacity: 1; 141 | } 142 | .TA-main-container .TA-composition-details.TA-active .TA-composition-details-arrow { 143 | top: 1em; 144 | } 145 | .TA-main-container .TA-composition-details-arrow { 146 | position: absolute; 147 | top: 0; 148 | left: 15px; 149 | width: 10px; 150 | height: 10px; 151 | border: solid #d1dce3; 152 | border-width: 1px 0 0 1px; 153 | background: #ffffff; 154 | transform: rotate(45deg) translate(-50%, -50%); 155 | transform-origin: left top; 156 | content: ""; 157 | transition: all .3s ease-in-out; 158 | } 159 | .TA-main-container .TA-composition-details-inner { 160 | padding: 5px 15px; 161 | margin: 0; 162 | border: solid #e1e8ed; 163 | border-width: 1px 0; 164 | list-style: none; 165 | } 166 | .TA-main-container .TA-account { 167 | position: relative; 168 | min-height: 35px; 169 | margin: 10px 0; 170 | } 171 | .TA-main-container .TA-account .TA-account-count { 172 | position: absolute; 173 | top: 0; 174 | bottom: 0; 175 | right: 0; 176 | z-index: 0; 177 | background: #e5eaeb; 178 | } 179 | .TA-main-container .TA-account .TA-account-count::before { 180 | position: absolute; 181 | bottom: 0; 182 | right: 2px; 183 | font-size: 14px; 184 | color: #8899a6; 185 | content: attr(data-count); 186 | } 187 | .TA-main-container .TA-account .TA-account-inner { 188 | position: relative; 189 | z-index: 1; 190 | } 191 | .TA-main-container .TA-account .TA-account-content { 192 | margin-left: 45px; 193 | } 194 | .TA-main-container .TA-account .TA-avatar { 195 | width: 35px; 196 | height: 35px; 197 | } 198 | .TA-main-container .TA-account .TA-fullname, 199 | .TA-main-container .TA-account .TA-username { 200 | display: block; 201 | overflow: hidden; 202 | white-space: nowrap; 203 | text-overflow: ellipsis; 204 | } 205 | .TA-main-container .TA-account:nth-child(n + 5) { 206 | display: inline-block; 207 | min-height: initial; 208 | margin: 5px 2px 5px 0; 209 | color: #8899a6; 210 | text-decoration: none; 211 | vertical-align: top; 212 | } 213 | .TA-main-container .TA-account:nth-child(n + 5):hover .TA-account-count, .TA-main-container .TA-account:nth-child(n + 5):focus .TA-account-count { 214 | text-decoration: underline; 215 | } 216 | .TA-main-container .TA-account:nth-child(n + 5) .TA-account-count { 217 | top: auto; 218 | left: 0; 219 | width: auto !important; 220 | overflow: hidden; 221 | background: none; 222 | line-height: 1; 223 | text-align: right; 224 | } 225 | .TA-main-container .TA-account:nth-child(n + 5) .TA-account-count::before { 226 | position: static; 227 | } 228 | .TA-main-container .TA-account:nth-child(n + 5) .TA-account-content { 229 | margin: 0; 230 | } 231 | .TA-main-container .TA-account:nth-child(n + 5) .TA-account-group { 232 | padding-right: 18px; 233 | } 234 | .TA-main-container .TA-account:nth-child(n + 5) .TA-avatar { 235 | position: static; 236 | display: block; 237 | width: 20px; 238 | height: 20px; 239 | } 240 | .TA-main-container .TA-account:nth-child(n + 5) .TA-account-group-inner { 241 | display: none; 242 | } 243 | .TA-main-container .TA-account:last-child { 244 | margin-bottom: 10px; 245 | } 246 | .TA-main-container .TA-engagement { 247 | display: flex; 248 | width: 100%; 249 | height: 40px; 250 | flex-flow: column nowrap; 251 | align-items: stretch; 252 | background: #e5eaeb; 253 | } 254 | .TA-main-container .TA-engagement .TA-rt, 255 | .TA-main-container .TA-engagement .TA-fav { 256 | flex: 1; 257 | display: flex; 258 | overflow: hidden; 259 | background: #52e08b; 260 | } 261 | .TA-main-container .TA-engagement .TA-rt::before, 262 | .TA-main-container .TA-engagement .TA-fav::before { 263 | margin: auto auto auto 2px; 264 | color: #ffffff; 265 | } 266 | .TA-main-container .TA-engagement .TA-rt::before { 267 | content: "\f152"; 268 | } 269 | .TA-main-container .TA-engagement .TA-fav::before { 270 | content: "\f147"; 271 | } 272 | .TA-main-container .TA-language { 273 | display: flex; 274 | width: 100%; 275 | height: 20px; 276 | flex-flow: row nowrap; 277 | align-items: stretch; 278 | background: #e5eaeb; 279 | } 280 | .TA-main-container .TA-language div { 281 | padding-left: 2px; 282 | background: #52e08b; 283 | line-height: 20px; 284 | color: #ffffff; 285 | font-weight: bold; 286 | text-transform: uppercase; 287 | } 288 | .TA-main-container .TA-language div:nth-child(even) { 289 | background: #1fad58; 290 | } 291 | .TA-main-container .TA-reminder { 292 | margin: 1em -15px 0; 293 | padding: 15px 15px 0; 294 | border-top: 1px solid #e1e8ed; 295 | color: #8899a6; 296 | } 297 | 298 | /*# sourceMappingURL=main.css.map */ 299 | -------------------------------------------------------------------------------- /UI-demo/css/main.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": ";;;;;;;;;AAWC,oBAAI;EACH,UAAU,EAAE,UAAU;;AAEvB,8BAAwB;EACvB,MAAM,EAAE,OAAO;;AAEhB,oCAA8B;EAC7B,MAAM,EAAE,YAAY;EACpB,SAAS,ECPc,IAAI;EDOI,KAAK,ECZb,OAAO;EDYwB,WAAW,EAAE,IAAI;;AAExE,iCAA2B;EAC1B,WAAW,EAAE,IAAI;EACjB,MAAM,EAAE,SAAS;;AAElB,gCAA0B;EACzB,OAAO,EAAE,IAAI;EAAE,MAAM,ECEE,KAAK;EDD5B,SAAS,EAAE,UAAU;EAAE,WAAW,EAAE,OAAO;;AAC3C,wCAAoB;EACnB,OAAO,EAAE,IAAI;EAAE,MAAM,EAAE,IAAI;EAAE,QAAQ,EAAE,OAAO;EAC9C,IAAI,EAAE,CAAC;EAAE,SAAS,EAAE,aAAa;EAAE,eAAe,EAAE,QAAQ;EAAE,WAAW,EAAE,OAAO;;AAClF,oDAAc;EACb,QAAQ,EAAE,QAAQ;EAAE,OAAO,EAAE,CAAC;;AAE/B,2DAAqB;EACpB,QAAQ,EAAE,QAAQ;EAAE,GAAG,EAAE,iBAAiB;EAAE,IAAI,EAAE,GAAG;EAAE,OAAO,EAAE,SAAS;EACzE,SAAS,EAAE,gBAAgB;EAAE,OAAO,EAAE,CAAC;EACvC,OAAO,EAAE,gBAAgB;;AAE1B,0DAAoB;EACnB,OAAO,EAAE,CAAC;EACV,UAAU,EAAE,iBAAwB;;AACpC,iEAAS;EACR,OAAO,EAAE,CAAC;;AAOZ,0DAAqB;EACpB,UAAU,ECzBW,OAAO;ED0B5B,SAAS,EAAE,IAAI;EAAE,KAAK,EC7CD,OAAO;;ADiD/B,6BAAyB;EAAE,UAAU,ECjCb,OAAO;;ADkC/B,4BAAyB;EAAE,UAAU,ECnCb,OAAO;;ADoC/B,6BAAyB;EAAE,UAAU,ECrCb,OAAO;;ADsC/B,8BAAyB;EAAE,UAAU,ECvCb,OAAO;;ADwC/B,+BAAyB;EAAE,UAAU,ECzCb,OAAO;;AD2C/B,6BAAuB;EACtB,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,UAAU;EAAE,WAAW,EAAE,OAAO;;AAE5C;gCAC0B;EACzB,IAAI,EAAE,CAAC;EACP,SAAS,ECzDc,IAAI;EDyDI,KAAK,EC7Db,OAAO;;AD+D/B,kCAA4B;EAAE,UAAU,EAAE,IAAI;;AAC9C,gCAA4B;EAAE,UAAU,EAAE,KAAK;;AAE/C,kCAA4B;EAC3B,OAAO,EAAE,IAAI;EAAE,KAAK,EAAE,IAAI;EAAE,MAAM,EC9CX,IAAI;ED+C3B,SAAS,EAAE,UAAU;EAAE,WAAW,EAAE,OAAO;;AAC3C,sCAAI;EACH,QAAQ,EAAE,QAAQ;EAAE,OAAO,EAAE,IAAI;EAAE,QAAQ,EAAE,MAAM;;AACnD,8CAAU;EACT,MAAM,EAAE,IAAI;EACZ,KAAK,EChEgB,OAAO;;ADmE9B,qDAAiC;EAAE,OAAO,EAAE,OAAO;;AACnD,oDAAiC;EAAE,OAAO,EAAE,OAAO;;AACnD,qDAAiC;EAAE,OAAO,EAAE,OAAO;;AACnD,sDAAiC;EAAE,OAAO,EAAE,OAAO;;AACnD,uDAAiC;EAAE,OAAO,EAAE,OAAO;;AAEpD,0CAAoC;EACnC,QAAQ,EAAE,QAAQ;EAAE,UAAU,EAAE,CAAC;EAAE,MAAM,EAAE,OAAuB;EAAE,QAAQ,EAAE,MAAM;EACpF,OAAO,EAAE,CAAC;EAAE,UAAU,EAAE,mBAAmB;;AAC3C,oDAAwB;EACvB,UAAU,EAAE,KAAK;EAAE,WAAW,EAAE,GAAG;EACnC,OAAO,EAAE,CAAC;;AACV,kFAA0C;EACzC,GAAG,EAAE,GAAG;;AAIX,gDAA0C;EACzC,QAAQ,EAAE,QAAQ;EAAE,GAAG,EAAE,CAAC;EAAE,IAAI,EAAE,IAAI;EAAE,KAAK,EAAE,IAAI;EAAE,MAAM,EAAE,IAAI;EACjE,MAAM,EAAE,aAA+B;EAAE,YAAY,EAAE,WAAW;EAClE,UAAU,EC9Fa,OAAO;ED+F9B,SAAS,EAAE,mCAAkC;EAAE,gBAAgB,EAAE,QAAQ;EACzE,OAAO,EAAE,EAAE;EAAE,UAAU,EAAE,mBAAmB;;AAE7C,gDAA0C;EACzC,OAAO,EAAE,QAAkB;EAAE,MAAM,EAAE,CAAC;EACtC,MAAM,EAAE,aAAmB;EAAE,YAAY,EAAE,KAAK;EAChD,UAAU,EAAE,IAAI;;AAEjB,8BAAwB;EACvB,QAAQ,EAAE,QAAQ;EAAE,UAAU,EAAE,IAAI;EAAE,MAAM,EAAE,MAAM;;AACpD,gDAA8B;EAC7B,QAAQ,EAAE,QAAQ;EAAE,GAAG,EAAC,CAAC;EAAE,MAAM,EAAE,CAAC;EAAE,KAAK,EAAE,CAAC;EAAE,OAAO,EAAE,CAAC;EAC1D,UAAU,EC3FY,OAAO;;AD4F7B,wDAAU;EACT,QAAQ,EAAE,QAAQ;EAAE,MAAM,EAAE,CAAC;EAAE,KAAK,EAAE,GAAG;EACzC,SAAS,EAAE,IAAI;EAAE,KAAK,EChHD,OAAO;EDiH5B,OAAO,EAAE,gBAAgB;;AAG3B,gDAA8B;EAC7B,QAAQ,EAAE,QAAQ;EAAE,OAAO,EAAE,CAAC;;AAE/B,kDAAgC;EAC/B,WAAW,EAAE,IAAI;;AAElB,yCAAuB;EACtB,KAAK,EAAE,IAAI;EAAE,MAAM,EAAE,IAAI;;AAE1B;2CACyB;EACxB,OAAO,EAAE,KAAK;EAAE,QAAQ,EAAE,MAAM;EAChC,WAAW,EAAE,MAAM;EAAE,aAAa,EAAE,QAAQ;;AAE7C,+CAAqC;EACpC,OAAO,EAAE,YAAY;EAAE,UAAU,EAAE,OAAO;EAAE,MAAM,EAAE,aAAa;EACjE,KAAK,ECpIiB,OAAO;EDoIN,eAAe,EAAE,IAAI;EAC5C,cAAc,EAAE,GAAG;;AAGlB,gJAA8B;EAC7B,eAAe,EAAE,SAAS;;AAG5B,iEAA8B;EAC7B,GAAG,EAAE,IAAI;EAAE,IAAI,EAAE,CAAC;EAAE,KAAK,EAAE,eAAe;EAAE,QAAQ,EAAE,MAAM;EAC5D,UAAU,EAAE,IAAI;EAChB,WAAW,EAAE,CAAC;EAAE,UAAU,EAAE,KAAK;;AACjC,yEAAU;EACT,QAAQ,EAAE,MAAM;;AAGlB,mEAAgC;EAC/B,MAAM,EAAE,CAAC;;AAEV,iEAA8B;EAC7B,aAAa,EAAE,IAAI;;AAEpB,0DAAuB;EACtB,QAAQ,EAAE,MAAM;EAAE,OAAO,EAAE,KAAK;EAAE,KAAK,EAAE,IAAI;EAAE,MAAM,EAAE,IAAI;;AAE5D,uEAAoC;EACnC,OAAO,EAAE,IAAI;;AAGf,yCAAa;EACZ,aAAa,EAAE,IAAI;;AAGrB,iCAA2B;EAC1B,OAAO,EAAE,IAAI;EAAE,KAAK,EAAE,IAAI;EAAE,MAAM,EAAE,IAAyB;EAC7D,SAAS,EAAE,aAAa;EAAE,WAAW,EAAE,OAAO;EAC9C,UAAU,ECtJa,OAAO;;ADuJ9B;yCACoB;EACnB,IAAI,EAAE,CAAC;EAAE,OAAO,EAAE,IAAI;EAAE,QAAQ,EAAE,MAAM;EACxC,UAAU,EC3JY,OAAO;;AD4J7B;iDAAU;EACT,MAAM,EAAE,kBAAkB;EAC1B,KAAK,ECtKgB,OAAO;;ADyK9B,gDAA4B;EAAE,OAAO,EAAE,OAAO;;AAC9C,iDAA4B;EAAE,OAAO,EAAE,OAAO;;AAE/C,+BAAyB;EACxB,OAAO,EAAE,IAAI;EAAE,KAAK,EAAE,IAAI;EAAE,MAAM,ECjKX,IAAI;EDkK3B,SAAS,EAAE,UAAU;EAAE,WAAW,EAAE,OAAO;EAC3C,UAAU,ECtKa,OAAO;;ADuK9B,mCAAI;EACH,YAAY,EAAE,GAAG;EACjB,UAAU,EC1KY,OAAO;ED2K7B,WAAW,ECvKW,IAAI;EDuKQ,KAAK,ECnLjB,OAAO;EDmL0B,WAAW,EAAE,IAAI;EAAE,cAAc,EAAE,SAAS;;AACnG,mDAAkB;EACjB,UAAU,EAAE,OAAqB;;AAIpC,+BAAyB;EACxB,MAAM,EAAE,WAA2B;EAAE,OAAO,EAAE,WAA+B;EAC7E,UAAU,EAAE,iBAAuB;EACnC,KAAK,ECrMkB,OAAO", 4 | "sources": ["file:///D:/Clouds/Google%20Drive/Taf/Twitter%20assistant/Code/scss/main.scss","file:///D:/Clouds/Google%20Drive/Taf/Twitter%20assistant/Code/scss/_variables.scss"], 5 | "names": [], 6 | "file": "main.css" 7 | } 8 | -------------------------------------------------------------------------------- /UI-demo/css/twitter-stylesheets.css: -------------------------------------------------------------------------------- 1 | body{font-family:Arial,sans-serif}.ProfilePage{background-image:none !important;background-color:#f5f8fa !important;line-height:1.375;min-width:936px}.WhoToFollow{display:none;font-size:12px}.ProfileSidebar .ProfileTweetbox,.ProfileSidebar .SignupCallOut,.ProfileSidebar .ProfileLifelineInfo,.ProfileSidebar .ProfileUserList,.ProfileSidebar .PhotoRail,.ProfileSidebar .WhoToFollow,.ProfileSidebar .EventFollowAll,.ProfileSidebar .Trends,.ProfileSidebar .RelatedStream,.ProfileSidebar .Footer{margin-top:20px}.ProfileSidebar .SignupCallOut,.ProfileSidebar .ProfileLifelineInfo,.ProfileSidebar .WhoToFollow,.ProfileSidebar .RelatedStream,.ProfileSidebar .EventFollowAll,.ProfileSidebar .Trends{background-color:#fff;border:1px solid #e1e8ed;border-radius:5px;margin-top:10px;padding:15px}.WhoToFollow.is-visible{display:block}.WhoToFollow-header{margin-bottom:15px}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,figure,p,pre{margin:0}.WhoToFollow-title{color:#66757f;display:inline-block;font-size:18px;font-weight:300;line-height:1}a{background:transparent}a{color:#2b7bb9;text-decoration:none}a,a:hover,a:focus,a:active{color:#00D084}a:hover,a:focus,a:active{text-decoration:underline}a:active,a:hover{outline:0}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button{border:0}button{background:transparent;border:0;padding:0}.btn-link{padding:0;background-color:transparent;background-image:none;border:0;cursor:pointer;border-radius:0;box-shadow:none}.btn-link:hover,.btn-link:focus{outline:0;text-decoration:underline;box-shadow:none}a,.btn-link,.btn-link:focus,.icon-btn,.pretty-link b,.pretty-link:hover s,.pretty-link:hover b,.pretty-link:focus s,.pretty-link:focus b,.metadata a:hover,.metadata a:focus,.account-group:hover .fullname,.account-group:focus .fullname,.account-summary:focus .fullname,.message .message-text a,.stats a strong,.plain-btn:hover,.plain-btn:focus,.dropdown.open .user-dropdown.plain-btn,.open>.plain-btn,#global-actions .new:before,.module .list-link:hover,.module .list-link:focus,.UserCompletion-step:hover,.stats a:hover,.stats a:hover strong,.stats a:focus,.stats a:focus strong,.profile-modal-header .fullname a:hover,.profile-modal-header .username a:hover,.profile-modal-header .fullname a:focus,.profile-modal-header .username a:focus,.story-article:hover .metadata,.story-article .metadata a:focus,.find-friends-sources li:hover .source,.stream-item a:hover .fullname,.stream-item a:focus .fullname,.stream-item .view-all-supplements:hover,.stream-item .view-all-supplements:focus,.tweet .time a:hover,.tweet .time a:focus,.tweet .details.with-icn b,.tweet .details.with-icn .Icon,.tweet .tweet-geo-text a:hover,.stream-item:hover .original-tweet .details b,.stream-item .original-tweet.focus .details b,.stream-item.open .original-tweet .details b,.simple-tweet:hover .details b,.simple-tweet.focus .details b,.simple-tweet.open .details b,.simple-tweet:hover .details .simple-details-link,.simple-tweet.focus .details .simple-details-link,.client-and-actions a:hover,.client-and-actions a:focus,.dismiss-promoted:hover b,.tweet .context .pretty-link:hover s,.tweet .context .pretty-link:hover b,.tweet .context .pretty-link:focus s,.tweet .context .pretty-link:focus b,.list .username a:hover,.list .username a:focus,.list-membership-container .create-a-list,.list-membership-container .create-a-list:hover,.story-header:hover .view-tweets,.card .list-details a:hover,.card .list-details a:focus,.card .card-body:hover .attribution,.card .card-body .attribution:focus,.events-card .card-body:hover .attribution,.events-card .card-body .attribution:focus,.new-tweets-bar,.onebox .soccer ul.ticker a:hover,.onebox .soccer ul.ticker a:focus,.discover-item-actions a,.remove-background-btn,.stream-item-activity-me .latest-tweet .tweet-row a:hover,.stream-item-activity-me .latest-tweet .tweet-row a:focus,.stream-item-activity-me .latest-tweet .tweet-row a:hover b,.stream-item-activity-me .latest-tweet .tweet-row a:focus b{color:#00D084}.account-summary{position:relative;display:block;min-height:48px}.account-summary .content{margin-left:58px;margin-right:20px}.account-summary .account-group,.account-summary .account-action{display:block;line-height:16px}.account-group:hover,.account-summary:focus .account-group{text-decoration:none}.account-group:hover .fullname,.account-group:focus .fullname,.account-summary:focus .fullname{text-decoration:underline}img{border:0}.avatar{width:48px;height:48px;border-radius:5px;-moz-force-broken-image-icon:1}.account-summary .avatar{position:absolute;top:0;left:0}.account-summary .account-group-inner,.account-summary .pretty-link,.account-summary .account-action,.account-summary .location{color:#8899a6}.account-summary .account-group-inner,.account-summary .metadata{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.account-summary .account-group-inner{display:block;width:100%}.fullname{font-weight:bold;color:#292f33}.WhoToFollow-users .fullname{color:#292f33;font-size:13px}.account-summary .account-group-inner,.account-summary .metadata{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.username,.time,.time a,.metadata,.metadata button.btn-link,.metadata a{font-size:13px;color:#8899a6}.username{direction:ltr;unicode-bidi:embed}.WhoToFollow-users .username{color:#8899a6}s{text-decoration:none}.username s,.account-group-inner s{color:#b1bbc3}.WhoToFollow{width:290px;margin:10px auto;box-sizing:border-box} -------------------------------------------------------------------------------- /UI-demo/js/main.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | for(i=0 ; i<150 ; i++){ 3 | $(".TA-activity").append( 4 | $("
") 5 | .addClass("TA-bar") 6 | .attr("data-value",Math.round(Math.random() * 100)) 7 | .append($("
") 8 | .addClass("TA-tweets") 9 | .css("height",Math.random() * 20 + 10 + "%") 10 | ) 11 | .append($("
") 12 | .addClass("TA-links") 13 | .css("height",Math.random() * 20 + "%") 14 | ) 15 | .append($("
") 16 | .addClass("TA-medias") 17 | .css("height",Math.random() * 10 + "%") 18 | ) 19 | .append($("
") 20 | .addClass("TA-replies") 21 | .css("height",Math.random() * 20 + "%") 22 | ) 23 | .append($("
") 24 | .addClass("TA-retweets") 25 | .css("height",Math.random() * 20 + "%") 26 | ) 27 | ); 28 | } 29 | $("#toggle").click(function(event){ 30 | $(".TA-composition-details").toggleClass("TA-active"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /UI-demo/js/main.min.js: -------------------------------------------------------------------------------- 1 | $(function(){for(i=0;150>i;i++)$(".TA-activity").append($("
").addClass("TA-bar").attr("data-value",Math.round(100*Math.random())).append($("
").addClass("TA-tweets").css("height",20*Math.random()+10+"%")).append($("
").addClass("TA-links").css("height",20*Math.random()+"%")).append($("
").addClass("TA-medias").css("height",10*Math.random()+"%")).append($("
").addClass("TA-replies").css("height",20*Math.random()+"%")).append($("
").addClass("TA-retweets").css("height",20*Math.random()+"%")));$("#toggle").click(function(){$(".TA-composition-details").toggleClass("TA-active")})}); 2 | -------------------------------------------------------------------------------- /UI-demo/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | /* ------------------------------------------------------------------ 2 | Variables 3 | ------------------------------------------------------------------ */ 4 | 5 | $plugin-name : "TA-"; 6 | 7 | // From Twitter UI 8 | $default-color : #292f33; 9 | $lighter-color : #8899a6; 10 | $border-color : #e1e8ed; 11 | $frame-bg-color : #ffffff; 12 | 13 | $default-font-size : 13px; 14 | $frame-padding : 15px; 15 | 16 | 17 | // Plugin UI assignment 18 | $icons-color : #ffffff; 19 | 20 | $retweets-color : #58a5ce; 21 | $replies-color : #6cefda; 22 | $medias-color : #ffed56; 23 | $links-color : #ffb463; 24 | $tweets-color : #e5eaeb; 25 | 26 | $fg-color : #52e08b; 27 | $bg-color : #e5eaeb; 28 | 29 | $histogram-height : 100px; 30 | $composition-height : 20px; 31 | $max-big-account : 5; 32 | -------------------------------------------------------------------------------- /UI-demo/scss/main.scss: -------------------------------------------------------------------------------- 1 | /* ------------------------------------------------------------------ 2 | Imports 3 | ------------------------------------------------------------------ */ 4 | 5 | @import "variables"; 6 | 7 | /* ------------------------------------------------------------------ 8 | Twitter Assistant 9 | ------------------------------------------------------------------ */ 10 | 11 | .#{$plugin-name}main-container { 12 | & * { 13 | box-sizing: border-box; 14 | } 15 | .#{$plugin-name}trigger { 16 | cursor: pointer; 17 | } 18 | .#{$plugin-name}section-title { 19 | margin: 1.5em 0 .5em; 20 | font-size: $default-font-size; color: $default-color; font-weight: bold; 21 | } 22 | .#{$plugin-name}graduation { 23 | font-weight: bold; 24 | cursor: ew-resize; 25 | } 26 | .#{$plugin-name}histogram { // Generic histogram 27 | display: flex; height: $histogram-height; 28 | flex-flow: row nowrap; align-items: stretch; 29 | .#{$plugin-name}bar { 30 | display: flex; height: 100%; overflow: visible; 31 | flex: 1; flex-flow: column nowrap; justify-content: flex-end; align-items: stretch; 32 | &[data-value] { 33 | position: relative; z-index: 0; 34 | } 35 | &[data-value]::after { 36 | position: absolute; top: calc(100% + .3em); left: 50%; padding: .1em .2em; 37 | transform: translateX(-50%); opacity: 0; 38 | content: attr(data-value); 39 | } 40 | &[data-value]:hover { 41 | z-index: 1; 42 | box-shadow: 0 0 0 1px $default-color; 43 | &::after { 44 | opacity: 1; 45 | } 46 | } 47 | } 48 | } 49 | .#{$plugin-name}activity { 50 | .#{$plugin-name}bar { 51 | &[data-value]::after { 52 | background: $bg-color; 53 | font-size: 10px; color: $default-color; 54 | } 55 | } 56 | } 57 | .#{$plugin-name}tweets { background: $tweets-color; } 58 | .#{$plugin-name}links { background: $links-color; } 59 | .#{$plugin-name}medias { background: $medias-color; } 60 | .#{$plugin-name}replies { background: $replies-color; } 61 | .#{$plugin-name}retweets { background: $retweets-color; } 62 | 63 | .#{$plugin-name}period { 64 | display: flex; 65 | flex-flow: row nowrap; align-items: stretch; 66 | } 67 | .#{$plugin-name}period-from, 68 | .#{$plugin-name}period-to { 69 | flex: 1; 70 | font-size: $default-font-size; color: $lighter-color; 71 | } 72 | .#{$plugin-name}period-from { text-align: left; } 73 | .#{$plugin-name}period-to { text-align: right; } 74 | 75 | .#{$plugin-name}composition { 76 | display: flex; width: 100%; height: $composition-height; 77 | flex-flow: row nowrap; align-items: stretch; 78 | div { 79 | position: relative; display: flex; overflow: hidden; 80 | &::before { 81 | margin: auto; 82 | color: $icons-color; 83 | } 84 | } 85 | .#{$plugin-name}tweets::before { content: "\f029"; } 86 | .#{$plugin-name}links::before { content: "\f098"; } 87 | .#{$plugin-name}medias::before { content: "\f027"; } 88 | .#{$plugin-name}replies::before { content: "\f151"; } 89 | .#{$plugin-name}retweets::before { content: "\f152"; } 90 | } 91 | .#{$plugin-name}composition-details { 92 | position: relative; max-height: 0; margin: 0 ($frame-padding * -1); overflow: hidden; 93 | opacity: 0; transition: all .3s ease-in-out; 94 | &.#{$plugin-name}active { 95 | max-height: 530px; padding-top: 1em; 96 | opacity: 1; 97 | .#{$plugin-name}composition-details-arrow { 98 | top: 1em; 99 | } 100 | } 101 | } 102 | .#{$plugin-name}composition-details-arrow { 103 | position: absolute; top: 0; left: 15px; width: 10px; height: 10px; 104 | border: solid darken($border-color, 5%); border-width: 1px 0 0 1px; 105 | background: $frame-bg-color; 106 | transform: rotate(45deg) translate(-50%,-50%); transform-origin: left top; 107 | content: ""; transition: all .3s ease-in-out; 108 | } 109 | .#{$plugin-name}composition-details-inner { 110 | padding: 5px $frame-padding; margin: 0; 111 | border: solid $border-color; border-width: 1px 0; 112 | list-style: none; 113 | } 114 | .#{$plugin-name}account { 115 | position: relative; min-height: 35px; margin: 10px 0; 116 | .#{$plugin-name}account-count { 117 | position: absolute; top:0; bottom: 0; right: 0; z-index: 0; 118 | background: $bg-color; 119 | &::before { 120 | position: absolute; bottom: 0; right: 2px; 121 | font-size: 14px; color: $lighter-color; 122 | content: attr(data-count); 123 | } 124 | } 125 | .#{$plugin-name}account-inner { 126 | position: relative; z-index: 1; 127 | } 128 | .#{$plugin-name}account-content { 129 | margin-left: 45px; 130 | } 131 | .#{$plugin-name}avatar { 132 | width: 35px; height: 35px; 133 | } 134 | .#{$plugin-name}fullname, 135 | .#{$plugin-name}username { 136 | display: block; overflow: hidden; 137 | white-space: nowrap; text-overflow: ellipsis; 138 | } 139 | &:nth-child(n + #{$max-big-account}) { 140 | display: inline-block; min-height: initial; margin: 5px 2px 5px 0; 141 | color: $lighter-color; text-decoration: none; 142 | vertical-align: top; 143 | &:hover, 144 | &:focus { 145 | .#{$plugin-name}account-count { 146 | text-decoration: underline; 147 | } 148 | } 149 | .#{$plugin-name}account-count { 150 | top: auto; left: 0; width: auto !important; overflow: hidden; 151 | background: none; 152 | line-height: 1; text-align: right; 153 | &::before { 154 | position: static; 155 | } 156 | } 157 | .#{$plugin-name}account-content { 158 | margin: 0; 159 | } 160 | .#{$plugin-name}account-group { 161 | padding-right: 18px; 162 | } 163 | .#{$plugin-name}avatar { 164 | position: static; display: block; width: 20px; height: 20px; 165 | } 166 | .#{$plugin-name}account-group-inner { 167 | display: none; 168 | } 169 | } 170 | &:last-child { 171 | margin-bottom: 10px; 172 | } 173 | } 174 | .#{$plugin-name}engagement { 175 | display: flex; width: 100%; height: ($composition-height * 2); 176 | flex-flow: column nowrap; align-items: stretch; 177 | background: $bg-color; 178 | .#{$plugin-name}rt, 179 | .#{$plugin-name}fav { 180 | flex: 1; display: flex; overflow: hidden; 181 | background: $fg-color; 182 | &::before { 183 | margin: auto auto auto 2px; 184 | color: $icons-color; 185 | } 186 | } 187 | .#{$plugin-name}rt::before { content: "\f152"; } 188 | .#{$plugin-name}fav::before { content: "\f147"; } 189 | } 190 | .#{$plugin-name}language { 191 | display: flex; width: 100%; height: $composition-height; 192 | flex-flow: row nowrap; align-items: stretch; 193 | background: $bg-color; 194 | div { 195 | padding-left: 2px; 196 | background: $fg-color; 197 | line-height: $composition-height; color: $icons-color; font-weight: bold; text-transform: uppercase; 198 | &:nth-child(even) { 199 | background: darken($fg-color,20%); 200 | } 201 | } 202 | } 203 | .#{$plugin-name}reminder { 204 | margin: 1em ($frame-padding * -1) 0; padding: $frame-padding $frame-padding 0; 205 | border-top: 1px solid $border-color; 206 | color: $lighter-color; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /UI-demo/scss/twitter-stylesheets.scss: -------------------------------------------------------------------------------- 1 | /* ------------------------------------------------------------------ 2 | Extracted from Twitter stylesheets 3 | ------------------------------------------------------------------ */ 4 | 5 | body { 6 | font-family: Arial,sans-serif; 7 | } 8 | .ProfilePage { 9 | background-image: none!important; 10 | background-color: #f5f8fa!important; 11 | line-height: 1.375; 12 | min-width: 936px; 13 | } 14 | 15 | .WhoToFollow { 16 | display: none; 17 | font-size: 12px; 18 | } 19 | .ProfileSidebar .ProfileTweetbox, .ProfileSidebar .SignupCallOut, .ProfileSidebar .ProfileLifelineInfo, .ProfileSidebar .ProfileUserList, .ProfileSidebar .PhotoRail, .ProfileSidebar .WhoToFollow, .ProfileSidebar .EventFollowAll, .ProfileSidebar .Trends, .ProfileSidebar .RelatedStream, .ProfileSidebar .Footer { 20 | margin-top: 20px; 21 | } 22 | .ProfileSidebar .SignupCallOut, .ProfileSidebar .ProfileLifelineInfo, .ProfileSidebar .WhoToFollow, .ProfileSidebar .RelatedStream, .ProfileSidebar .EventFollowAll, .ProfileSidebar .Trends { 23 | background-color: #fff; 24 | border: 1px solid #e1e8ed; 25 | border-radius: 5px; 26 | margin-top: 10px; 27 | padding: 15px; 28 | } 29 | .WhoToFollow.is-visible { 30 | display: block; 31 | } 32 | .WhoToFollow-header { 33 | margin-bottom: 15px; 34 | } 35 | blockquote, dl, dd, h1, h2, h3, h4, h5, h6, figure, p, pre { 36 | margin: 0; 37 | } 38 | .WhoToFollow-title { 39 | color: #66757f; 40 | display: inline-block; 41 | font-size: 18px; 42 | font-weight: 300; 43 | line-height: 1; 44 | } 45 | 46 | a { 47 | background: transparent; 48 | } 49 | a { 50 | color: #2b7bb9; 51 | text-decoration: none; 52 | } 53 | a, a:hover, a:focus, a:active { 54 | color: #00D084; 55 | } 56 | a:hover, a:focus, a:active { 57 | text-decoration: underline; 58 | } 59 | a:active, a:hover { 60 | outline: 0; 61 | } 62 | 63 | button, html input[type="button"], input[type="reset"], input[type="submit"] { 64 | -webkit-appearance: button; 65 | cursor: pointer; 66 | } 67 | button { 68 | border: 0; 69 | } 70 | button { 71 | background: transparent; 72 | border: 0; 73 | padding: 0; 74 | } 75 | .btn-link { 76 | padding: 0; 77 | background-color: transparent; 78 | background-image: none; 79 | border: 0; 80 | cursor: pointer; 81 | border-radius: 0; 82 | box-shadow: none; 83 | } 84 | .btn-link:hover, .btn-link:focus { 85 | outline: 0; 86 | text-decoration: underline; 87 | box-shadow: none; 88 | } 89 | a, .btn-link, .btn-link:focus, .icon-btn, .pretty-link b, .pretty-link:hover s, .pretty-link:hover b, .pretty-link:focus s, .pretty-link:focus b, .metadata a:hover, .metadata a:focus, .account-group:hover .fullname, .account-group:focus .fullname, .account-summary:focus .fullname, .message .message-text a, .stats a strong, .plain-btn:hover, .plain-btn:focus, .dropdown.open .user-dropdown.plain-btn, .open > .plain-btn, #global-actions .new:before, .module .list-link:hover, .module .list-link:focus, .UserCompletion-step:hover, .stats a:hover, .stats a:hover strong, .stats a:focus, .stats a:focus strong, .profile-modal-header .fullname a:hover, .profile-modal-header .username a:hover, .profile-modal-header .fullname a:focus, .profile-modal-header .username a:focus, .story-article:hover .metadata, .story-article .metadata a:focus, .find-friends-sources li:hover .source, .stream-item a:hover .fullname, .stream-item a:focus .fullname, .stream-item .view-all-supplements:hover, .stream-item .view-all-supplements:focus, .tweet .time a:hover, .tweet .time a:focus, .tweet .details.with-icn b, .tweet .details.with-icn .Icon, .tweet .tweet-geo-text a:hover, .stream-item:hover .original-tweet .details b, .stream-item .original-tweet.focus .details b, .stream-item.open .original-tweet .details b, .simple-tweet:hover .details b, .simple-tweet.focus .details b, .simple-tweet.open .details b, .simple-tweet:hover .details .simple-details-link, .simple-tweet.focus .details .simple-details-link, .client-and-actions a:hover, .client-and-actions a:focus, .dismiss-promoted:hover b, .tweet .context .pretty-link:hover s, .tweet .context .pretty-link:hover b, .tweet .context .pretty-link:focus s, .tweet .context .pretty-link:focus b, .list .username a:hover, .list .username a:focus, .list-membership-container .create-a-list, .list-membership-container .create-a-list:hover, .story-header:hover .view-tweets, .card .list-details a:hover, .card .list-details a:focus, .card .card-body:hover .attribution, .card .card-body .attribution:focus, .events-card .card-body:hover .attribution, .events-card .card-body .attribution:focus, .new-tweets-bar, .onebox .soccer ul.ticker a:hover, .onebox .soccer ul.ticker a:focus, .discover-item-actions a, .remove-background-btn, .stream-item-activity-me .latest-tweet .tweet-row a:hover, .stream-item-activity-me .latest-tweet .tweet-row a:focus, .stream-item-activity-me .latest-tweet .tweet-row a:hover b, .stream-item-activity-me .latest-tweet .tweet-row a:focus b { 90 | color: #00D084; 91 | } 92 | 93 | .account-summary { 94 | position: relative; 95 | display: block; 96 | min-height: 48px; 97 | } 98 | .account-summary .content { 99 | margin-left: 58px; 100 | margin-right: 20px; 101 | } 102 | .account-summary .account-group, .account-summary .account-action { 103 | display: block; 104 | line-height: 16px; 105 | } 106 | .account-group:hover, .account-summary:focus .account-group { 107 | text-decoration: none; 108 | } 109 | .account-group:hover .fullname, .account-group:focus .fullname, .account-summary:focus .fullname { 110 | text-decoration: underline; 111 | } 112 | img { 113 | border: 0; 114 | } 115 | .avatar { 116 | width: 48px; 117 | height: 48px; 118 | border-radius: 5px; 119 | -moz-force-broken-image-icon: 1; 120 | } 121 | .account-summary .avatar { 122 | position: absolute; 123 | top: 0; 124 | left: 0; 125 | } 126 | .account-summary .account-group-inner, .account-summary .pretty-link, .account-summary .account-action, .account-summary .location { 127 | color: #8899a6; 128 | } 129 | .account-summary .account-group-inner, .account-summary .metadata { 130 | white-space: nowrap; 131 | overflow: hidden; 132 | text-overflow: ellipsis; 133 | } 134 | .account-summary .account-group-inner { 135 | display: block; 136 | width: 100%; 137 | } 138 | .fullname { 139 | font-weight: bold; 140 | color: #292f33; 141 | } 142 | .WhoToFollow-users .fullname { 143 | color: #292f33; 144 | font-size: 13px; 145 | } 146 | .account-summary .account-group-inner, .account-summary .metadata { 147 | white-space: nowrap; 148 | overflow: hidden; 149 | text-overflow: ellipsis; 150 | } 151 | .username, .time, .time a, .metadata, .metadata button.btn-link, .metadata a { 152 | font-size: 13px; 153 | color: #8899a6; 154 | } 155 | .username { 156 | direction: ltr; 157 | unicode-bidi: embed; 158 | } 159 | .WhoToFollow-users .username { 160 | color: #8899a6; 161 | } 162 | s { 163 | text-decoration: none; 164 | } 165 | .username s, .account-group-inner s { 166 | color: #b1bbc3; 167 | } 168 | 169 | /* ------------------------------------------------------------------ 170 | Just for demo 171 | ------------------------------------------------------------------ */ 172 | 173 | .WhoToFollow { 174 | width: 290px; margin: 10px auto; box-sizing: border-box; 175 | } 176 | -------------------------------------------------------------------------------- /firefox/.jpmignore: -------------------------------------------------------------------------------- 1 | .jpmignore # https://github.com/mozilla-jetpack/jpm/issues/320 2 | 3 | defs 4 | test 5 | doc 6 | 7 | *.d.ts 8 | *.ts 9 | *.js.map 10 | 11 | *.xpi -------------------------------------------------------------------------------- /firefox/data/createTwitterApp/collectTwitterAppAPICredentials.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | const apiKeyRow = document.body.querySelector('.app-settings .row:nth-of-type(1)'); 5 | const apiSecretRow = document.body.querySelector('.app-settings .row:nth-of-type(2)'); 6 | 7 | if(!apiKeyRow.querySelector('span.heading').textContent.toLowerCase().contains('key')) 8 | self.port.emit('error', new Error('assertion broken: first row does not correspond to the API key')); 9 | 10 | if(!apiSecretRow.querySelector('span.heading').textContent.toLowerCase().contains('secret')) 11 | self.port.emit('error', new Error('assertion broken: first row does not correspond to the API secret')); 12 | 13 | self.port.emit('credentials', { 14 | key: apiKeyRow.querySelector('span:nth-of-type(2)').textContent.trim(), 15 | secret: apiSecretRow.querySelector('span:nth-of-type(2)').textContent.trim() 16 | }) 17 | 18 | })(); 19 | -------------------------------------------------------------------------------- /firefox/data/createTwitterApp/fillInAppCreationForm.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | /* 5 | Need self.options in the form of 6 | { 7 | // needs to be globally unique and below 32 chars 8 | name: string, 9 | // 10-200 chars 10 | description: string, 11 | website: string 12 | } 13 | */ 14 | if(!self.options){ 15 | throw new Error('need options in fillInAppCreationForm'); 16 | } 17 | 18 | const {name, description, website} = self.options; 19 | //console.log('credentials from inside', username); 20 | 21 | const appCreationForm = document.querySelector('form#twitter-apps-create-form'); 22 | 23 | const nameInput = appCreationForm.querySelector('#edit-app-details input#edit-name'); 24 | const descriptionInput = appCreationForm.querySelector('#edit-app-details input#edit-description'); 25 | const websiteInput = appCreationForm.querySelector('#edit-app-details input#edit-url'); 26 | 27 | // Agreeing to https://dev.twitter.com/terms/api-terms 28 | const tosCheckbox = appCreationForm.querySelector('#edit-tos input[type="checkbox"]#edit-tos-agreement'); 29 | 30 | const inputSubmit = appCreationForm.querySelector('input[type="submit"]'); 31 | 32 | nameInput.value = name; 33 | descriptionInput.value = description; 34 | websiteInput.value = website; 35 | 36 | // make sure to inform the user they're agreeing to https://dev.twitter.com/terms/api-terms 37 | // simulate a click (might be important like for the submit) 38 | tosCheckbox.click(); 39 | 40 | 41 | setTimeout(() => { 42 | // simulate a click since it seems important (appCreationForm.submit() isn't enough apparently) 43 | inputSubmit.click(); 44 | }, 2*1000); 45 | 46 | })(); 47 | -------------------------------------------------------------------------------- /firefox/data/dev/twitter-login.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | self.port.on('twitter-credentials', function(credentials){ 4 | const {username, password} = credentials; 5 | //console.log('credentials from inside', username); 6 | 7 | const signinForm = document.querySelector('.front-signin form'); 8 | 9 | const usernameInput = signinForm.querySelector('.username input'); 10 | const passwordInput = signinForm.querySelector('.password input[type="password"]'); 11 | 12 | const submitButton = signinForm.querySelector('button[type="submit"]') 13 | 14 | usernameInput.value = username; 15 | passwordInput.value = password; 16 | 17 | console.log(usernameInput.value, passwordInput.value) 18 | 19 | //signinForm.submit(); 20 | setTimeout( () => { 21 | //signinForm.submit(); 22 | submitButton.click(); 23 | }, 2*1000); 24 | 25 | }); -------------------------------------------------------------------------------- /firefox/data/images/Twitter_logo_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/firefox/data/images/Twitter_logo_blue.png -------------------------------------------------------------------------------- /firefox/data/metrics-integration/TweetMine.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import getRetweetOriginalTweet = require('./getRetweetOriginalTweet'); 4 | import getWhosBeingConversedWith = require('./getWhosBeingConversedWith'); 5 | import stemByLang = require('./stem'); 6 | 7 | const ONE_DAY = 24*60*60*1000; // ms 8 | const DEFAULT_STEMMER_LANG = 'en'; 9 | 10 | function isRetweet(t: TwitterAPITweet) : boolean{ 11 | return 'retweeted_status' in t; 12 | } 13 | 14 | // https://stackoverflow.com/questions/1144783/replacing-all-occurrences-of-a-string-in-javascript 15 | function escapeRegExp(string: string) { 16 | return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); 17 | } 18 | function replaceAll(string: string, find: string, replace: string) { 19 | return string.replace(new RegExp(escapeRegExp(find), 'g'), replace); 20 | } 21 | 22 | function removeEntitiesFromTweetText(text: string, entities: TwitterAPIEntities){ 23 | let result = text; 24 | 25 | result = replaceAll(result, '@', ''); 26 | result = replaceAll(result, '#', ''); 27 | 28 | if(entities.hashtags){ 29 | entities.hashtags.forEach(hashtag => { 30 | result = replaceAll(result, hashtag.text, ''); 31 | }); 32 | } 33 | 34 | if(entities.media){ 35 | entities.media.forEach(media => { 36 | result = replaceAll(result, media.url, ''); 37 | }); 38 | } 39 | 40 | if(entities.urls){ 41 | entities.urls.forEach(url => { 42 | result = replaceAll(result, url.url, ''); 43 | }); 44 | } 45 | 46 | if(entities.user_mentions){ 47 | entities.user_mentions.forEach(user_mention => { 48 | result = replaceAll(result, user_mention.screen_name, ''); 49 | }); 50 | } 51 | 52 | return result; 53 | } 54 | 55 | 56 | /* 57 | @args 58 | tweets : as received by the homeline API 59 | visitedUsername : currently viewed user (screen_name) 60 | */ 61 | function TweetMine( 62 | tweets: TwitterAPITweet[], 63 | nbDays: number, 64 | visitedUserId: TwitterUserId, 65 | addonUser: TwitterUserId, 66 | addonUserFriendIds: Set, 67 | languages?: Map 68 | ){ 69 | 70 | function isConversation(tweet: TwitterAPITweet) : boolean{ 71 | return tweet.user.id_str === visitedUserId && 72 | !tweet.retweeted_status && 73 | tweet.text.startsWith('@') && 74 | // if a user recently changed of screen_name, a tweet may start with @, but not 75 | // refer to an actual user. Testing if there is an entity to make sure. 76 | tweet.entities.user_mentions && tweet.entities.user_mentions.length >= 1; // a bit weak, but close enough. Would need to check if the user actually exists 77 | } 78 | 79 | function byDateRage(from: number, to: number){ 80 | if(!from || !to) 81 | throw new TypeError('need 2 args'); 82 | 83 | return tweets.filter( t => { 84 | var createdTimestamp = (new Date(t.created_at)).getTime(); 85 | return createdTimestamp >= from && createdTimestamp < to; 86 | }); 87 | } 88 | 89 | // remove all tweets that don't fit in the range 90 | var now = Date.now(); 91 | var since = now - nbDays*ONE_DAY; 92 | 93 | tweets = byDateRage(since, now); 94 | 95 | 96 | return { 97 | // from and to are UNIX timestamps (number of ms since Jan 1st 1970) 98 | byDateRange: byDateRage, 99 | 100 | getRetweets: function(){ 101 | return tweets.filter(isRetweet); 102 | }, 103 | /* 104 | includes RTs and conversations 105 | */ 106 | getTweetsWithLinks: function(){ 107 | return tweets.filter(tweet => { 108 | try{ 109 | return tweet.entities.urls.length >= 1; 110 | } 111 | catch(e){ 112 | // most likely a nested property doesn't exist 113 | return false; 114 | } 115 | }); 116 | }, 117 | 118 | getConversations: function(){ 119 | return tweets.filter(isConversation); 120 | }, 121 | 122 | getNonRetweetNonConversationTweets: function(){ 123 | // (Set) until proper Set definition in TS 1.4 124 | var rts : Set = new (Set)(this.getRetweets()); 125 | var convs : Set = new (Set)(this.getConversations()); 126 | var ret : Set = new (Set)(tweets); 127 | 128 | rts.forEach(t => ret.delete(t)); 129 | convs.forEach(t => ret.delete(t)); 130 | 131 | return (Array).from(ret); 132 | }, 133 | 134 | getOldestTweet: function(){ 135 | return tweets[tweets.length - 1]; // last 136 | }, 137 | 138 | getOwnTweets: function() : TwitterAPITweet[] { 139 | return tweets.filter(tweet => !('retweeted_status' in tweet)); 140 | }, 141 | 142 | getGeneratedRetweetsCount: function(){ 143 | return this.getOwnTweets() 144 | .map((tweet : TwitterAPITweet) => tweet.retweet_count) 145 | .reduce((acc: number, rtCount: number) => {return acc + rtCount}, 0); 146 | }, 147 | getGeneratedFavoritesCount: function(){ 148 | return this.getOwnTweets() 149 | .map((tweet : TwitterAPITweet) => tweet.favorite_count) 150 | .reduce((acc: number, favCount: number) => {return acc + favCount}, 0); 151 | }, 152 | 153 | getTweetsThatWouldBeSeenIfAddonUserFollowedVisitedUser: function(){ 154 | return tweets.filter(t => { 155 | const isRT = isRetweet(t); 156 | const isConv = isConversation(t); 157 | 158 | if(!isRT && !isConv) 159 | return true; 160 | else{ 161 | if(!addonUserFriendIds){ 162 | return isRT || !isConv; 163 | } 164 | 165 | return (isRT && !addonUserFriendIds.has(getRetweetOriginalTweet(t).user.id_str)) || 166 | (isConv && addonUserFriendIds.has( getWhosBeingConversedWith(t) )); 167 | } 168 | }); 169 | 170 | }, 171 | 172 | getTweetsByLang: function(){ 173 | const tweetsByLang = new Map() 174 | 175 | tweets.forEach(t => { 176 | const originalTweet = getRetweetOriginalTweet(t); 177 | const text = removeEntitiesFromTweetText(originalTweet.text, t.entities); 178 | const lang = originalTweet.lang; 179 | 180 | const tweets = tweetsByLang.get(lang) || []; 181 | 182 | tweets.push(t); 183 | 184 | tweetsByLang.set(lang, tweets) 185 | }) 186 | 187 | }, 188 | 189 | getWordMap: function(){ 190 | const wordToTweets = new Map(); 191 | 192 | tweets.forEach(t => { 193 | const originalTweet = getRetweetOriginalTweet(t); 194 | const text = removeEntitiesFromTweetText(originalTweet.text, t.entities); 195 | const lang = originalTweet.lang; 196 | 197 | const stem = stemByLang.get(lang) || stemByLang.get(DEFAULT_STEMMER_LANG); 198 | 199 | //console.log('getWordMap', lang, typeof stem); 200 | 201 | const stems = new Set(stem(text)); 202 | 203 | stems.forEach(s => { 204 | const tweets = wordToTweets.get(s) || []; 205 | 206 | tweets.push(t); 207 | 208 | wordToTweets.set(s, tweets) 209 | }); 210 | 211 | if(t.entities.hashtags){ 212 | t.entities.hashtags.forEach(hashtag => { 213 | const s = '#'+hashtag.text; 214 | 215 | let tweets = wordToTweets.get(s); 216 | if(!tweets){ 217 | tweets = []; 218 | } 219 | 220 | tweets.push(t); 221 | 222 | wordToTweets.set(s, tweets); 223 | }); 224 | } 225 | 226 | }); 227 | 228 | 229 | // sometimes, the 0-length string gets in the map 230 | wordToTweets.delete('') 231 | 232 | return wordToTweets; 233 | }, 234 | 235 | get length(){ 236 | return tweets.length; 237 | } 238 | } 239 | } 240 | 241 | export = TweetMine; -------------------------------------------------------------------------------- /firefox/data/metrics-integration/additional.css: -------------------------------------------------------------------------------- 1 | body > .twitter-assistant-container{ 2 | position: absolute; 3 | top: 0; 4 | right: 0; 5 | 6 | width: 20vw; 7 | } 8 | 9 | 10 | 11 | .word-mass{ 12 | display: block; 13 | list-style: none; 14 | padding: 0; 15 | 16 | text-align: center; 17 | } 18 | 19 | .word-mass li{ 20 | display: inline-block; 21 | 22 | outline: 1px solid #DDD; 23 | text-align: center; 24 | vertical-align: middle; 25 | 26 | width: 33%; 27 | 28 | position: relative; 29 | 30 | height: 2em; 31 | } 32 | .word-mass li .proportion{ 33 | background-color: beige; 34 | 35 | height: 100%; 36 | position: absolute; 37 | right: 0; 38 | 39 | z-index: 0; 40 | } 41 | .word-mass li .word{ 42 | z-index: 1; 43 | position: relative; 44 | } 45 | 46 | .twitter-assistant-container ol{ 47 | margin: 0; 48 | } -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/DetailList.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | interface DetailsData{ 4 | amount: number 5 | text: string 6 | url: string //(url) 7 | image: string //(url) 8 | } 9 | 10 | interface DetailsProps{ 11 | details: DetailsData[] 12 | } 13 | 14 | const totalHeight = 300; 15 | 16 | const DetailList = React.createClass({ 17 | 18 | getInitialState: function(){ 19 | return {anim: false}; 20 | }, 21 | 22 | render: function(){ 23 | let details : DetailsData[] = this.props.details; 24 | 25 | // only keep 10 top items 26 | details = details.slice(0, 10); 27 | 28 | const totalConsideredCount = details 29 | .reduce( (acc, data) => {return acc+data.amount}, 0 ); 30 | 31 | if(!this.state.anim) 32 | // hack to trigger the CSS animation. No idea how to do better. rAF doesn't work :-/ 33 | setTimeout(() => { 34 | this.setState({anim: true}); 35 | }, 20); 36 | 37 | return React.DOM.div({className: ['TA-composition-details', (this.state.anim ? 'TA-active' : '')].join(' ')}, 38 | React.DOM.ol({className: 'TA-composition-details-inner'}, details.map((detailsData) => { 39 | var amount = detailsData.amount, 40 | text = detailsData.text, 41 | url = detailsData.url, 42 | image = detailsData.image; 43 | 44 | 45 | return React.DOM.li({ 46 | className: "TA-account account-summary" 47 | }, [ 48 | React.DOM.div({ 49 | className : "TA-account-count", 50 | style: { 51 | width: (amount*100/totalConsideredCount) + '%' 52 | }, 53 | 'data-count': amount 54 | }), 55 | React.DOM.div({className : "TA-account-inner"}, [ 56 | React.DOM.div({className: "TA-account-content content"}, [ 57 | React.DOM.a({ 58 | className: "TA-account-group account-group", 59 | href: url || '', 60 | target: '_blank' 61 | }, [ 62 | image ? React.DOM.img({className: 'TA-avatar avatar', src: image}) : undefined, 63 | React.DOM.span({className: 'TA-account-group-inner account-group-inner'}, [ 64 | React.DOM.b({className: 'TA-fullname fullname'}, text) 65 | // missing @thibautseguy 66 | ]) 67 | ]) 68 | ]) 69 | ]), 70 | ]); 71 | 72 | // missing TA-composition-details-more 73 | 74 | })) 75 | ); 76 | } 77 | 78 | }); 79 | 80 | 81 | export = DetailList; 82 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/GeneratedEngagement.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Metrics = require('./Metrics'); 4 | 5 | 6 | var GeneratedEngagement = React.createClass({ 7 | 8 | render: function(){ 9 | /* 10 | { 11 | tweetMine: TweetMine 12 | } 13 | */ 14 | var data = this.props; 15 | 16 | var tweetMine = data.tweetMine; 17 | 18 | return React.DOM.div( {className: "all-metrics"}, [ 19 | /*Metrics({ 20 | name: 'Retweets', 21 | values : [{ 22 | percent: tweetMine.getGeneratedRetweetsCount()*100/tweetMine.getOwnTweets().length 23 | } 24 | ] 25 | }), 26 | Metrics({ 27 | name: 'Favorites', 28 | values : [{ 29 | percent: tweetMine.getGeneratedFavoritesCount()*100/tweetMine.getOwnTweets().length 30 | } 31 | ] 32 | })*/ 33 | 34 | /*, 35 | Holding off replies for now as the count is going to be fairly hard to get https://github.com/DavidBruant/Twitter-Assistant/issues/14 36 | 37 | 38 | Metrics({ 39 | name: 'Replies', 40 | values : [{ 41 | percent: tweetMine.getGeneratedReplies().length*100/tweetMine.length 42 | } 43 | ] 44 | })*/ 45 | ]); 46 | } 47 | }); 48 | 49 | export = GeneratedEngagement; 50 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/Histogram.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ONE_DAY = 24*60*60*1000; 4 | 5 | const MAX_TWEETS_PER_DAY = 15; 6 | 7 | const Histogram = React.createClass({ 8 | 9 | render: function(){ 10 | /* 11 | { 12 | tweetMine: TweetMine, 13 | histogramSize: number 14 | } 15 | */ 16 | const data = this.props; 17 | 18 | const tweetMine = data.tweetMine, 19 | histogramSize = data.histogramSize; 20 | 21 | return React.DOM.section( {className: 'TA-histogram TA-activity'}, Array(histogramSize).fill(0).map((e, i) => { 22 | let today = (new Date()).getTime(); 23 | today = today - today%ONE_DAY; // beginning of today 24 | 25 | const thisDayTweets = tweetMine.byDateRange(today - i*ONE_DAY, today - (i-1)*ONE_DAY); 26 | 27 | const height = Math.min(thisDayTweets.length/MAX_TWEETS_PER_DAY , 1)*100; 28 | // TODO add a class when > 1 29 | return React.DOM.div({ 30 | className: 'TA-bar', 31 | "data-value": thisDayTweets.length 32 | }, React.DOM.div({ 33 | className: "TA-tweets", 34 | style: { 35 | height: height +'%' 36 | } 37 | }) 38 | ); 39 | }).reverse()) 40 | } 41 | }); 42 | 43 | export = Histogram; 44 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/HumansAreNotMetricsReminder.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const HumansAreNotMetricsReminder = React.createClass({ 4 | 5 | render: function(){ 6 | return React.DOM.footer({className: 'TA-reminder'}, [ 7 | React.DOM.strong({}, "Reminder: "), 8 | "A human being is more than the few metrics you see above." 9 | ]); 10 | // missing feedback link 11 | } 12 | 13 | }); 14 | 15 | export = HumansAreNotMetricsReminder; 16 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/Metrics.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import DetailList = require('./DetailList'); 4 | 5 | // duplicated from DetailList 6 | interface DetailsData{ 7 | amount: number 8 | text: string 9 | url: string //(url) 10 | image: string //(url) 11 | } 12 | 13 | interface MetricsState{ 14 | detailView: MetricsPropsValue 15 | } 16 | 17 | interface MetricsPropsValue{ 18 | class?: string 19 | title?: string 20 | percent: number //(between 0 and 100) 21 | details? : DetailsData[] 22 | } 23 | 24 | interface MetricsProps{ 25 | name?: string 26 | values: MetricsPropsValue[] 27 | } 28 | 29 | /*const Metrics = React.createClass({ 30 | getInitialState: function() : MetricsState{ 31 | return {detailView: undefined}; 32 | }, 33 | 34 | componentWillReceiveProps: function(nextProps: MetricsProps){ 35 | 36 | if(this.state.detailView){ 37 | var newState = { 38 | detailView: nextProps.values.find(e => e.class === this.state.detailView.class) 39 | }; 40 | 41 | this.setState(newState); 42 | } 43 | }, 44 | 45 | render: function(){ 46 | const data : MetricsProps = this.props; 47 | const state : MetricsState = this.state; 48 | 49 | const name = data.name, 50 | values = data.values; 51 | 52 | var fractionContainerChildren : any; // Actually a ReactChild (ReactElement | string), but union types aren't there yet 53 | 54 | if(values.length === 1){ 55 | let value = values[0]; 56 | let times = Math.floor(value.percent/100); 57 | let rest = value.percent - times*100; 58 | 59 | fractionContainerClasses.push( times <= 5 ? ('x'+times+'-'+(times+1)) : 'lots' ) 60 | 61 | fractionContainerChildren = times <= 5 ? 62 | React.DOM.div( { 63 | className: ["value", value.class].filter(s => !!s).join(' '), 64 | title: value.title, 65 | style: { 66 | width: rest.toFixed(1)+'%' 67 | } 68 | }) : 69 | value.percent.toFixed(1) + '%'; 70 | } 71 | else{ 72 | fractionContainerChildren = values.map(v => { 73 | var clickable = !!v.details; 74 | 75 | return React.DOM.div( { 76 | className: [ 77 | v.class, 78 | clickable ? 'clickable' : '' 79 | ].filter(s => !!s).join(' '), 80 | title: v.title, 81 | style: { 82 | width: v.percent.toFixed(1)+'%' 83 | }, 84 | onClick: !clickable ? undefined : () => { 85 | console.log('click', v.percent); 86 | this.setState({ 87 | detailView: state.detailView === v ? undefined : v 88 | }); 89 | } 90 | }); 91 | }); 92 | } 93 | 94 | var children : any[] = []; // Actually a ReactChild (ReactElement | string), but union types aren't there yet 95 | if(name) 96 | children.push(React.DOM.div( {className: "name"}, name )); 97 | 98 | children.push(React.DOM.div( 99 | {className: fractionContainerClasses.join(' ')}, 100 | fractionContainerChildren 101 | )); 102 | 103 | if(state.detailView) 104 | children.push( DetailList({details: state.detailView.details}) ); 105 | 106 | return React.DOM.div( {className: "metrics"}, children) 107 | } 108 | 109 | }); 110 | 111 | 112 | export = Metrics;*/ -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/TimelineComposition.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import getRetweetOriginalTweet = require('../getRetweetOriginalTweet'); 4 | import getWhosBeingConversedWith = require('../getWhosBeingConversedWith'); 5 | 6 | import makeTimelineCompositionChildren = require('./makeTimelineCompositionChildren'); 7 | 8 | function getDomain(url: string){ 9 | var a = document.createElement('a'); 10 | a.href = url; 11 | return a.hostname; 12 | } 13 | 14 | 15 | 16 | interface UserCountEntry{ 17 | count: number 18 | user: TwitterAPIUser 19 | userId: TwitterUserId // in case the user is missing 20 | } 21 | 22 | function makeRetweetDetails(retweets: TwitterAPITweet[], users: Map){ 23 | var originalTweets = retweets.map(getRetweetOriginalTweet); 24 | var originalTweetsByAuthor = new Map(); 25 | originalTweets.forEach( t => { 26 | var userId = t.user.id_str; 27 | 28 | if(!originalTweetsByAuthor.has(userId)){ 29 | originalTweetsByAuthor.set(userId, { 30 | count: 0, 31 | user: users.get(userId), 32 | userId: userId 33 | }); 34 | } 35 | 36 | originalTweetsByAuthor.get(userId).count++; 37 | }); 38 | 39 | var sortedRetweetedUsers: UserCountEntry[] = Array.from(originalTweetsByAuthor.values()) 40 | .sort((o1, o2) => o1.count < o2.count ? 1 : -1 ); 41 | 42 | // keep only the first 10 for now 43 | sortedRetweetedUsers = sortedRetweetedUsers.slice(0, 10); 44 | 45 | var missingUserIds = sortedRetweetedUsers 46 | .map(o => o.userId) 47 | .filter(id => !users.has(id)); 48 | 49 | return { 50 | retweetDetails: sortedRetweetedUsers.map((entry) => { 51 | var count = entry.count, 52 | user = entry.user; 53 | 54 | return { 55 | amount: count, 56 | text: user && user.name, 57 | url: user && ("https://twitter.com/"+user.screen_name), 58 | image: user && user.profile_image_url_https 59 | } 60 | }), 61 | missingUsers: missingUserIds 62 | }; 63 | } 64 | 65 | 66 | function makeConversationDetails(conversationTweets: TwitterAPITweet[], users: Map){ 67 | var usersConversedWith = new Map(); 68 | //console.log('conversationTweets', conversationTweets) 69 | //console.log('to myself', conversationTweets.filter(t => t.user.screen_name.toLowerCase() === 'DavidBruant'.toLowerCase())) 70 | 71 | conversationTweets.forEach(t => { 72 | var userId = getWhosBeingConversedWith(t); 73 | 74 | if(!usersConversedWith.has(userId)){ 75 | usersConversedWith.set(userId, { 76 | count: 0, 77 | user: users.get(userId), 78 | userId: userId 79 | }); 80 | } 81 | 82 | usersConversedWith.get(userId).count++; 83 | }); 84 | 85 | var sortedConversedUsers : UserCountEntry[] = Array.from(usersConversedWith.values()) 86 | .sort((o1, o2) => o1.count < o2.count ? 1 : -1 ); 87 | 88 | var missingUserIds = sortedConversedUsers 89 | .map(o => o.userId) 90 | .filter(id => !users.has(id)); 91 | 92 | 93 | // keep only the first 10 for now 94 | sortedConversedUsers = sortedConversedUsers.slice(0, 10); 95 | 96 | return { 97 | conversationDetails : sortedConversedUsers.map((entry) => { 98 | var count = entry.count, 99 | user = entry.user; 100 | 101 | return { 102 | amount: count, 103 | text: user && user.name, 104 | url: user && ("https://twitter.com/"+user.screen_name), 105 | image: user && user.profile_image_url_https 106 | } 107 | }), 108 | missingUsers : missingUserIds 109 | }; 110 | } 111 | 112 | interface DomainCountEntry{ 113 | count: number 114 | tweets: TwitterAPITweet[] 115 | } 116 | 117 | function makeLinksDetails(tweetsWithLinks: TwitterAPITweet[]){ 118 | var countsByDomain = new Map(); 119 | 120 | tweetsWithLinks.forEach(t => { 121 | t.entities.urls.forEach(urlObj => { 122 | var domain = getDomain(urlObj.expanded_url); 123 | var record = countsByDomain.get(domain); 124 | 125 | if(!record){ 126 | record = { 127 | count: 0, 128 | tweets: [] 129 | }; 130 | 131 | countsByDomain.set(domain, record); 132 | } 133 | 134 | record.count++; 135 | record.tweets.push(t); 136 | }); 137 | }); 138 | 139 | var sortedDomains : string[] = (Array).from((countsByDomain).keys()) 140 | .sort((d1:string, d2:string) => countsByDomain.get(d1).count < countsByDomain.get(d2).count ? 1 : -1 ); 141 | 142 | // keep only the first 10 for now 143 | sortedDomains = sortedDomains.slice(0, 10); 144 | 145 | return sortedDomains.map(domain => { 146 | var record = countsByDomain.get(domain); 147 | 148 | return { 149 | amount: record.count, 150 | text: domain, 151 | url: 'http://'+domain+'/' 152 | }; 153 | }); 154 | } 155 | 156 | interface TimelineCompositionProps{ 157 | tweetMine: any // TweetMine 158 | users: Map 159 | askMissingUsers : (ids: TwitterUserId[]) => void 160 | showDetails: (details?: any) => void 161 | } 162 | 163 | var TimelineComposition = React.createClass({ 164 | 165 | render: function(){ 166 | var data : TimelineCompositionProps = this.props; 167 | var state = this.state; 168 | 169 | var tweetMine = data.tweetMine, 170 | users = data.users, 171 | askMissingUsers = data.askMissingUsers; 172 | 173 | // console.log('TimelineComposition props users', iterableToArray(users.keys())); 174 | 175 | var writtenTweets : TwitterAPITweet[] = tweetMine.getNonRetweetNonConversationTweets(); 176 | 177 | var tweetsWithLinks = writtenTweets.filter(t => { 178 | try{ 179 | return t.entities.urls.length >= 1; 180 | } 181 | catch(e){ 182 | // most likely a nested property doesn't exist 183 | return false; 184 | } 185 | }); 186 | var linkPercent = tweetsWithLinks.length*100/tweetMine.length; 187 | var linkTweetSet = new (Set)(tweetsWithLinks); // waiting for TS1.4 for proper Set interface 188 | 189 | // if a tweet has both a link and a media, the link is prioritized 190 | var mediaTweets = writtenTweets.filter(t => { 191 | try{ 192 | return t.entities.media.length >= 1 && !linkTweetSet.has(t); 193 | } 194 | catch(e){ 195 | // most likely a nested property doesn't exist 196 | return false; 197 | } 198 | }); 199 | linkTweetSet = undefined; 200 | var mediaPercent = mediaTweets.length*100/tweetMine.length; 201 | 202 | 203 | var retweets = tweetMine.getRetweets(); 204 | var rtPercent = retweets.length*100/tweetMine.length; 205 | 206 | var conversationTweets = tweetMine.getConversations(); 207 | var convPercent = conversationTweets.length*100/tweetMine.length; 208 | 209 | // looking forward to destructuring in TS 1.5 210 | var convLeftover = makeConversationDetails(conversationTweets, users); 211 | var conversationDetails = convLeftover.conversationDetails, 212 | convMissingUsers = convLeftover.missingUsers; 213 | 214 | const rtLeftover = makeRetweetDetails(retweets, users); 215 | const rtDetails = rtLeftover.retweetDetails, 216 | rtMissingUsers = rtLeftover.missingUsers; 217 | 218 | const missingUsers = Array.from(new Set(convMissingUsers.concat(rtMissingUsers))); 219 | 220 | if(missingUsers.length >= 1){ 221 | askMissingUsers(missingUsers); 222 | } 223 | 224 | return React.DOM.div({className: "TA-composition"}, makeTimelineCompositionChildren([ 225 | { 226 | class: 'TA-retweets Icon', 227 | title: "retweets", 228 | percent: rtPercent, 229 | details: rtDetails 230 | }, 231 | { 232 | class: 'TA-replies Icon', 233 | title: "replies", 234 | percent: convPercent, 235 | details: conversationDetails 236 | }, 237 | { 238 | class: 'TA-links Icon', 239 | title: "links", 240 | percent: linkPercent, 241 | details: makeLinksDetails(tweetsWithLinks) 242 | }, 243 | { 244 | class: 'TA-medias Icon', 245 | title: "medias", 246 | percent: mediaPercent 247 | }, 248 | { 249 | class: 'TA-tweets Icon', 250 | title: "other tweets", 251 | percent: 100 - (rtPercent + convPercent + mediaPercent + linkPercent) 252 | } 253 | ], data.showDetails)); 254 | } 255 | }); 256 | 257 | export = TimelineComposition; -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/TweetList.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import getRetweetOriginalTweet = require('../getRetweetOriginalTweet'); 4 | 5 | interface TweetListProps{ 6 | title: number 7 | tweets: TwitterAPITweet[] 8 | users: Map, 9 | askUsers: (userIds: TwitterUserId[]) => void; 10 | } 11 | 12 | 13 | const TweetList = React.createClass({ 14 | 15 | render: function(){ 16 | const props : TweetListProps = this.props; 17 | 18 | const missingUsers :TwitterUserId[] = []; 19 | 20 | // this is ugly 21 | setTimeout(() => { 22 | if(missingUsers.length >= 1) 23 | props.askUsers(missingUsers); 24 | }, 20) 25 | 26 | return React.DOM.div({className: 'AppContainer'}, 27 | React.DOM.div({className: 'AppContent-main content-main u-cf'}, 28 | React.DOM.div({className: 'Grid Grid--withGutter'}, 29 | React.DOM.div({className: 'Grid-cell u-size1of3 u-lg-size1of4'}), 30 | React.DOM.div({className: 'Grid-cell u-size2of3 u-lg-size3of4'}, 31 | React.DOM.div({className: 'Grid Grid--withGutter'}, 32 | React.DOM.div({className: 'TA-tl-container Grid-cell u-lg-size2of3'}, 33 | // finally some content... 34 | React.DOM.div({className: 'ProfileHeading'}, 35 | // no... wait... 36 | React.DOM.div({className: 'ProfileHeading-content'}, 37 | React.DOM.div({className: 'ProfileHeading-toggle'}, 38 | // there you go! 39 | React.DOM.h2({className: 'ProfileHeading-toggleItem is-active'}, 40 | props.title 41 | ) 42 | /*React.DOM.button( 43 | { 44 | className: 'btn-link back-to-top', 45 | onClick: e => { 46 | throw 'TODO'; 47 | } 48 | }, 49 | 'Close' 50 | )*/ 51 | ) 52 | ) 53 | ), 54 | React.DOM.div({ className: 'timeline' }, 55 | React.DOM.ol({className: 'stream-items'}, props.tweets.map(t => { 56 | const originalTweet = getRetweetOriginalTweet(t) 57 | 58 | const userId = originalTweet.user.id_str; 59 | const user = props.users.get(originalTweet.user.id_str) || { 60 | profile_image_url_https: '', 61 | screen_name: '', 62 | name: '' 63 | }; 64 | 65 | if(!props.users.has(originalTweet.user.id_str)) 66 | missingUsers.push(originalTweet.user.id_str) 67 | 68 | 69 | console.log('tweet user', userId, user); 70 | 71 | return React.DOM.li({className: 'TA-tweet-item stream-item expanding-stream-item'}, 72 | React.DOM.div({className: 'TA-tweet-item-inner tweet'}, 73 | React.DOM.div({className: 'content'}, 74 | React.DOM.div({className: 'stream-item-header'}, 75 | React.DOM.a( 76 | { 77 | className: 'account-group js-account-group js-action-profile js-user-profile-link js-nav', 78 | href: user.screen_name ? '/'+user.screen_name : undefined, 79 | target: '_blank' 80 | }, 81 | React.DOM.img({className: 'avatar', src: user.profile_image_url_https }), 82 | React.DOM.strong({className: 'fullname js-action-profile-name show-popup-with-id'}, user.name), 83 | React.DOM.span({}, ''), // useless span because why not 84 | React.DOM.span({className: 'username js-action-profile-name'}, 85 | React.DOM.s({}, '@'), 86 | React.DOM.b({}, user.screen_name) 87 | ) 88 | ), 89 | React.DOM.small({className: 'time'}, 90 | React.DOM.a( 91 | { 92 | className: 'tweet-timestamp', 93 | title: originalTweet.created_at, 94 | href: '/statuses/'+t.id_str, 95 | target: '_blank' 96 | }, 97 | React.DOM.span({className: '_timestamp'}, originalTweet.created_at) 98 | ) 99 | ) 100 | ), 101 | React.DOM.p({className: 'TweetTextSize TweetTextSize--16px tweet-text'}, originalTweet.text) 102 | ) 103 | ) 104 | ) 105 | })) 106 | ) 107 | ), 108 | React.DOM.div({className: 'Grid-cell u-size1of3'}) 109 | ) 110 | ) 111 | ) 112 | ) 113 | ); 114 | } 115 | }); 116 | 117 | export = TweetList; -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/TweetsPerDayEstimate.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | interface TweetsPerDayEstimateProps{ 4 | addonUserAlreadyFollowingVisitedUser: boolean 5 | estimate: number 6 | } 7 | 8 | const TweetsPerDayEstimate = React.createClass({ 9 | 10 | render: function(){ 11 | const props : TweetsPerDayEstimateProps = this.props; 12 | 13 | console.log('estimate', props.estimate); 14 | 15 | return React.DOM.section( 16 | {className: 'tweets-per-day-estimate'}, 17 | props.addonUserAlreadyFollowingVisitedUser ? 18 | 'Following this account contributes ~'+ props.estimate.toFixed(2) + ' tweets per day to your timeline' : 19 | 'Following this account would add ~'+ props.estimate.toFixed(2) + ' tweets per day to your timeline' 20 | ); 21 | } 22 | }); 23 | 24 | export = TweetsPerDayEstimate; -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/TwitterAssistant.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | 'use strict'; 5 | 6 | import Histogram = require('./Histogram'); 7 | import TimelineComposition = require('./TimelineComposition'); 8 | import TwitterAssistantTopInfo = require('./TwitterAssistantTopInfo'); 9 | import HumansAreNotMetricsReminder = require('./HumansAreNotMetricsReminder'); 10 | import TweetsPerDayEstimate = require('./TweetsPerDayEstimate'); 11 | import DetailList = require('./DetailList'); 12 | import WordMass = require('./WordMass'); 13 | import GeneratedEngagement = require('./GeneratedEngagement'); 14 | 15 | import TweetMine = require('../TweetMine'); 16 | 17 | 18 | const ONE_DAY = 24*60*60*1000; // ms 19 | 20 | interface TwitterAssistantProps{ 21 | tweetMine: any //for now. TODO create TweetMine interface 22 | displayDayCount: number 23 | users: Map 24 | askUsers: (userIds: TwitterUserId[]) => void; 25 | addonUserAlreadyFollowingVisitedUser: boolean 26 | visitedUserIsAddonUser: boolean, 27 | showTweetList: (tweets: TwitterAPITweet[], title: string) => void 28 | } 29 | 30 | 31 | var TwitterAssistant = React.createClass({ 32 | getInitialState: function(){ 33 | return { 34 | details: undefined, 35 | class: undefined 36 | }; 37 | }, 38 | 39 | render: function(){ 40 | const props: TwitterAssistantProps = this.props, 41 | state = this.state; 42 | 43 | const tweetMine = props.tweetMine, 44 | users = props.users, 45 | askUsers = props.askUsers; 46 | 47 | if(!tweetMine){ 48 | return React.DOM.div({className: 'TA-main-container WhoToFollow is-visible'}, [ 49 | React.DOM.header({className: 'TA-header WhoToFollow-header'}, [ 50 | React.DOM.h3({className: 'TA-title WhoToFollow-title'}, "Twitter Assistant"), 51 | React.DOM.a({ 52 | href: "mailto:bruant.d+ta@gmail.com", 53 | title: "The addon author is here to help out!" 54 | }, 'Help') 55 | ]), 56 | React.DOM.p({}, 'Patience is your best ally against network latency... ⟳') 57 | ]); 58 | } 59 | else{ 60 | const oldestTweet = tweetMine.getOldestTweet(); 61 | const daysSinceOldestTweet = Math.round( (Date.now() - (new Date(oldestTweet.created_at)).getTime())/ONE_DAY ); 62 | 63 | /*var ownTweets = tweetMine.getOwnTweets(); 64 | console.log(ownTweets.map(tweet => { 65 | return { 66 | text: tweet.text, 67 | rt: tweet.retweet_count 68 | }; 69 | }));*/ 70 | 71 | const estimate = tweetMine.getTweetsThatWouldBeSeenIfAddonUserFollowedVisitedUser().length/daysSinceOldestTweet; 72 | 73 | return React.DOM.div({className: 'TA-main-container WhoToFollow is-visible'}, [ 74 | 75 | React.DOM.header({className: 'TA-header WhoToFollow-header'}, [ 76 | React.DOM.h3({className: 'TA-title WhoToFollow-title'}, "Twitter Assistant"), 77 | ' · ', 78 | React.DOM.a({ 79 | href: "mailto:bruant.d+ta@gmail.com", 80 | title: "The addon author is here to help out!" 81 | }, 'Help') 82 | ]), 83 | 84 | TwitterAssistantTopInfo({ 85 | nbDays: daysSinceOldestTweet, 86 | tweetsConsidered: tweetMine.length 87 | }), 88 | 89 | props.visitedUserIsAddonUser ? undefined : TweetsPerDayEstimate({ 90 | addonUserAlreadyFollowingVisitedUser: props.addonUserAlreadyFollowingVisitedUser, 91 | estimate: estimate 92 | }), 93 | 94 | Histogram({ 95 | tweetMine: tweetMine, 96 | histogramSize: props.displayDayCount 97 | }), 98 | 99 | 100 | React.DOM.div({className: "TA-period"}, [ 101 | React.DOM.div({className: "TA-period-from"}, props.displayDayCount+' days ago'), 102 | React.DOM.div({className: "TA-period-to"}, 'today'), 103 | ]), 104 | 105 | React.DOM.div({className: "TA-section-title"}, 'Timeline Composition'), 106 | 107 | TimelineComposition({ 108 | tweetMine: tweetMine, 109 | users : users, 110 | askMissingUsers : askUsers, 111 | showDetails: (fragmentDetails: any) => { 112 | const details = fragmentDetails.details; 113 | const className = fragmentDetails.class; 114 | 115 | console.log('show details', state.details, details); 116 | 117 | this.setState(state.class === className ? {details: undefined, class: undefined} : fragmentDetails); 118 | }, 119 | showTweetList: props.showTweetList 120 | }), 121 | 122 | state.details ? DetailList({details: state.details}) : undefined, 123 | 124 | WordMass({ 125 | wordToTweetsMap: tweetMine.getWordMap(), 126 | showTweetList: props.showTweetList 127 | }), 128 | 129 | /*React.DOM.div({className: "TA-section-title"}, 'Generated Engagement'), 130 | 131 | GeneratedEngagement({ 132 | tweetMine: tweetMine 133 | }),*/ 134 | 135 | HumansAreNotMetricsReminder() 136 | 137 | ]); 138 | } 139 | } 140 | 141 | }); 142 | 143 | 144 | export = TwitterAssistant; 145 | 146 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/TwitterAssistantTopInfo.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | interface TwitterAssistantTopInfoProps{ 4 | nbDays: number 5 | tweetsConsidered: number 6 | } 7 | 8 | const TwitterAssistantTopInfo = React.createClass({ 9 | 10 | render: function(){ 11 | const props : TwitterAssistantTopInfoProps = this.props; 12 | 13 | return React.DOM.section({className: 'TA-section-title'}, [ 14 | // TODO make props.nbDays a button with className="TA-graduation btn-link" 15 | 'Last '+props.nbDays+' days activity: '+props.tweetsConsidered+' tweets' 16 | ]); 17 | } 18 | }); 19 | 20 | export = TwitterAssistantTopInfo; 21 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/WordMass.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | interface WordMassProps{ 5 | wordToTweetsMap: Map 6 | showTweetList: (tweets: TwitterAPITweet[], title: string) => void 7 | } 8 | 9 | console.log('WordTweetTreeMap'); 10 | 11 | const WordMass = React.createClass({ 12 | 13 | render: function(){ 14 | const props = this.props; 15 | const wordToTweetsMap = props.wordToTweetsMap; 16 | 17 | const CONSIDERED_WORDS = 30; 18 | 19 | //console.log('wordToTweetsMap', wordToTweetsMap); 20 | const wordToTweetsEntries: Array<{word: string, tweets: TwitterAPITweet[]}> = []; 21 | wordToTweetsMap.forEach((tweets:TwitterAPITweet[], word: string) => { 22 | if(tweets.length >= 2) 23 | wordToTweetsEntries.push({tweets, word}) 24 | }) 25 | 26 | const sortedEntries: Array<{word: string, tweets:TwitterAPITweet[]}> = wordToTweetsEntries 27 | .sort(({word: w1, tweets: tweets1}, {word: w2, tweets: tweets2}) => tweets2.length - tweets1.length); 28 | const limitedEntries = sortedEntries.slice(0, CONSIDERED_WORDS); 29 | 30 | //const lengthSum = limitedEntries.reduce((acc, [w, tweets]) => {return acc + tweets.length}, 0); 31 | //console.log("limitedEntries", limitedEntries.map(({tweets}) => tweets.length)) 32 | 33 | const widthAdjustment = 80/(limitedEntries[0] && limitedEntries[0].tweets.length || 1); 34 | 35 | 36 | return React.DOM.ol({className: 'word-mass'}, limitedEntries.map(({word, tweets}) => { 37 | 38 | var width = tweets.length*widthAdjustment; 39 | 40 | return React.DOM.li( 41 | { 42 | title: word 43 | }, 44 | React.DOM.div( 45 | { 46 | onClick: e => { 47 | props.showTweetList(tweets, "Tweets with the word '"+word+"'"); 48 | } 49 | }, 50 | React.DOM.span({ 51 | className: "proportion", 52 | style: { 53 | width: width+'%', 54 | } 55 | }), 56 | React.DOM.span({className: 'word'}, word) 57 | ) 58 | 59 | 60 | ); 61 | 62 | 63 | })); 64 | } 65 | 66 | }); 67 | 68 | export = WordMass; -------------------------------------------------------------------------------- /firefox/data/metrics-integration/components/makeTimelineCompositionChildren.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function makeTimelineCompositionChildren(values: any, onDetailViewChange: any){ 4 | 5 | return values.map((v:any) => { 6 | const clickable = !!v.details; 7 | 8 | return React.DOM.div( { 9 | className: [ 10 | v.class, 11 | clickable ? 'TA-trigger' : '' 12 | ].filter(s => !!s).join(' '), 13 | title: v.title, 14 | style: { 15 | width: v.percent.toFixed(1)+'%' 16 | }, 17 | onClick: !clickable ? undefined : () => { 18 | onDetailViewChange({ 19 | class: v.class, 20 | details: v.details 21 | }); 22 | } 23 | }); 24 | }); 25 | } 26 | 27 | export = makeTimelineCompositionChildren; -------------------------------------------------------------------------------- /firefox/data/metrics-integration/getRetweetOriginalTweet.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | extract the the original tweet out of a retweet 5 | */ 6 | function getOriginalTweet(rt: TwitterAPITweet){ 7 | let ret = rt; 8 | 9 | while(Object(ret.retweeted_status) === ret.retweeted_status) 10 | ret = ret.retweeted_status; 11 | 12 | return ret; 13 | } 14 | 15 | export = getOriginalTweet -------------------------------------------------------------------------------- /firefox/data/metrics-integration/getWhosBeingConversedWith.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | t is a tweet that is a conversation with someone. 5 | This function return who... or at least does its best 6 | */ 7 | function getWhosBeingConversedWith(t: TwitterAPITweet){ 8 | let userId : TwitterUserId; 9 | // for now, only keep the first mentionned user 10 | 11 | // getting in_reply_to_user_id_str instead of looking inside entities because entities 12 | // is empty if the user conversed to recently changed of screen_name 13 | // (noticed it thanks to @angelinamagnum => @hopefulcyborg) 14 | // TODO : skip these cases 15 | 16 | if(t.in_reply_to_user_id_str === t.user.id_str){ 17 | // If A replies to B and A replies to their own reply as a followup, in_reply_to_user_id_str 18 | // refers to B in the first reply, A in the second (which isn't the intention) 19 | 20 | if(Array.isArray(t.entities.user_mentions) && t.entities.user_mentions.length >= 1){ 21 | // <= 1 because sometimes people do ".@DavidBruant blabla bla" to make the reply public 22 | const mentionedUser = t.entities.user_mentions.find(um => um.indices[0] <= 1); 23 | userId = mentionedUser && mentionedUser.id_str; 24 | } 25 | } 26 | else{ 27 | userId = t.in_reply_to_user_id_str; 28 | } 29 | 30 | return userId; 31 | } 32 | 33 | export = getWhosBeingConversedWith -------------------------------------------------------------------------------- /firefox/data/metrics-integration/main.css: -------------------------------------------------------------------------------- 1 | .TA-tweets-list { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | z-index: 1000; 8 | padding: 5% 0; 9 | background: rgba(0, 0, 0, 0.75); 10 | 11 | /* += David Bruant */ 12 | overflow: auto; 13 | } 14 | 15 | .TA-tweets-list .TA-tweet-item-inner { 16 | background-color: none !important; 17 | cursor: auto !important 18 | } 19 | 20 | .TA-main-container * { 21 | box-sizing: border-box 22 | } 23 | 24 | .TA-main-container .TA-trigger { 25 | cursor: pointer 26 | } 27 | 28 | .TA-main-container .TA-section-title { 29 | margin: 1.5em 0 .5em; 30 | font-size: 13px; 31 | color: #292f33; 32 | font-weight: bold 33 | } 34 | 35 | .TA-main-container .TA-graduation { 36 | font-weight: bold; 37 | cursor: ew-resize 38 | } 39 | 40 | .TA-main-container .TA-histogram { 41 | display: flex; 42 | height: 100px; 43 | flex-flow: row nowrap; 44 | align-items: stretch 45 | } 46 | 47 | .TA-main-container .TA-histogram .TA-bar { 48 | display: flex; 49 | height: 100%; 50 | overflow: visible; 51 | flex: 1; 52 | flex-flow: column nowrap; 53 | justify-content: flex-end; 54 | align-items: stretch 55 | } 56 | 57 | .TA-main-container .TA-histogram .TA-bar[data-value] { 58 | position: relative; 59 | z-index: 0 60 | } 61 | 62 | .TA-main-container .TA-histogram .TA-bar[data-value]::after { 63 | position: absolute; 64 | top: calc(100% + .3em); 65 | left: 50%; 66 | padding: .1em .2em; 67 | transform: translateX(-50%); 68 | opacity: 0; 69 | content: attr(data-value) 70 | } 71 | 72 | .TA-main-container .TA-histogram .TA-bar[data-value]:hover { 73 | z-index: 1; 74 | box-shadow: 0 0 0 1px #292f33 75 | } 76 | 77 | .TA-main-container .TA-histogram .TA-bar[data-value]:hover::after { 78 | opacity: 1 79 | } 80 | 81 | .TA-main-container .TA-activity .TA-bar[data-value]::after { 82 | background: #e5eaeb; 83 | font-size: 10px; 84 | color: #292f33 85 | } 86 | 87 | .TA-main-container .TA-tweets { 88 | background: #e5eaeb 89 | } 90 | 91 | .TA-main-container .TA-links { 92 | background: #ffb463 93 | } 94 | 95 | .TA-main-container .TA-medias { 96 | background: #ffed56 97 | } 98 | 99 | .TA-main-container .TA-replies { 100 | background: #6cefda 101 | } 102 | 103 | .TA-main-container .TA-retweets { 104 | background: #58a5ce 105 | } 106 | 107 | .TA-main-container .TA-period { 108 | display: flex; 109 | flex-flow: row nowrap; 110 | align-items: stretch 111 | } 112 | 113 | .TA-main-container .TA-period-from, 114 | .TA-main-container .TA-period-to { 115 | flex: 1; 116 | font-size: 13px; 117 | color: #8899a6 118 | } 119 | 120 | .TA-main-container .TA-period-from { 121 | text-align: left 122 | } 123 | 124 | .TA-main-container .TA-period-to { 125 | text-align: right 126 | } 127 | 128 | .TA-main-container .TA-composition { 129 | display: flex; 130 | width: 100%; 131 | height: 20px; 132 | flex-flow: row nowrap; 133 | align-items: stretch 134 | } 135 | 136 | .TA-main-container .TA-composition div { 137 | position: relative; 138 | display: flex; 139 | overflow: hidden 140 | } 141 | 142 | .TA-main-container .TA-composition div::before { 143 | margin: auto; 144 | color: #fff 145 | } 146 | 147 | .TA-main-container .TA-composition .TA-tweets::before { 148 | content: "\f029" 149 | } 150 | 151 | .TA-main-container .TA-composition .TA-links::before { 152 | content: "\f098" 153 | } 154 | 155 | .TA-main-container .TA-composition .TA-medias::before { 156 | content: "\f027" 157 | } 158 | 159 | .TA-main-container .TA-composition .TA-replies::before { 160 | content: "\f151" 161 | } 162 | 163 | .TA-main-container .TA-composition .TA-retweets::before { 164 | content: "\f152" 165 | } 166 | 167 | .TA-main-container .TA-composition-details { 168 | position: relative; 169 | max-height: 0; 170 | margin: 0 -15px; 171 | overflow: hidden; 172 | opacity: 0; 173 | transition: all .3s ease-in-out 174 | } 175 | 176 | .TA-main-container .TA-composition-details.TA-active { 177 | max-height: 530px; 178 | padding-top: 1em; 179 | opacity: 1 180 | } 181 | 182 | .TA-main-container .TA-composition-details.TA-active .TA-composition-details-arrow { 183 | top: 1em 184 | } 185 | 186 | .TA-main-container .TA-composition-details-arrow { 187 | position: absolute; 188 | top: 0; 189 | left: 15px; 190 | width: 10px; 191 | height: 10px; 192 | border: solid #d1dce3; 193 | border-width: 1px 0 0 1px; 194 | background: #fff; 195 | transform: rotate(45deg) translate(-50%, -50%); 196 | transform-origin: left top; 197 | content: ""; 198 | transition: all .3s ease-in-out 199 | } 200 | 201 | .TA-main-container .TA-composition-details-inner { 202 | padding: 5px 15px; 203 | margin: 0; 204 | border: solid #e1e8ed; 205 | border-width: 1px 0; 206 | list-style: none 207 | } 208 | 209 | .TA-main-container .TA-account { 210 | position: relative; 211 | min-height: 35px; 212 | margin: 10px 0 213 | } 214 | 215 | .TA-main-container .TA-account .TA-account-count { 216 | position: absolute; 217 | top: 0; 218 | bottom: 0; 219 | right: 0; 220 | z-index: 0; 221 | background: #e5eaeb 222 | } 223 | 224 | .TA-main-container .TA-account .TA-account-count::before { 225 | position: absolute; 226 | bottom: 0; 227 | right: 2px; 228 | font-size: 14px; 229 | color: #8899a6; 230 | content: attr(data-count) 231 | } 232 | 233 | .TA-main-container .TA-account .TA-account-inner { 234 | position: relative; 235 | z-index: 1 236 | } 237 | 238 | .TA-main-container .TA-account .TA-account-content { 239 | margin-left: 45px 240 | } 241 | 242 | .TA-main-container .TA-account .TA-avatar { 243 | width: 35px; 244 | height: 35px 245 | } 246 | 247 | .TA-main-container .TA-account .TA-fullname, 248 | .TA-main-container .TA-account .TA-username { 249 | display: block; 250 | overflow: hidden; 251 | white-space: nowrap; 252 | text-overflow: ellipsis 253 | } 254 | 255 | .TA-main-container .TA-account:nth-child(n+9) { 256 | display: inline-block; 257 | min-height: initial; 258 | margin: 5px 2px 5px 0; 259 | color: #8899a6; 260 | text-decoration: none; 261 | vertical-align: top 262 | } 263 | 264 | .TA-main-container .TA-account:nth-child(n+9):hover .TA-account-count, 265 | .TA-main-container .TA-account:nth-child(n+9):focus .TA-account-count { 266 | text-decoration: underline 267 | } 268 | 269 | .TA-main-container .TA-account:nth-child(n+9) .TA-account-count { 270 | top: auto; 271 | left: 0; 272 | width: auto !important; 273 | overflow: hidden; 274 | background: none; 275 | line-height: 1; 276 | text-align: right 277 | } 278 | 279 | .TA-main-container .TA-account:nth-child(n+9) .TA-account-count::before { 280 | position: static 281 | } 282 | 283 | .TA-main-container .TA-account:nth-child(n+9) .TA-account-content { 284 | margin: 0 285 | } 286 | 287 | .TA-main-container .TA-account:nth-child(n+9) .TA-account-group { 288 | padding-right: 18px 289 | } 290 | 291 | .TA-main-container .TA-account:nth-child(n+9) .TA-avatar { 292 | position: static; 293 | display: block; 294 | width: 20px; 295 | height: 20px 296 | } 297 | 298 | .TA-main-container .TA-account:nth-child(n+9) .TA-account-group-inner { 299 | display: none 300 | } 301 | 302 | .TA-main-container .TA-account:last-child { 303 | margin-bottom: 10px 304 | } 305 | 306 | .TA-main-container .TA-engagement { 307 | display: flex; 308 | width: 100%; 309 | height: 40px; 310 | flex-flow: column nowrap; 311 | align-items: stretch; 312 | background: #e5eaeb 313 | } 314 | 315 | .TA-main-container .TA-engagement .TA-rt, 316 | .TA-main-container .TA-engagement .TA-fav { 317 | flex: 1; 318 | display: flex; 319 | overflow: hidden; 320 | background: #52e08b 321 | } 322 | 323 | .TA-main-container .TA-engagement .TA-rt::before, 324 | .TA-main-container .TA-engagement .TA-fav::before { 325 | margin: auto auto auto 2px; 326 | color: #fff 327 | } 328 | 329 | .TA-main-container .TA-engagement .TA-rt::before { 330 | content: "\f152" 331 | } 332 | 333 | .TA-main-container .TA-engagement .TA-fav::before { 334 | content: "\f147" 335 | } 336 | 337 | .TA-main-container .TA-language { 338 | display: flex; 339 | width: 100%; 340 | height: 20px; 341 | flex-flow: row nowrap; 342 | align-items: stretch; 343 | background: #e5eaeb 344 | } 345 | 346 | .TA-main-container .TA-language div { 347 | padding-left: 2px; 348 | background: #52e08b; 349 | line-height: 20px; 350 | color: #fff; 351 | font-weight: bold; 352 | text-transform: uppercase 353 | } 354 | 355 | .TA-main-container .TA-language div:nth-child(even) { 356 | background: #1fad58 357 | } 358 | 359 | .TA-main-container .TA-reminder { 360 | margin: 1em -15px 0; 361 | padding: 15px 15px 0; 362 | border-top: 1px solid #e1e8ed; 363 | color: #8899a6 364 | } 365 | -------------------------------------------------------------------------------- /firefox/data/metrics-integration/main.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | 7 | // declare var React : React.Exports; 8 | 'use strict'; 9 | 10 | declare var self : { 11 | // TODO document event interface 12 | port: JetpackPort 13 | } 14 | 15 | 16 | import TwitterAssistant = require('./components/TwitterAssistant'); 17 | import TweetList = require('./components/TweetList'); 18 | import TweetMine = require('./TweetMine'); 19 | import stemByLang = require('./stem'); 20 | 21 | const ONE_DAY = 24*60*60*1000; // ms 22 | 23 | const RIGHT_PROFILE_SIDEBAR_SELECTOR = '.ProfileSidebar .SidebarCommonModules'; 24 | 25 | const twitterAssistantContainerP : Promise = (new Promise( resolve => { 26 | document.addEventListener('DOMContentLoaded', function listener(){ 27 | resolve(document); 28 | document.removeEventListener('DOMContentLoaded', listener); 29 | }); 30 | })).then(document => { 31 | // container on the twitter.com website 32 | let twitterContainer = document.body.querySelector(RIGHT_PROFILE_SIDEBAR_SELECTOR); 33 | // container of the addon panel 34 | const twitterAssistantContainer = document.createElement('div'); 35 | twitterAssistantContainer.classList.add('twitter-assistant-container'); 36 | twitterAssistantContainer.classList.add('TA-main-container'); 37 | twitterAssistantContainer.classList.add('module'); // from Twitter CSS 38 | 39 | if(twitterContainer){ 40 | twitterContainer.insertBefore(twitterAssistantContainer, twitterContainer.firstChild); 41 | } 42 | else{ 43 | // sometimes, Twitter changes its HTML structure. Need to display things somewhere anyway. 44 | twitterContainer = document.body; 45 | twitterContainer.appendChild(twitterAssistantContainer); 46 | } 47 | 48 | 49 | return twitterAssistantContainer; 50 | }); 51 | 52 | ( twitterAssistantContainerP).catch( (err: Error) => { 53 | console.error('twitterAssistantContainerP error', String(err)); 54 | }); 55 | 56 | 57 | 58 | /* 59 | various pieces of state 60 | */ 61 | 62 | function askUsers(userIds : TwitterUserId[]){ 63 | self.port.emit('ask-users', userIds); 64 | } 65 | 66 | let users = new Map(); 67 | let timeline : TwitterAPITweet[]; 68 | let visitedUser : TwitterAPIUser; 69 | let addonUserAndFriends : { 70 | user: TwitterAPIUser 71 | friendIds: Set 72 | }; 73 | let displayDayCount: number; 74 | let languages: Map; // give a value by default to get started 75 | 76 | /* tweets list*/ 77 | const tweetListContainer: HTMLElement = document.createElement('div'); 78 | tweetListContainer.classList.add('TA-tweets-list'); 79 | 80 | let tweetListState: { 81 | title: string 82 | tweets: TwitterAPITweet[] 83 | }; 84 | 85 | tweetListContainer.addEventListener('click', e => { 86 | if(!(e.target).matches('.TA-tweets-list .timeline *')){ 87 | tweetListContainer.remove(); 88 | tweetListState = undefined; 89 | } 90 | }); 91 | 92 | function renderTweetList(){ 93 | if(!tweetListState) 94 | return; 95 | 96 | document.body.appendChild(tweetListContainer); 97 | React.renderComponent( 98 | TweetList(Object.assign({askUsers: askUsers, users: users}, tweetListState)), 99 | tweetListContainer 100 | ) 101 | } 102 | 103 | 104 | 105 | function updateTwitterAssistant(){ 106 | let addonUserAlreadyFollowingVisitedUser: boolean; 107 | let visitedUserIsAddonUser: boolean; 108 | if(addonUserAndFriends && visitedUser){ 109 | addonUserAlreadyFollowingVisitedUser = addonUserAndFriends.friendIds.has(visitedUser.id_str); 110 | visitedUserIsAddonUser = addonUserAndFriends.user.id_str === visitedUser.id_str; 111 | } 112 | 113 | return twitterAssistantContainerP.then(twitterAssistantContainer => { 114 | 115 | React.renderComponent(TwitterAssistant({ 116 | tweetMine: timeline ? TweetMine( 117 | timeline, 118 | displayDayCount, 119 | visitedUser ? visitedUser.id_str : undefined, 120 | addonUserAndFriends ? addonUserAndFriends.user.id_str : undefined, 121 | addonUserAndFriends ? addonUserAndFriends.friendIds : undefined, 122 | languages 123 | ) : undefined, 124 | displayDayCount : displayDayCount, 125 | users: users, 126 | askUsers: askUsers, 127 | addonUserAlreadyFollowingVisitedUser: addonUserAlreadyFollowingVisitedUser, 128 | visitedUserIsAddonUser: visitedUserIsAddonUser, 129 | showTweetList: (tweets: TwitterAPITweet[], title: string) => { 130 | tweetListState = { 131 | tweets: tweets, 132 | title: title 133 | }; 134 | renderTweetList(); 135 | } 136 | }), twitterAssistantContainer); 137 | 138 | renderTweetList(); 139 | 140 | }).catch(err => { 141 | console.error('metrics integration error', String(err)); 142 | throw err; 143 | }); 144 | } 145 | 146 | self.port.on('answer-users', (receivedUsers: TwitterAPIUser[]) => { 147 | receivedUsers.forEach(u => users.set(u.id_str, u)); 148 | 149 | updateTwitterAssistant(); 150 | }); 151 | 152 | self.port.on('visited-user-details', u => { 153 | visitedUser = u; 154 | users.set(visitedUser.id_str, visitedUser); 155 | 156 | updateTwitterAssistant(); 157 | }); 158 | 159 | self.port.on('addon-user-and-friends', _addonUserAndFriends => { 160 | console.log("received addon user infos in content", _addonUserAndFriends); 161 | 162 | addonUserAndFriends = { 163 | user: _addonUserAndFriends.user, 164 | friendIds: new Set(_addonUserAndFriends.friendIds) 165 | } 166 | 167 | updateTwitterAssistant(); 168 | }); 169 | 170 | self.port.on('twitter-user-data', partialTimeline => { 171 | timeline = timeline ? timeline.concat(partialTimeline) : partialTimeline; 172 | 173 | updateTwitterAssistant(); 174 | }); 175 | 176 | self.port.on('display-days-count', _displayDayCount => { 177 | displayDayCount = _displayDayCount; 178 | 179 | updateTwitterAssistant(); 180 | }); 181 | 182 | function matchTwitterLanguagesAndSupportedLanguages(languages: {code: string, name: string}[]){ 183 | languages.forEach(({code, name}) => { 184 | if(!stemByLang.has(code)){ 185 | console.warn('language', code, name, 'not supported by Twitter Assistant'); 186 | } 187 | }); 188 | } 189 | 190 | /*self.port.on('languages', (_languages: {code: string, name: string}[]) => { 191 | console.log('content-side languages', languages); 192 | languages = new Map(); 193 | 194 | _languages.forEach(l => languages.set(l.code, l)); 195 | 196 | updateTwitterAssistant(); 197 | 198 | // do the matching in the content process because of build-time constraints. 199 | // TODO move this at the addon level and do it only once (not once per tab) 200 | matchTwitterLanguagesAndSupportedLanguages(_languages); 201 | });*/ 202 | 203 | // Initial "empty" rendering ASAP so the user knows Twitter Assistant exists 204 | updateTwitterAssistant(); 205 | -------------------------------------------------------------------------------- /firefox/data/panel/components/AutomatedAppCreation.js: -------------------------------------------------------------------------------- 1 | (function(exports){ 2 | 'use strict'; 3 | 4 | 5 | exports.AutomatedAppCreation = React.createClass({ 6 | getInitialState: function(){ 7 | return { 8 | pendingAppCreation: false 9 | }; 10 | }, 11 | 12 | 13 | render: function(){ 14 | /* 15 | { 16 | automateTwitterAppCreation: () => void, 17 | switchToManual: () => void 18 | } 19 | */ 20 | const data = this.props; 21 | const state = this.state; 22 | 23 | return React.DOM.div({}, [ 24 | // Why the user is being bothered 25 | React.DOM.p({}, [ 26 | "You need a 'Twitter App' to use Twitter Assistant." 27 | ]), 28 | 29 | // Quick action 30 | React.DOM.div({className: 'app-creation' + (state.pendingAppCreation ? ' pending' : '')}, React.DOM.button({ 31 | className: 'automatic', 32 | disabled: state.pendingAppCreation ? 'disabled' : null, 33 | onClick: e => { 34 | data.automateTwitterAppCreation(); 35 | this.setState({ 36 | pendingAppCreation: true 37 | }); 38 | } 39 | }, "Create an app automatically")), 40 | 41 | // Explain what's about to happen 42 | React.DOM.div({/*className: "instructions"*/}, [ 43 | React.DOM.p({}, "A tab will open in the background. On your behalf, it's going to:"), 44 | React.DOM.ol({}, [ 45 | React.DOM.li({}, React.DOM.strong({}, "Create a Twitter app")), 46 | React.DOM.li({}, [ 47 | React.DOM.strong({}, "Accept"), 48 | " the ", 49 | React.DOM.a({ 50 | target: "_blank", 51 | href: "https://dev.twitter.com/terms/api-terms" 52 | }, 'Twitter API terms of services ("Developer rules of the road")') 53 | ]), 54 | React.DOM.li({}, [ 55 | "When the app is created, the addon will ", 56 | React.DOM.strong({}, 'fetch the "API key" and "API secret"'), 57 | ' from the "API keys" tab.' 58 | ]) 59 | ]) 60 | ]), 61 | 62 | // Alternatives 63 | React.DOM.p({className: 'discrete'}, [ 64 | "You can also ", 65 | React.DOM.a({ 66 | target: '_blank', 67 | href: 'http://iag.me/socialmedia/how-to-create-a-twitter-app-in-8-easy-steps/', 68 | onClick: data.switchToManual 69 | }, 'create an app manually "in 8 easy steps"'), 70 | " or maybe ", 71 | React.DOM.button({ 72 | className: "existing-app", 73 | onClick: data.switchToManual 74 | }, "you already have one") 75 | ]) 76 | ]); 77 | } 78 | }); 79 | 80 | })(this); 81 | -------------------------------------------------------------------------------- /firefox/data/panel/components/ManualAppCreation.js: -------------------------------------------------------------------------------- 1 | (function(exports){ 2 | 'use strict'; 3 | 4 | 5 | exports.ManualAppCreation = React.createClass({ 6 | 7 | render: function(){ 8 | /* 9 | { 10 | testCredentials({key, secret}) => void, 11 | credentials: {key, secret}, 12 | switchToAutomated: () => void 13 | } 14 | */ 15 | const data = this.props; 16 | 17 | const children = [ 18 | React.DOM.p({}, 'Enter API key and secret'), 19 | TwitterAPICredentialsForm(data) 20 | ]; 21 | 22 | if(!data.credentials){ 23 | children.push(React.DOM.p({className: 'discrete'}, [ 24 | "Try to make a Twitter App ", 25 | React.DOM.button({ 26 | className: "existing-app", 27 | onClick: data.switchToAutomated 28 | }, "automatically"), 29 | " instead" 30 | ])) 31 | } 32 | 33 | return React.DOM.div({}, children); 34 | } 35 | }); 36 | 37 | })(this); 38 | -------------------------------------------------------------------------------- /firefox/data/panel/components/TwitterAPICredentialsForm.js: -------------------------------------------------------------------------------- 1 | (function(exports){ 2 | 'use strict'; 3 | 4 | 5 | exports.TwitterAPICredentialsForm = React.createClass({ 6 | 7 | render: function(){ 8 | /* 9 | { 10 | credentials: {key, secret}, 11 | testCredentials: {key, secret} => void, 12 | forgetCredentials: () => void 13 | } 14 | */ 15 | const data = this.props; 16 | 17 | /* 18 |
19 | 20 | 21 | 22 |
23 | */ 24 | 25 | return React.DOM.form({ 26 | className: 'api-credentials', 27 | onSubmit: e => { 28 | e.preventDefault(); 29 | 30 | var key = this.refs.key.getDOMNode().value, 31 | secret = this.refs.secret.getDOMNode().value; 32 | 33 | console.log('form submit', key, secret); 34 | 35 | if(!key || !secret || key.length <= 1 || secret.length <= 1) 36 | return; // ignore 37 | 38 | data.testCredentials({key: key, secret: secret}); 39 | } 40 | }, [ 41 | React.DOM.label({}, [ 42 | React.DOM.span({}, "API key"), 43 | React.DOM.input({ 44 | className: 'key', 45 | type: 'text', 46 | size: '30', 47 | ref: 'key', 48 | defaultValue: data.credentials && data.credentials.key 49 | }) 50 | ]), 51 | React.DOM.label({}, [ 52 | React.DOM.span({}, "API secret"), 53 | React.DOM.input({ 54 | className: 'secret', 55 | type: 'text', 56 | size: '30', 57 | ref: 'secret', 58 | defaultValue: data.credentials && data.credentials.secret 59 | }) 60 | ]), 61 | React.DOM.button({type: "submit"}, 'OK')/*, 62 | React.DOM.p({ 63 | style: { 64 | display: 'inline-block', 65 | margin: 0, 66 | "vertical-align": 'middle' 67 | } 68 | }, React.DOM.button({ 69 | type: 'button', 70 | onClick: e => { 71 | //console.log('clear click', this.refs.key.getDOMNode().value) 72 | this.refs.key.getDOMNode().value = '', 73 | this.refs.secret.getDOMNode().value = ''; 74 | 75 | data.forgetCredentials(); 76 | } 77 | }, 'forget these credentials'))*/ 78 | ]); 79 | } 80 | }); 81 | 82 | })(this); 83 | -------------------------------------------------------------------------------- /firefox/data/panel/components/TwitterAssistantPanel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | { 5 | loggedUser: string 6 | attemptLogin: () => {} 7 | changeTwitterAssistantServerDomain: 8 | } 9 | */ 10 | 11 | const DEFAULT_TWITTER_ASSISTANT_SERVER_ORIGIN = 'http://localhost:3737/'; 12 | 13 | (function(global){ 14 | global.TwitterAssistantPanel = React.createClass({ 15 | displayName: 'TwitterAssistantPanel', 16 | 17 | render: function(){ 18 | const props = this.props; 19 | 20 | if(props.errorMessage) 21 | console.error(props.errorMessage); 22 | 23 | return React.DOM.div({}, 24 | React.DOM.h1({}, 'Twitter Assistant'), 25 | React.DOM.h2({}, 'Hello' + (props.loggedUser ? ' @'+props.loggedUser.screen_name : '') + '!'), 26 | !props.loggedUser ? React.DOM.button({ 27 | onClick(e){ 28 | props.signinWithTwitter(); 29 | } 30 | }, 'Sign in with Twitter') : "You're all set :-) Look at someone's profile on Twitter!", 31 | 32 | props.errorMessage ? React.DOM.section({className: 'error'}, props.errorMessage) : undefined, 33 | 34 | React.DOM.footer({}, 35 | React.DOM.a( 36 | { 37 | href: "mailto:bruant.d+ta@gmail.com", 38 | title: "The addon author is here to help out!" 39 | }, 40 | 'Help' 41 | ) 42 | ) 43 | ); 44 | } 45 | }); 46 | })(this); 47 | -------------------------------------------------------------------------------- /firefox/data/panel/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const data = { 4 | //automateTwitterAppCreation: e => self.addon.port.emit('automate-twitter-app-creation'), 5 | //testCredentials: credentials => self.addon.port.emit('test-credentials', credentials) 6 | signinWithTwitter(){ 7 | self.addon.port.emit('sign-in-with-twitter') 8 | } 9 | }; 10 | 11 | function updatePanel(){ 12 | React.renderComponent(TwitterAssistantPanel(data), document.body); 13 | } 14 | 15 | 16 | self.addon.port.on('logged-in-user', user => { 17 | data.loggedUser = user; 18 | delete data.errorMessage; 19 | updatePanel(); 20 | }); 21 | 22 | 23 | self.addon.port.on('error-request-token', error => { 24 | console.log('panel receiving error', error) 25 | data.errorMessage = 26 | "Error trying to reach Twitter (via "+error.twitterAssistantServerOrigin+") "+String(error.message); 27 | updatePanel(); 28 | }); 29 | 30 | 31 | 32 | 33 | updatePanel(); 34 | -------------------------------------------------------------------------------- /firefox/data/panel/mainPanel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | Twitter Assistant Panel 113 | 114 | 126 | 127 | 128 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /firefox/defs/ES6.d.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | interface String { 4 | /** Iterator */ 5 | // [Symbol.iterator] (): Iterator; 6 | 7 | /** 8 | * Returns a nonnegative integer Number less than 1114112 (0x110000) that is the code point 9 | * value of the UTF-16 encoded code point starting at the string element at position pos in 10 | * the String resulting from converting this object to a String. 11 | * If there is no element at that position, the result is undefined. 12 | * If a valid UTF-16 surrogate pair does not begin at pos, the result is the code unit at pos. 13 | */ 14 | codePointAt(pos: number): number; 15 | 16 | /** 17 | * Returns true if searchString appears as a substring of the result of converting this 18 | * object to a String, at one or more positions that are 19 | * greater than or equal to position; otherwise, returns false. 20 | * @param searchString search string 21 | * @param position If position is undefined, 0 is assumed, so as to search all of the String. 22 | */ 23 | contains(searchString: string, position?: number): boolean; 24 | 25 | /** 26 | * Returns true if the sequence of elements of searchString converted to a String is the 27 | * same as the corresponding elements of this object (converted to a String) starting at 28 | * endPosition – length(this). Otherwise returns false. 29 | */ 30 | endsWith(searchString: string, endPosition?: number): boolean; 31 | 32 | /** 33 | * Returns the String value result of normalizing the string into the normalization form 34 | * named by form as specified in Unicode Standard Annex #15, Unicode Normalization Forms. 35 | * @param form Applicable values: "NFC", "NFD", "NFKC", or "NFKD", If not specified default 36 | * is "NFC" 37 | */ 38 | normalize(form?: string): string; 39 | 40 | /** 41 | * Returns a String value that is made from count copies appended together. If count is 0, 42 | * T is the empty String is returned. 43 | * @param count number of copies to append 44 | */ 45 | repeat(count: number): string; 46 | 47 | /** 48 | * Returns true if the sequence of elements of searchString converted to a String is the 49 | * same as the corresponding elements of this object (converted to a String) starting at 50 | * position. Otherwise returns false. 51 | */ 52 | startsWith(searchString: string, position?: number): boolean; 53 | 54 | /** 55 | * Returns an HTML anchor element and sets the name attribute to the text value 56 | * @param name 57 | */ 58 | anchor(name: string): string; 59 | 60 | /** Returns a HTML element */ 61 | big(): string; 62 | 63 | /** Returns a HTML element */ 64 | blink(): string; 65 | 66 | /** Returns a HTML element */ 67 | bold(): string; 68 | 69 | /** Returns a HTML element */ 70 | fixed(): string 71 | 72 | /** Returns a HTML element and sets the color attribute value */ 73 | fontcolor(color: string): string 74 | 75 | /** Returns a HTML element and sets the size attribute value */ 76 | fontsize(size: number): string; 77 | 78 | /** Returns a HTML element and sets the size attribute value */ 79 | fontsize(size: string): string; 80 | 81 | /** Returns an HTML element */ 82 | italics(): string; 83 | 84 | /** Returns an HTML element and sets the href attribute value */ 85 | link(url: string): string; 86 | 87 | /** Returns a HTML element */ 88 | small(): string; 89 | 90 | /** Returns a HTML element */ 91 | strike(): string; 92 | 93 | /** Returns a HTML element */ 94 | sub(): string; 95 | 96 | /** Returns a HTML element */ 97 | sup(): string; 98 | } 99 | -------------------------------------------------------------------------------- /firefox/defs/OAuth.d.ts: -------------------------------------------------------------------------------- 1 | interface OAuthCredentials{ 2 | key: string 3 | secret: string 4 | } 5 | 6 | // specialized string 7 | interface AccessToken extends String{ 8 | __AccessToken : AccessToken 9 | } -------------------------------------------------------------------------------- /firefox/defs/SimpleTwitterObjects.d.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Simple representation of a tweet 4 | */ 5 | interface SimpleTweetId extends String{ 6 | __SimpleTweetId: SimpleTweetId 7 | } 8 | 9 | interface SimpleTweet{ 10 | id: SimpleTweetId 11 | text: string // actual text. Always. Not the crap returned by the Twitter API 12 | author: SimpleTwitterUserId 13 | retweet?: SimpleTweetId 14 | // mentions: maybe same things than 15 | created_at: Date 16 | } 17 | 18 | /* 19 | Simple representation of a Twitter User 20 | */ 21 | interface SimpleTwitterUserId extends String{ 22 | __SimpleTwitterUserId: SimpleTwitterUserId 23 | } 24 | 25 | interface SimpleTwitterUser{ 26 | id: SimpleTwitterUserId 27 | follows: SimpleTwitterUserId[] 28 | followers: SimpleTwitterUserId[] 29 | created_at: Date 30 | profile_picture_url: string 31 | } -------------------------------------------------------------------------------- /firefox/defs/TwitterAPI.d.ts: -------------------------------------------------------------------------------- 1 | 2 | interface TwitterAPI_I { 3 | /* 4 | https://dev.twitter.com/rest/reference/get/statuses/user_timeline 5 | endpoint: https://api.twitter.com/1.1/statuses/user_timeline.json 6 | 7 | maxId: the caller needs to substract 1 8 | https://dev.twitter.com/docs/working-with-timelines 9 | ... or not: 10 | "Environments where a Tweet ID cannot be represented as an integer with 64 bits of 11 | precision (such as JavaScript) should skip this step." 12 | */ 13 | getUserTimeline : (twittername: string, maxId?: TwitterTweetId) => Promise 14 | 15 | /* 16 | https://dev.twitter.com/rest/reference/get/search/tweets 17 | https://dev.twitter.com/docs/using-search 18 | endpoint: https://api.twitter.com/1.1/search/tweets.json 19 | */ 20 | search: (parameters : TwitterAPISearchParams) => Promise 21 | 22 | 23 | /* 24 | https://dev.twitter.com/rest/reference/get/users/lookup 25 | endpoint: https://api.twitter.com/1.1/users/lookup.json 26 | */ 27 | lookupUsersByIds: (user_ids: TwitterUserId[]) => Promise 28 | lookupUsersByScreenNames: (screen_names: string[]) => Promise 29 | 30 | /* 31 | https://dev.twitter.com/rest/reference/get/friends/ids 32 | endpoint : https://api.twitter.com/1.1/friends/ids.json 33 | */ 34 | getFriends: (id: TwitterUserId) => Promise<{ids: TwitterUserId[]}> 35 | 36 | /* 37 | https://dev.twitter.com/rest/reference/get/help/languages 38 | endpoint : https://api.twitter.com/1.1/help/languages.jsonavril 39 | */ 40 | getLanguages: () => Promise<{code: string, name: string}[]> 41 | } 42 | 43 | 44 | 45 | interface TwitterAPIUserTimelineOptions{ 46 | count?: number // between 0 and 200 47 | include_rts?: number // 0 or 1 48 | 'trim_user'?: string // 't' or nothing 49 | screen_name?: string 50 | max_id?: TwitterTweetId 51 | } 52 | 53 | 54 | interface TwitterAPIUserLookupOptions{ 55 | user_id?: string // 783214,6253282 56 | screen_name?: string // twitterapi,twitter 57 | include_entities : boolean 58 | } 59 | 60 | interface TwitterAPISearchParams{ 61 | q: { 62 | text: string // the string shouldn't be encoded 63 | from: string 64 | to: string 65 | '@': string 66 | since: Date 67 | until: Date 68 | filter: string 69 | } 70 | since_id: TwitterTweetId 71 | max_id: TwitterTweetId 72 | count: number 73 | result_type: string // 'mixed', 'recent', 'popular' 74 | lang: string 75 | geocode: string 76 | } 77 | 78 | 79 | 80 | interface TwitterAPIEntities{ 81 | urls: TwitterAPIURLEntity[] 82 | user_mentions: TwitterAPIUserMentionEntity[] 83 | media: TwitterAPIMediaEntity[] 84 | hashtags: TwitterAPIHashtagEntity[] 85 | } 86 | 87 | interface TwitterAPIUserMentionEntity{ 88 | id_str: TwitterUserId 89 | indices: number[] 90 | screen_name: string 91 | } 92 | 93 | interface TwitterAPIURLEntity{ 94 | url: string 95 | expanded_url: string 96 | indices: number[] 97 | } 98 | 99 | interface TwitterAPIMediaEntity{ 100 | url: string 101 | } 102 | 103 | interface TwitterAPIHashtagEntity{ 104 | text: string 105 | } 106 | 107 | 108 | 109 | interface TwitterTweetId extends String{ 110 | __TwitterTweetId : TwitterTweetId 111 | } 112 | 113 | 114 | 115 | interface TwitterAPITweet{ 116 | // id: number // purposefully commented so TypeScript warns about its use, because it shouldn't be used in JS 117 | id_str : TwitterTweetId 118 | 119 | created_at : string // Date-parseable string 120 | 121 | entities: TwitterAPIEntities 122 | user: TwitterAPIReducedUser // the 'trim_user' parameter will always be used 123 | text: string 124 | lang: string 125 | 126 | retweeted_status?: TwitterAPITweet 127 | retweet_count: number 128 | favorite_count: number 129 | 130 | in_reply_to_user_id_str: TwitterUserId 131 | } 132 | 133 | 134 | interface TwitterUserId extends String{ 135 | __TwitterUserId: TwitterUserId 136 | } 137 | 138 | /* 139 | Some API calls allow for a 'trim_user' parameter. When used, a reduced version of the user is used 140 | */ 141 | interface TwitterAPIReducedUser{ 142 | // 'id' purposefully omitted 143 | id_str: TwitterUserId 144 | } 145 | 146 | interface TwitterAPIUser extends TwitterAPIReducedUser{ 147 | screen_name: string 148 | name: string 149 | profile_image_url_https : string 150 | } 151 | 152 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-base64.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "sdk/base64" { 3 | export function encode(str: string): string 4 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-chrome.d.ts: -------------------------------------------------------------------------------- 1 | declare module "chrome" { 2 | export var Cu : ComponentsUtils; 3 | } 4 | 5 | interface ComponentsUtils{ 6 | import(path: string, to?: Object) : any 7 | import(path: "resource:///modules/devtools/shared/event-emitter.js", to?: Object) : EventEmitterExportObject 8 | } 9 | 10 | 11 | 12 | declare class EventEmitter{ 13 | on(eventName: string, listener:(e:any)=>void) : void 14 | once(eventName: string, listener:(e:any)=>void) : void 15 | off(eventName: string, listener:(e:any)=>void) : void 16 | emit(eventName: string, event:any) : void 17 | } 18 | 19 | interface EventEmitterConstructor{ 20 | decorate(o:any): void 21 | } 22 | 23 | interface EventEmitterExportObject{ 24 | EventEmitter: EventEmitterConstructor 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-content-worker.d.ts: -------------------------------------------------------------------------------- 1 | 2 | // https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/content_worker 3 | 4 | declare module "sdk/content/worker" { 5 | 6 | //import EventTargetModule = require("sdk/event/target"); 7 | //import JetpackPortModule = require("JetpackPort"); 8 | 9 | export class Worker implements JetpackEventTarget { 10 | constructor(options : WorkerOptions) 11 | port : JetpackPort 12 | 13 | on(eventName: string, listener:(e:any)=>void) : void 14 | once(eventName: string, listener:(e:any)=>void) : void 15 | off(eventName: string, listener:(e:any)=>void) : void 16 | emit(eventName: string, event:any) : void 17 | } 18 | 19 | export interface WorkerOptions extends WorkerDescription{ 20 | window: Window 21 | } 22 | 23 | export interface WorkerDescription{ 24 | contentScriptFile?: any // string | string[] in TypeScript 1.4 25 | contentScriptOptions?: Object 26 | onMessage?: (message: any) => void 27 | onError?: (message: Error) => void 28 | } 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-event-core.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/event/target"{ 2 | export class JetpackEventTarget implements JetpackEventTarget{ 3 | on(eventName: string, listener:(e:any)=>void) : void 4 | once(eventName: string, listener:(e:any)=>void) : void 5 | off(eventName: string, listener:(e:any)=>void) : void 6 | emit(eventName: string, event:any) : void 7 | } 8 | 9 | } 10 | 11 | interface JetpackEventTarget{ 12 | on(eventName: string, listener:(e:any)=>void) : void 13 | once(eventName: string, listener:(e:any)=>void) : void 14 | off(eventName: string, listener:(e:any)=>void) : void 15 | emit(eventName: string, event:any) : void 16 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-net-xhr.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "sdk/net/xhr" { 3 | export var XMLHttpRequest: { 4 | prototype: XMLHttpRequest; 5 | new(params?: JetpackXHRParams): XMLHttpRequest; 6 | } 7 | 8 | interface JetpackXHRParams{ 9 | mozAnon: boolean 10 | } 11 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-pagemod.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "sdk/page-mod" { 3 | import EventTargetModule = require("sdk/event/target"); 4 | //import JetpackPortModule = require("JetpackPort"); 5 | 6 | export class PageMod extends EventTargetModule.JetpackEventTarget{ 7 | constructor(params: PageModParams) 8 | //port: JetpackPortModule.JetpackPort 9 | } 10 | 11 | export interface PageModParams{ 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-panel.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "sdk/panel" { 3 | import ui = require("sdk/ui"); 4 | import EventTargetModule = require("sdk/event/target"); 5 | 6 | export class Panel implements JetpackEventTarget{ 7 | constructor(params: PanelParams) 8 | show: (params: ShowParams) => void 9 | hide: () => void 10 | port: JetpackPort 11 | 12 | // TODO document specific events 13 | on(eventName: string, listener:(e:any)=>void) : void 14 | once(eventName: string, listener:(e:any)=>void) : void 15 | off(eventName: string, listener:(e:any)=>void) : void 16 | emit(eventName: string, event:any) : void 17 | } 18 | 19 | export interface PanelParams{ 20 | width: number 21 | height: number 22 | contentURL: string 23 | } 24 | 25 | export interface ShowParams{ 26 | position: ui.ActionButton 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-port.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface JetpackPort extends JetpackEventTarget{ 4 | 5 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-preferences-service.d.ts: -------------------------------------------------------------------------------- 1 | 2 | // https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/preferences_service 3 | declare module "sdk/preferences/service" { 4 | export function get(prefName: string, defaultValue?: any) : any 5 | export function set(prefName: string, value: any) : void 6 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-promise.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/core/promise"{ 2 | export function defer(): Deferred; 3 | } 4 | 5 | 6 | interface Deferred { 7 | promise: JetpackPromise; 8 | resolve(value: T): void; 9 | reject(reason: any): void; 10 | } 11 | 12 | interface JetpackPromise { 13 | then(onFulfill: (value: T) => U, onReject?: (reason:any) => U): JetpackPromise; 14 | then(onFulfill: (value: T) => JetpackPromise, onReject?: (reason:any) => U): JetpackPromise; 15 | then(onFulfill: (value: T) => U, onReject?: (reason:any) => JetpackPromise): JetpackPromise; 16 | then(onFulfill: (value: T) => JetpackPromise, onReject?: (reason:any) => JetpackPromise): JetpackPromise; 17 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-request.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "sdk/request" { 3 | export function Request(desc: RequestDescription) : { 4 | get: () => void; 5 | post: () => void; 6 | } 7 | 8 | interface RequestDescription{ 9 | url: string 10 | headers?: Object // a finer-grain description of header could be good 11 | content?: string 12 | contentType?: string 13 | onComplete: (response: SDKRequestResponse) => void 14 | onError: (error: Error) => void 15 | } 16 | 17 | interface SDKRequestResponse{ 18 | json: Object 19 | status: number 20 | } 21 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-self.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/self" { 2 | export var data: SelfData; 3 | } 4 | 5 | interface SelfData{ 6 | url(id: string) : string 7 | load(id: string) : void 8 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-simple-prefs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/simple-prefs" { 2 | export var prefs : Prefs 3 | } 4 | 5 | interface Prefs{ 6 | "sdk.console.logLevel" : string 7 | "dev-env": boolean 8 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-system-events.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/system/events" { 2 | // boolean may be optional, but putting it compulsory to not forget to set it to "true" (strong ref which is what we usually want) 3 | export function on(e: string, listener: (event: SystemEvent) => void , strong: boolean) : void 4 | export function on(e: "content-document-global-created", listener: (event: ContentDocumentGlobalCreatedSystemEvent) => void , strong: boolean) : void 5 | } 6 | 7 | interface SystemEvent{ 8 | type : string 9 | subject : any 10 | data : any 11 | } 12 | 13 | interface ContentDocumentGlobalCreatedSystemEvent extends SystemEvent{ 14 | //type === "content-document-global-created" 15 | subject: ContentWindow 16 | data: string // origin 17 | } 18 | 19 | 20 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-system.d.ts: -------------------------------------------------------------------------------- 1 | 2 | // https://developer.mozilla.org/en-US/Add-ons/SDK/High-Level_APIs/system 3 | declare module "sdk/system" { 4 | export var staticArgs : any 5 | } 6 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-tabs-utils.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/tabs/utils" { 2 | export function getTabs() : XulTab[] 3 | export function getTabId(tab : XulTab) : number 4 | export function getTabContentWindow(tab: XulTab) : ContentWindow 5 | } 6 | 7 | interface Global{} 8 | 9 | interface XulTab{ 10 | 11 | } 12 | 13 | interface ContentWindow extends Global{ 14 | 15 | } -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-tabs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/tabs" { 2 | 3 | import WorkerModule = require("sdk/content/worker"); 4 | 5 | // part of event emitter. Figure out a way to share that across different modules 6 | export function on(eventName: string, listener: (e: any) => void) : void 7 | 8 | export function once(eventName: 'open', listener: (tab: SdkTab) => void) : void 9 | export function once(eventName: string, listener: (e: any) => void) : void 10 | 11 | export var activeTab : SdkTab 12 | export function open(url: string) : void 13 | 14 | export interface SdkTab extends JetpackEventTarget{ 15 | id: number 16 | attach: (desc: WorkerModule.WorkerDescription) => WorkerModule.Worker 17 | title: string 18 | url: string 19 | 20 | close: () => void 21 | 22 | // TODO document specific events 23 | /*on(eventName: string, listener:(e:any)=>void) : void 24 | once(eventName: string, listener:(e:any)=>void) : void 25 | off(eventName: string, listener:(e:any)=>void) : void 26 | emit(eventName: string, event:any) : void*/ 27 | } 28 | } 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-timers.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/timers" { 2 | export function setTimeout(fn: () => void, time:number ) : TimeoutId; 3 | export function setInterval(fn: () => void, time:number ) : IntervalId; 4 | export function clearTimeout(id: TimeoutId ) : void 5 | export function clearInterval(id: IntervalId ) : void 6 | } 7 | 8 | interface TimeoutId{} 9 | interface IntervalId{} -------------------------------------------------------------------------------- /firefox/defs/jetpack/jetpack-ui.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sdk/ui" { 2 | export class ActionButton{ 3 | constructor(params: ActionButtonParams) 4 | } 5 | } 6 | 7 | interface ActionButtonParams{ 8 | id: string 9 | label: string 10 | icon: string 11 | onClick: (state: string) => void 12 | } -------------------------------------------------------------------------------- /firefox/doc/main.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/firefox/doc/main.md -------------------------------------------------------------------------------- /firefox/lib/TwitterAPI.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import RequestModule = require("sdk/request"); 4 | 5 | import makeSearchString = require('./makeSearchString'); 6 | 7 | var Request = RequestModule.Request; 8 | 9 | var str = Function.prototype.call.bind( Object.prototype.toString ); 10 | 11 | function stringifyTwitterSearchQuery(obj: any){ 12 | var queryParts: string[] = []; 13 | 14 | if(obj.text) 15 | queryParts.push(obj.text); 16 | 17 | Object.keys(obj).forEach(k => { 18 | if(k === 'text') 19 | return; 20 | 21 | var val = obj[k]; 22 | 23 | if(str(val) === str(new Date())) 24 | val = val.toISOString().slice(0, 10); // keep only "2014-08-30" 25 | 26 | queryParts.push(k + ':' + val); // don't urlencode. It'll be done by makeSearchString 27 | }); 28 | 29 | return queryParts.join('+'); 30 | } 31 | 32 | function TwitterAPI(accessToken: AccessToken) : TwitterAPI_I{ 33 | return { 34 | /* 35 | maxId: the caller needs to substract 1 36 | https://dev.twitter.com/docs/working-with-timelines 37 | ... or not: 38 | "Environments where a Tweet ID cannot be represented as an integer with 64 bits of 39 | precision (such as JavaScript) should skip this step." 40 | */ 41 | getUserTimeline : function getUserTimeline(twittername: string, maxId?:TwitterTweetId){ 42 | 43 | var searchObj: TwitterAPIUserTimelineOptions = { 44 | count: 200, 45 | include_rts: 1, 46 | 'trim_user': 't', 47 | screen_name : twittername 48 | }; 49 | 50 | if(maxId){ 51 | searchObj['max_id'] = maxId; 52 | } 53 | 54 | var searchString = makeSearchString(searchObj); 55 | 56 | return new Promise((resolve, reject) => { 57 | var reqStart = Date.now(); 58 | 59 | Request({ 60 | url: 'https://api.twitter.com/1.1/statuses/user_timeline.json?'+searchString, 61 | headers: { 62 | 'Authorization': 'Bearer '+accessToken 63 | }, 64 | onComplete: function (response) { 65 | console.log( 66 | '/1.1/statuses/user_timeline.json status', 67 | response.status, 68 | ((Date.now() - reqStart)/1000).toFixed(1)+'s' 69 | ); 70 | 71 | resolve(response.json); 72 | }, 73 | onError: reject 74 | }).get(); 75 | 76 | }); 77 | }, 78 | /* 79 | https://dev.twitter.com/docs/api/1.1/get/search/tweets 80 | https://dev.twitter.com/docs/using-search 81 | 82 | parameters : { 83 | q: { 84 | text: string, // the string shouldn't be encoded 85 | from: string, 86 | to: string, 87 | '@': string, 88 | since: date, 89 | until: date, 90 | filter: string, 91 | }, 92 | since_id: string, 93 | max_id: string, 94 | count: number, 95 | result_type: string ( mixed, recent, popular), 96 | lang: string, 97 | geocode: string 98 | } 99 | 100 | 101 | twitterAPI.search({ 102 | q: { 103 | '@': user 104 | } 105 | }) 106 | .then(tweets => console.log("tweets to user", user, tweets)) 107 | .catch(e => console.error(e)) 108 | */ 109 | search: function(parameters){ 110 | var q = stringifyTwitterSearchQuery(parameters.q); 111 | 112 | var searchString = makeSearchString((Object).assign( 113 | {}, 114 | { // defaults 115 | count: 100, 116 | result_type: 'recent' 117 | }, 118 | parameters, 119 | {q: q} 120 | )); 121 | 122 | console.log("searchString", searchString) 123 | 124 | return new Promise((resolve, reject) => { 125 | var reqStart = Date.now(); 126 | 127 | Request({ 128 | url: 'https://api.twitter.com/1.1/search/tweets.json?'+searchString, 129 | headers: { 130 | 'Authorization': 'Bearer '+accessToken 131 | }, 132 | onComplete: function (response) { 133 | console.log( 134 | '/1.1/search/tweets.json status', 135 | response.status, 136 | ((Date.now() - reqStart)/1000).toFixed(1)+'s' 137 | ); 138 | 139 | resolve(response.json); 140 | }, 141 | onError: reject 142 | }).get(); 143 | 144 | }) 145 | }, 146 | 147 | lookupUsersByIds: function(user_ids: TwitterUserId[]){ 148 | var searchString = makeSearchString({ 149 | user_id: user_ids.join(','), 150 | include_entities : false 151 | }); 152 | 153 | return new Promise((resolve, reject) => { 154 | var reqStart = Date.now(); 155 | 156 | Request({ 157 | url: 'https://api.twitter.com/1.1/users/lookup.json?'+searchString, 158 | headers: { 159 | 'Authorization': 'Bearer '+accessToken 160 | }, 161 | onComplete: function (response) { 162 | console.log( 163 | '/1.1/users/lookup.json (ids) status', 164 | user_ids, 165 | response.status, 166 | ((Date.now() - reqStart)/1000).toFixed(1)+'s' 167 | ); 168 | 169 | resolve(response.json); 170 | }, 171 | onError: reject 172 | }).get(); 173 | 174 | }); 175 | }, 176 | 177 | lookupUsersByScreenNames: function(screen_names: string[]){ 178 | var searchString = makeSearchString({ 179 | screen_name: screen_names.join(','), 180 | include_entities : false 181 | }); 182 | 183 | return new Promise((resolve, reject) => { 184 | var reqStart = Date.now(); 185 | 186 | Request({ 187 | url: 'https://api.twitter.com/1.1/users/lookup.json?'+searchString, 188 | headers: { 189 | 'Authorization': 'Bearer '+accessToken 190 | }, 191 | onComplete: function (response) { 192 | console.log( 193 | '/1.1/users/lookup.json (screen names) status', 194 | screen_names, 195 | response.status, 196 | ((Date.now() - reqStart)/1000).toFixed(1)+'s' 197 | ); 198 | 199 | resolve(response.json); 200 | }, 201 | onError: reject 202 | }).get(); 203 | 204 | }); 205 | }, 206 | 207 | getFriends: function(user_id: TwitterUserId){ 208 | var searchString = makeSearchString({ 209 | user_id: user_id, 210 | stringify_ids : true, 211 | count: 5000 212 | }); 213 | 214 | return new Promise((resolve, reject) => { 215 | var reqStart = Date.now(); 216 | 217 | Request({ 218 | url: 'https://api.twitter.com/1.1/friends/ids.json?'+searchString, 219 | headers: { 220 | 'Authorization': 'Bearer '+accessToken 221 | }, 222 | onComplete: function (response) { 223 | console.log( 224 | '/1.1/friends/ids.json status', 225 | response.status, 226 | ((Date.now() - reqStart)/1000).toFixed(1)+'s' 227 | ); 228 | 229 | resolve(response.json); 230 | }, 231 | onError: reject 232 | }).get(); 233 | 234 | }); 235 | }, 236 | 237 | // https://dev.twitter.com/rest/reference/get/help/languages 238 | getLanguages: function(){ 239 | 240 | return new Promise((resolve, reject) => { 241 | var reqStart = Date.now(); 242 | 243 | Request({ 244 | url: 'https://api.twitter.com/1.1/help/languages.json', 245 | headers: { 246 | 'Authorization': 'Bearer '+accessToken 247 | }, 248 | onComplete: function (response) { 249 | console.log( 250 | '/1.1/help/languages.json', 251 | response.status, 252 | ((Date.now() - reqStart)/1000).toFixed(1)+'s', 253 | response 254 | ); 255 | 256 | resolve(response.json); 257 | }, 258 | onError: reject 259 | }).get(); 260 | 261 | }); 262 | } 263 | 264 | }; 265 | } 266 | 267 | export = TwitterAPI; 268 | -------------------------------------------------------------------------------- /firefox/lib/TwitterAPIViaServer.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import RequestModule = require("sdk/request"); 4 | 5 | const Request = RequestModule.Request; 6 | 7 | const str = Function.prototype.call.bind( Object.prototype.toString ); 8 | 9 | function stringifyTwitterSearchQuery(obj: any){ 10 | var queryParts: string[] = []; 11 | 12 | if(obj.text) 13 | queryParts.push(obj.text); 14 | 15 | Object.keys(obj).forEach(k => { 16 | if(k === 'text') 17 | return; 18 | 19 | var val = obj[k]; 20 | 21 | if(str(val) === str(new Date())) 22 | val = val.toISOString().slice(0, 10); // keep only "2014-08-30" 23 | 24 | queryParts.push(k + ':' + val); // don't urlencode. It'll be done by makeSearchString 25 | }); 26 | 27 | return queryParts.join('+'); 28 | } 29 | 30 | function TwitterAPIViaServer(oauthToken: string, serverOrigin: string) : TwitterAPI_I{ 31 | 32 | const twitterAssistantServerAPIURL = serverOrigin + '/twitter/api'; 33 | 34 | function request(url: string, parameters?: any){ 35 | return new Promise((resolve, reject) => { 36 | const reqStart = Date.now(); 37 | 38 | Request({ 39 | url: twitterAssistantServerAPIURL, 40 | contentType: 'application/json', 41 | content: JSON.stringify({ 42 | url: url, 43 | parameters: parameters, 44 | token: oauthToken 45 | }), 46 | anonymous: true, 47 | onComplete: response => { 48 | console.log( 49 | url, 50 | response.status, 51 | ((Date.now() - reqStart)/1000).toFixed(1)+'s' 52 | ); 53 | 54 | resolve(response.json); 55 | }, 56 | onError: reject 57 | }).post(); 58 | 59 | }); 60 | } 61 | 62 | return { 63 | /* 64 | maxId: the caller needs to substract 1 65 | https://dev.twitter.com/docs/working-with-timelines 66 | ... or not: 67 | "Environments where a Tweet ID cannot be represented as an integer with 64 bits of 68 | precision (such as JavaScript) should skip this step." 69 | */ 70 | getUserTimeline : function getUserTimeline(twittername: string, maxId?:TwitterTweetId){ 71 | const searchObj: TwitterAPIUserTimelineOptions = { 72 | count: 200, 73 | include_rts: 1, 74 | 'trim_user': 't', 75 | screen_name : twittername 76 | }; 77 | 78 | if(maxId){ 79 | searchObj['max_id'] = maxId; 80 | } 81 | 82 | return request( 83 | 'https://api.twitter.com/1.1/statuses/user_timeline.json', 84 | searchObj 85 | ) 86 | }, 87 | /* 88 | https://dev.twitter.com/docs/api/1.1/get/search/tweets 89 | https://dev.twitter.com/docs/using-search 90 | 91 | parameters : { 92 | q: { 93 | text: string, // the string shouldn't be encoded 94 | from: string, 95 | to: string, 96 | '@': string, 97 | since: date, 98 | until: date, 99 | filter: string, 100 | }, 101 | since_id: string, 102 | max_id: string, 103 | count: number, 104 | result_type: string ( mixed, recent, popular), 105 | lang: string, 106 | geocode: string 107 | } 108 | 109 | 110 | twitterAPI.search({ 111 | q: { 112 | '@': user 113 | } 114 | }) 115 | .then(tweets => console.log("tweets to user", user, tweets)) 116 | .catch(e => console.error(e)) 117 | */ 118 | search: function(searchParams){ 119 | const q = stringifyTwitterSearchQuery(searchParams.q); 120 | 121 | const parameters = (Object).assign( 122 | {}, 123 | { // defaults 124 | count: 100, 125 | result_type: 'recent' 126 | }, 127 | {q: q} 128 | ); 129 | 130 | return request( 131 | 'https://api.twitter.com/1.1/search/tweets.json', 132 | parameters 133 | ); 134 | }, 135 | 136 | lookupUsersByIds: function(user_ids: TwitterUserId[]){ 137 | const parameters = { 138 | user_id: user_ids.join(','), 139 | include_entities : false 140 | }; 141 | 142 | return request( 143 | 'https://api.twitter.com/1.1/users/lookup.json', 144 | parameters 145 | ); 146 | }, 147 | 148 | lookupUsersByScreenNames: function(screen_names: string[]){ 149 | const parameters = { 150 | screen_name: screen_names.join(','), 151 | include_entities : false 152 | }; 153 | 154 | return request( 155 | 'https://api.twitter.com/1.1/users/lookup.json', 156 | parameters 157 | ); 158 | }, 159 | 160 | getFriends: function(user_id: TwitterUserId){ 161 | const parameters = { 162 | user_id: user_id, 163 | stringify_ids : true, 164 | count: 5000 165 | }; 166 | 167 | return request( 168 | 'https://api.twitter.com/1.1/friends/ids.json', 169 | parameters 170 | ); 171 | }, 172 | 173 | // https://dev.twitter.com/rest/reference/get/help/languages 174 | getLanguages: function(){ 175 | return request('https://api.twitter.com/1.1/help/languages.json'); 176 | }, 177 | 178 | verifyCredentials: function(){ 179 | return request('https://api.twitter.com/1.1/account/verify_credentials.json'); 180 | } 181 | 182 | }; 183 | } 184 | 185 | export = TwitterAPIViaServer; 186 | -------------------------------------------------------------------------------- /firefox/lib/createTwitterApp.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // TODO replace with https://developer.mozilla.org/en-US/Add-ons/SDK/High-Level_APIs/page-worker#contentURL 4 | // or https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/frame_hidden-frame 5 | import tabs = require("sdk/tabs"); 6 | import timersModule = require("sdk/timers"); 7 | import selfModule = require("sdk/self"); 8 | 9 | var setTimeout = timersModule.setTimeout; 10 | var data = selfModule.data; 11 | 12 | // this page does HTTP 302 to the destination URL if the user is already logged in 13 | var TWITTER_APP_LOGIN_PAGE = "https://twitter.com/login?redirect_after_login=https%3A//apps.twitter.com/app/new"; 14 | 15 | var TWITTER_ASSISTANT_APP_NAME_PREFIX = 'TAssistant'; 16 | var TWITTER_APP_NAME_MAX_LENGTH = 32; 17 | 18 | function randomString(length = 30){ 19 | var chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; 20 | 21 | return Array(length).fill(undefined) 22 | .map(() => chars[ Math.floor(chars.length*Math.random()) ]) 23 | .join(''); 24 | } 25 | 26 | 27 | function createTwitterApp(username: string){ 28 | 29 | var tabP = new Promise((resolve, reject) => { 30 | tabs.once('open', resolve); // this looks racy. What if a tab opens before mine? 31 | tabs.open( TWITTER_APP_LOGIN_PAGE ); 32 | }); 33 | 34 | return tabP.then(tab => { 35 | 36 | var loggedinP = new Promise((resolve, reject) => { 37 | tab.once('ready', () => { 38 | resolve(tab); 39 | }) 40 | }); 41 | 42 | // fill in the app creation form and submit it 43 | var appCreatedP = loggedinP.then(tab => { 44 | var appName = TWITTER_ASSISTANT_APP_NAME_PREFIX + '-' + username + '-'; 45 | appName = appName + randomString(TWITTER_APP_NAME_MAX_LENGTH - appName.length); 46 | 47 | var worker = tab.attach({ 48 | contentScriptFile: data.url('createTwitterApp/fillInAppCreationForm.js'), 49 | contentScriptOptions: { 50 | // needs to be globally unique and below 32 chars 51 | name: appName, 52 | // 10-200 chars 53 | description: 'Application automatically generated by the Twitter Assistant browser add-on. (private use only)', 54 | // maybe make this URL a documentation page to what's going on eventually 55 | website: 'https://github.com/DavidBruant/Twitter-Assistant' 56 | 57 | } 58 | }); 59 | 60 | return new Promise((resolve, reject) => { 61 | worker.once('detach', () => { 62 | tab.once('ready', () => { 63 | 64 | console.log('detached', tab.title, tab.url); 65 | if(tab.title.contains(appName)){ 66 | console.log("victory! app created!", appName); 67 | resolve(tab); 68 | } 69 | else{ 70 | reject(new Error('problem while trying to create app')); 71 | } 72 | 73 | }); 74 | }); 75 | 76 | }); 77 | 78 | }); 79 | 80 | var twitterAppAPICredentialsP = appCreatedP.then(tab => { 81 | return new Promise((resolve, reject) => { 82 | tab.once('ready', () => { 83 | var worker = tab.attach({ 84 | contentScriptFile: data.url('createTwitterApp/collectTwitterAppAPICredentials.js') 85 | }); 86 | 87 | worker.port.on('credentials', credentials => { 88 | resolve(credentials); 89 | }); 90 | 91 | worker.port.on('error', error => { 92 | console.log('content script credentials error', error); 93 | reject(error); 94 | }); 95 | }) 96 | 97 | tab.url += '/keys'; 98 | }); 99 | 100 | }); 101 | 102 | // TODO should be twitterAppAPICredentialsP.finally when that's supported 103 | function closeTab(){ 104 | tabP.then(tab => tab.close()); 105 | } 106 | twitterAppAPICredentialsP.then(closeTab).catch(closeTab); 107 | 108 | return twitterAppAPICredentialsP; 109 | }); 110 | 111 | } 112 | 113 | 114 | export = createTwitterApp; 115 | 116 | -------------------------------------------------------------------------------- /firefox/lib/getAccessToken.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // https://dev.twitter.com/docs/auth/application-only-auth 3 | // RFC 1738 4 | import xhrModule = require("sdk/net/xhr"); 5 | import base64 = require("sdk/base64"); 6 | 7 | var XMLHttpRequest = xhrModule.XMLHttpRequest 8 | 9 | // cache key is bearerTokenCredentails 10 | var tokenCache = new Map(); 11 | 12 | function getAccessToken(key: string, secret: string){ 13 | return new Promise((resolve, reject) => { 14 | var encodedConsumerKey = encodeURIComponent(key); 15 | var encodedConsumerSecret = encodeURIComponent(secret); 16 | 17 | var bearerTokenCredentials = encodedConsumerKey + ':' + encodedConsumerSecret; 18 | 19 | var b64bearerToken = base64.encode(bearerTokenCredentials); 20 | 21 | if(tokenCache.has(bearerTokenCredentials)){ 22 | resolve(tokenCache.get(bearerTokenCredentials)); 23 | } 24 | else{ 25 | // Using ("sdk/net/xhr").XMLHttpRequest until https://bugzilla.mozilla.org/show_bug.cgi?id=1002229 ships to release (Firefox 32?) 26 | var xhr = new XMLHttpRequest({mozAnon: true}); 27 | 28 | xhr.open('POST', "https://api.twitter.com/oauth2/token"); 29 | 30 | xhr.setRequestHeader('Authorization', 'Basic '+b64bearerToken); 31 | xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8'); 32 | 33 | xhr.responseType = "json"; 34 | 35 | xhr.addEventListener('load', e => { 36 | console.log('/oauth2/token status', xhr.status); 37 | 38 | if(xhr.status < 400){ 39 | var token = xhr.response.access_token; 40 | 41 | tokenCache.set(bearerTokenCredentials, token); 42 | 43 | resolve(token); 44 | } 45 | else{ 46 | reject('/oauth2/token error '+ xhr.status); 47 | } 48 | 49 | }); 50 | 51 | xhr.addEventListener('error', e => { 52 | reject('/oauth2/token error '+ String(e)); 53 | }) 54 | 55 | xhr.send('grant_type=client_credentials'); 56 | } 57 | 58 | }); 59 | } 60 | 61 | export = getAccessToken; 62 | -------------------------------------------------------------------------------- /firefox/lib/getAddonUserInfoAndFriends.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import guessAddonUserTwitterName = require('./guessAddonUserTwitterName'); 4 | 5 | interface UserInfoAndFriends{ 6 | user: TwitterAPIUser; 7 | friendIds: TwitterUserId[] 8 | } 9 | 10 | var cachedUserName: string; 11 | var cachedUserInfoAndFriends : UserInfoAndFriends; 12 | var lastUserInfoFetchTimestamp = -Infinity; 13 | var lastAddonUserTwitterNameGuessTimestamp = -Infinity; 14 | 15 | var USER_INFO_VALIDITY = 2*60*60*1000; // ms 16 | 17 | // For the time of this duration, let's assume the user hasn't logged out 18 | var ASSUME_SAME_USER_DURATION = 15*60*1000; 19 | 20 | function cacheAndReturn(userInfoAndFriends: UserInfoAndFriends){ 21 | cachedUserName = userInfoAndFriends.user.screen_name; 22 | cachedUserInfoAndFriends = userInfoAndFriends; 23 | lastUserInfoFetchTimestamp = Date.now(); 24 | return userInfoAndFriends; 25 | } 26 | 27 | function getAddonUserInfoAndFriends(twitterAPI: TwitterAPI_I) : Promise { 28 | var guessedUser : Promise; 29 | 30 | function getUserAndFriendsFromUsername(username: string){ 31 | return twitterAPI.lookupUsersByScreenNames([username]).then(function(users){ 32 | var user = users[0]; 33 | return twitterAPI.getFriends(user.id_str).then(function(result){ 34 | return { 35 | user: user, 36 | friendIds: result.ids 37 | }; 38 | }) 39 | }) 40 | } 41 | 42 | 43 | if(Date.now() - lastAddonUserTwitterNameGuessTimestamp < ASSUME_SAME_USER_DURATION){ 44 | guessedUser = Promise.resolve(cachedUserName); 45 | } 46 | else{ 47 | guessedUser = guessAddonUserTwitterName().then(username => { 48 | lastAddonUserTwitterNameGuessTimestamp = Date.now(); 49 | return username; 50 | }) 51 | } 52 | 53 | return guessedUser.then(function(username){ 54 | 55 | if(username){ // addon user logged in to Twitter 56 | if(username === cachedUserName){ 57 | if(Date.now() - lastUserInfoFetchTimestamp < USER_INFO_VALIDITY) 58 | return Promise.resolve(cachedUserInfoAndFriends); 59 | else 60 | return getUserAndFriendsFromUsername(username).then(cacheAndReturn); 61 | } 62 | else 63 | return getUserAndFriendsFromUsername(username).then(cacheAndReturn); 64 | } 65 | else{ // not logged in 66 | // respectfully clearing cache 67 | cachedUserName = undefined; 68 | cachedUserInfoAndFriends = undefined; 69 | 70 | return undefined; 71 | } 72 | 73 | }); 74 | } 75 | 76 | export = getAddonUserInfoAndFriends; -------------------------------------------------------------------------------- /firefox/lib/getReadyForTwitterProfilePages.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | if access to Twitter API, have a pagemod 5 | if no access to Twitter API, no need for a pagemod 6 | 7 | */ 8 | 9 | import pagemodModule = require("sdk/page-mod"); 10 | import selfModule = require("sdk/self"); 11 | 12 | 13 | import getAccessToken = require('./getAccessToken'); 14 | import getTimelineOverATimePeriod = require('./getTimelineOverATimePeriod'); 15 | import TwitterAPIViaServer = require('./TwitterAPIViaServer'); 16 | import guessAddonUserTwitterName = require('./guessAddonUserTwitterName'); 17 | import getAddonUserInfoAndFriends = require('./getAddonUserInfoAndFriends'); 18 | 19 | //import stemByLang = require('../data/metrics-integration/stem'); 20 | 21 | 22 | const PageMod = pagemodModule.PageMod; 23 | const data = selfModule.data; 24 | 25 | const ONE_DAY = 24*60*60*1000; 26 | const DISPLAY_DAY_COUNT = 40; 27 | 28 | let twitterAPI : TwitterAPI_I; 29 | 30 | /*let addonUsername : string; 31 | let addonUserAndFriendsP : Promise<{ 32 | user: TwitterAPIUser 33 | friendIds: TwitterUserId[] 34 | }>;*/ 35 | 36 | let languages: {code: string, name: string}[]; 37 | 38 | 39 | // create the pageMod inconditionally 40 | // if the browser was offline initially, an access token couldn't be acquired 41 | // the pageMod will verify if there is an access token at each attach event (and retry) 42 | const twitterProfilePageMod = new PageMod({ 43 | include: /^https?:\/\/twitter\.com\/([^\/]+)\/?$/, 44 | 45 | contentScriptFile: [ 46 | data.url("ext/react.js"), 47 | 48 | data.url("metrics-integration/twitter-assistant-content.js") 49 | ], 50 | contentScriptWhen: "start", // mostly so the 'attach' event happens as soon as possible 51 | 52 | contentStyleFile: [ 53 | data.url("metrics-integration/main.css"), 54 | data.url("metrics-integration/additional.css") 55 | ] 56 | }); 57 | 58 | twitterProfilePageMod.on('attach', function onAttach(worker){ 59 | // TODO: 60 | // Do nothing on 404 pages as well as search results 61 | 62 | const matches = worker.url.match(/^https?:\/\/twitter\.com\/([^\/\?]+)[\/\?]?/); 63 | 64 | if(!Array.isArray(matches) || matches.length < 2) 65 | return; 66 | const visitedUser : string = matches[1]; 67 | 68 | 69 | twitterAPI.lookupUsersByScreenNames([visitedUser]).then( users => { 70 | worker.port.emit('visited-user-details', users[0]); 71 | }); 72 | 73 | getAddonUserInfoAndFriends(twitterAPI).then( result => { 74 | worker.port.emit('addon-user-and-friends', result); 75 | }); 76 | 77 | 78 | worker.port.emit('display-days-count', DISPLAY_DAY_COUNT); 79 | 80 | const getTimelineWithProgress = getTimelineOverATimePeriod(twitterAPI.getUserTimeline); 81 | const timelineComplete = getTimelineWithProgress({ 82 | username: visitedUser, 83 | timestampFrom: (new Date()).getTime() - ONE_DAY*40 84 | }, sendTimelineToContent); 85 | 86 | ( timelineComplete).catch( (err: Error) => { 87 | console.error('error while getting the user timeline', visitedUser, err); 88 | }); 89 | 90 | /*if(languages){ 91 | worker.port.emit('languages', languages); 92 | } 93 | else{ 94 | twitterAPI.getLanguages() 95 | .then(l => { 96 | languages = l; 97 | worker.port.emit('languages', languages); 98 | }) 99 | .catch(err => console.error('Twitter languages error', err)); 100 | }*/ 101 | 102 | function sendTimelineToContent(partialTimeline: TwitterAPITweet[]){ 103 | worker.port.emit('twitter-user-data', partialTimeline); 104 | } 105 | 106 | worker.port.on('ask-users', (userIds: TwitterUserId[]) => { 107 | twitterAPI.lookupUsersByIds(userIds).then(users => { 108 | worker.port.emit('answer-users', users) 109 | }); 110 | }); 111 | 112 | }); 113 | 114 | function getReady(oauthToken: string, serverOrigin: string) : Promise{ 115 | twitterAPI = TwitterAPIViaServer(oauthToken, serverOrigin); 116 | return Promise.resolve(); // so TS shut up 117 | } 118 | 119 | export = getReady; -------------------------------------------------------------------------------- /firefox/lib/getTimelineOverATimePeriod.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | interface getTimelineOverATimePeriodQuery{ 4 | username:string 5 | timestampFrom: number // timestamp 6 | timestampTo?: number // timestamp 7 | } 8 | 9 | function getTimelineOverATimePeriod(getUserTimeline: (twittername: string, maxId?: TwitterTweetId) => Promise){ 10 | 11 | // should be one of these Stream+Promise hybrid when that's ready 12 | return function(query: getTimelineOverATimePeriodQuery, progress : (tweets : TwitterAPITweet[]) => void){ 13 | var username = query.username, 14 | timestampFrom = query.timestampFrom, 15 | timestampTo = query.timestampTo; 16 | 17 | 18 | timestampTo = timestampTo || (new Date()).getTime() 19 | var accumulatedTweets : TwitterAPITweet[] = []; 20 | 21 | function accumulateTweets(timeline: TwitterAPITweet[]){ 22 | // remove from timeline tweets that wouldn't be in the range 23 | var toAccumulate = timeline = timeline.filter(tweet => { 24 | var createdTimestamp = (new Date(tweet.created_at)).getTime(); 25 | return createdTimestamp >= timestampFrom && createdTimestamp <= timestampTo; 26 | }); 27 | 28 | accumulatedTweets = accumulatedTweets.concat(toAccumulate); 29 | 30 | return toAccumulate; 31 | } 32 | 33 | return getUserTimeline(username).then(function processTweets(timeline: TwitterAPITweet[]) : Promise{ 34 | //console.log("processTweets", accumulatedTweets.length, timeline.length); 35 | 36 | // max_id dance may lead to re-feching one same tweet. 37 | if(accumulatedTweets.length > 0 && timeline[0].id_str === accumulatedTweets[accumulatedTweets.length-1].id_str) 38 | timeline = timeline.slice(1); 39 | var accumulated = accumulateTweets(timeline); 40 | 41 | //console.log("processTweets 2", accumulatedTweets.length, timeline.length, accumulated.length); 42 | 43 | progress(accumulated); 44 | 45 | // if tweets don't go back far enough, get max_id of last tweet and call getUserTimeline again 46 | if(timeline.length === accumulated.length){ 47 | var maxId = accumulatedTweets[accumulatedTweets.length - 1].id_str; 48 | return getUserTimeline(username, maxId).then(processTweets); 49 | } 50 | else{ 51 | // Promise.resolve added to calm TypeScript. Union types in TS1.4 might allow removing it 52 | return Promise.resolve(accumulatedTweets); 53 | } 54 | }); 55 | } 56 | } 57 | 58 | export = getTimelineOverATimePeriod; -------------------------------------------------------------------------------- /firefox/lib/guessAddonUserTwitterName.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import xhrModule = require("sdk/net/xhr"); 4 | import selfModule = require("sdk/self"); 5 | 6 | var XMLHttpRequest = xhrModule.XMLHttpRequest; 7 | var data = selfModule.data; 8 | 9 | function guessAddonUserTwitterName(){ 10 | return new Promise((resolve, reject) => { 11 | var reqStart = Date.now(); 12 | 13 | var xhr = new XMLHttpRequest(); 14 | xhr.open("GET", 'https://twitter.com/'); 15 | xhr.responseType = "document"; 16 | 17 | xhr.onload = function(){ 18 | if(xhr.status >= 400){ 19 | reject(new Error('status code '+xhr.status)) 20 | } 21 | else{ 22 | var doc = xhr.response; 23 | var screenNameElement = doc.body.querySelector('.DashboardProfileCard .DashboardProfileCard-screenname'); 24 | 25 | if(screenNameElement) // .slice(1) to remove the initial @ 26 | resolve(screenNameElement.textContent.trim().slice(1)); 27 | else 28 | reject('user not logged in or Twitter changed its HTML'); 29 | } 30 | }; 31 | 32 | xhr.send(); 33 | 34 | }); 35 | }; 36 | 37 | export = guessAddonUserTwitterName; -------------------------------------------------------------------------------- /firefox/lib/main.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import ui = require('sdk/ui'); 4 | import selfModule = require('sdk/self'); 5 | import tabs = require('sdk/tabs'); 6 | import system = require('sdk/system'); 7 | import timersModule = require('sdk/timers'); 8 | import windowsModule = require('sdk/windows'); 9 | import urlModule = require('sdk/url'); 10 | 11 | import prefModule = require('sdk/simple-prefs'); 12 | import lowLevelPrefs = require('sdk/preferences/service'); 13 | 14 | import requestToken = require('./requestToken'); 15 | import guessAddonUserTwitterName = require('./guessAddonUserTwitterName'); 16 | import getReadyForTwitterProfilePages = require('./getReadyForTwitterProfilePages'); 17 | import makeSigninPanel = require('./makeSigninPanel'); 18 | import TwitterAPIViaServer = require('./TwitterAPIViaServer'); 19 | 20 | 21 | const data = selfModule.data; 22 | const setTimeout = timersModule.setTimeout; 23 | const staticArgs = system.staticArgs; 24 | const prefs : any = prefModule.prefs; 25 | const windows = windowsModule.browserWindows; 26 | const URL = urlModule.URL; 27 | 28 | const TWITTER_MAIN_PAGE = "https://twitter.com"; 29 | const TWITTER_USER_PAGES = [ 30 | "https://twitter.com/DavidBruant"/*, 31 | "https://twitter.com/rauschma", 32 | "https://twitter.com/nitot", 33 | "https://twitter.com/guillaumemasson", 34 | "https://twitter.com/angustweets"*/ 35 | 36 | // https://twitter.com/BuzzFeed // for an account with LOTS of tweets 37 | // https://twitter.com/dupatron has no tweets at this point 38 | ]; 39 | 40 | const OAUTH_TOKEN_PREF = "oauthtoken"; 41 | 42 | 43 | declare var process: any; 44 | 45 | export var main = function(){ 46 | 47 | prefs["sdk.console.logLevel"] = 'all'; 48 | 49 | const signinPanel = makeSigninPanel(); 50 | 51 | const twitterAssistantButton = new ui.ActionButton({ 52 | id: "twitter-assistant-signin-panel-button", 53 | label: "Twitter Assistant panel", 54 | icon: data.url('images/Twitter_logo_blue.png'), 55 | onClick: state => { 56 | signinPanel.show({position: twitterAssistantButton}); 57 | } 58 | }); 59 | 60 | let twitterAssistantServerOrigin = 'http://92.243.26.40:3737'; 61 | let twitterCallbackURL = twitterAssistantServerOrigin+'/twitter/callback'; 62 | 63 | function validateOauthToken(oauthToken: string){ 64 | console.log('oauthToken', oauthToken) 65 | const twitterAPI = TwitterAPIViaServer(oauthToken, twitterAssistantServerOrigin); 66 | 67 | return twitterAPI.verifyCredentials() 68 | .then(user => { 69 | if(!user) 70 | throw new Error('Invalid token'); 71 | 72 | return { 73 | oauthToken: oauthToken, 74 | user: user 75 | }; 76 | }) 77 | } 78 | 79 | 80 | signinPanel.port.on('sign-in-with-twitter', () => { 81 | console.log('receiving sign-in-with-twitter'); 82 | 83 | const oauthTokenP = requestToken(twitterAssistantServerOrigin, twitterCallbackURL) 84 | .then(twitterPermissionURL => { 85 | const twitterSigninWindow = windows.open(twitterPermissionURL); 86 | 87 | return new Promise(resolve => { 88 | twitterSigninWindow.on('open', w => { 89 | const tab = w.tabs.activeTab; 90 | tab.on('ready', t => { 91 | if(t.url.startsWith(twitterCallbackURL)){ 92 | const parsedURL = URL(t.url); 93 | const search = parsedURL.search; 94 | const query = new Map(); 95 | 96 | search.slice(1).split('&') 97 | .forEach(p => { 98 | const x = p.split('='); 99 | query.set(x[0], x[1]); 100 | }); 101 | w.close(); 102 | resolve(query.get('oauth_token')); 103 | } 104 | 105 | }); 106 | }) 107 | }) 108 | }); 109 | 110 | oauthTokenP 111 | .catch(e => { 112 | console.error('oauthTokenP.catch', e) 113 | signinPanel.port.emit( 114 | 'error-request-token', 115 | {twitterAssistantServerOrigin: twitterAssistantServerOrigin, message: String(e)} 116 | ); 117 | }); 118 | 119 | 120 | const tokenAndUserP = oauthTokenP.then(validateOauthToken); 121 | 122 | tokenAndUserP 123 | .then(tokenAndUser => getReadyForTwitterProfilePages(tokenAndUser.oauthToken, twitterAssistantServerOrigin)) 124 | 125 | tokenAndUserP 126 | .then(tokenAndUser => { 127 | prefs[OAUTH_TOKEN_PREF] = tokenAndUser.oauthToken; 128 | signinPanel.port.emit('logged-in-user', tokenAndUser.user); 129 | tabs.open('https://twitter.com/'+tokenAndUser.user.screen_name); 130 | }) 131 | .catch(err => console.error('error verifying the token', err)); 132 | }); 133 | 134 | /* 135 | init 136 | */ 137 | const savedToken = prefs[OAUTH_TOKEN_PREF]; 138 | 139 | if(savedToken){ 140 | 141 | validateOauthToken(savedToken) 142 | .catch(err => { 143 | // pref contains an invalid token 144 | prefs[OAUTH_TOKEN_PREF] = ''; 145 | signinPanel.show({position: twitterAssistantButton}) 146 | throw err; 147 | }) 148 | .then(tokenAndUser => { 149 | getReadyForTwitterProfilePages(tokenAndUser.oauthToken, twitterAssistantServerOrigin); 150 | signinPanel.port.emit('logged-in-user', tokenAndUser.user); 151 | }) 152 | } 153 | else{ 154 | signinPanel.show({position: twitterAssistantButton}) 155 | } 156 | 157 | }; 158 | -------------------------------------------------------------------------------- /firefox/lib/makeSearchString.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // TODO get rid of this module when https://bugzilla.mozilla.org/show_bug.cgi?id=935223 is RESOLVED FIXED 4 | 5 | function makeSearchString(obj: any){ 6 | var sp : string[] = []; 7 | 8 | // http://stackoverflow.com/a/3608791 9 | Object.keys(obj).forEach( k => sp.push(encodeURI(k) + '=' + encodeURI(obj[k])) ); 10 | 11 | return sp.join('&'); 12 | } 13 | 14 | export = makeSearchString; -------------------------------------------------------------------------------- /firefox/lib/makeSigninPanel.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import panelModule = require("sdk/panel"); 4 | import selfModule = require("sdk/self"); 5 | 6 | import guessAddonUserTwitterName = require('./guessAddonUserTwitterName'); 7 | 8 | const Panel = panelModule.Panel; 9 | const data = selfModule.data; 10 | 11 | function makeSigninPanel(){ 12 | 13 | const signinPanel = new Panel({ 14 | width: 650, 15 | height: 400, 16 | contentURL: data.url('panel/mainPanel.html') 17 | }); 18 | 19 | /*signinPanel.on('show', e => { 20 | console.log("signinPanel.on 'show'") 21 | 22 | guessAddonUserTwitterName() 23 | .then(username => { 24 | console.log('guessed user', username); 25 | signinPanel.port.emit('update-logged-user', username); 26 | }) 27 | .catch(err => { 28 | console.log('err guessed user', err); 29 | signinPanel.port.emit('update-logged-user', undefined); 30 | }); 31 | })*/ 32 | 33 | return signinPanel; 34 | } 35 | 36 | export = makeSigninPanel; -------------------------------------------------------------------------------- /firefox/lib/requestToken.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // https://dev.twitter.com/docs/auth/application-only-auth 3 | // RFC 1738 4 | import xhrModule = require("sdk/net/xhr"); 5 | 6 | const XMLHttpRequest = xhrModule.XMLHttpRequest 7 | 8 | function requestToken(twitterAssistantServerOrigin: string, callbackURL: string){ 9 | console.log('requestToken', twitterAssistantServerOrigin, callbackURL); 10 | 11 | return new Promise((resolve, reject) => { 12 | const xhr = new XMLHttpRequest({mozAnon: true}); 13 | const url = twitterAssistantServerOrigin + '/twitter/oauth/request_token' 14 | 15 | xhr.open('POST', url); 16 | 17 | xhr.addEventListener('load', e => { 18 | console.log('/oauth2/token status', xhr.status); 19 | 20 | if(xhr.status < 400){ 21 | resolve(xhr.response); 22 | } 23 | else{ 24 | reject(url +' HTTP error '+ xhr.status); 25 | } 26 | 27 | }); 28 | 29 | xhr.addEventListener('error', e => { 30 | reject(url +' error '+ String(e)); 31 | }); 32 | 33 | xhr.setRequestHeader('Content-Type', 'application/json'); 34 | xhr.send(JSON.stringify({callbackURL: callbackURL})); 35 | }); 36 | } 37 | 38 | export = requestToken; 39 | -------------------------------------------------------------------------------- /firefox/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-assistant", 3 | "title": "Twitter Assistant", 4 | "id": "jid1-BbLB1WcU09jwfA@jetpack", 5 | "description": "An addon improving the Twitter web experience", 6 | "author": "David Bruant", 7 | "license": "MIT", 8 | "main": "lib/main.js", 9 | "version": "2.1.0" 10 | } 11 | -------------------------------------------------------------------------------- /firefox/test/test-TwitterAccountURLRegExp.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | //var assert = assert = require('chai').assert; 4 | var assert = { 5 | isTrue: function(x, msg){ 6 | if(x !== true) 7 | console.error('should be true', msg) 8 | }, 9 | isFalse: function(x, msg){ 10 | if(x !== false) 11 | console.error('should be false', msg) 12 | }, 13 | equal: function(x, y, msg){ 14 | if(!Object.is(x, y)) 15 | console.error('expected', x, 'to ===', y, msg); 16 | 17 | return Object.is(x, y); 18 | } 19 | } 20 | 21 | function extractUserFromUrl(str){ 22 | var matches = str.match(/^https?:\/\/twitter\.com\/([^\/\?]+)[\/\?]?/); 23 | 24 | if(!Array.isArray(matches) || matches.length < 2) 25 | return; 26 | return matches[1]; 27 | } 28 | 29 | 30 | [ 31 | 'http://twitter.com/abc', 32 | 'https://twitter.com/abc', 33 | 'https://twitter.com/abc?yo=ya', 34 | 'https://twitter.com/abc?_utm=whatever' 35 | ].forEach(function(str){ 36 | assert.equal(extractUserFromUrl(str), 'abc', str); 37 | }); 38 | 39 | -------------------------------------------------------------------------------- /log-width.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | votre superbe page web créée le lun. 26 mai 2014 15:41 6 | 7 | 23 | 24 | 47 | 48 | 124 | 125 | 126 | 127 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /logo-alpc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/logo-alpc.jpg -------------------------------------------------------------------------------- /mockup/maquette01-theme1-expended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette01-theme1-expended.png -------------------------------------------------------------------------------- /mockup/maquette01-theme1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette01-theme1.png -------------------------------------------------------------------------------- /mockup/maquette01-theme2-expended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette01-theme2-expended.png -------------------------------------------------------------------------------- /mockup/maquette01-theme2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette01-theme2.png -------------------------------------------------------------------------------- /mockup/maquette01-theme3-expended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette01-theme3-expended.png -------------------------------------------------------------------------------- /mockup/maquette01-theme3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette01-theme3.png -------------------------------------------------------------------------------- /mockup/maquette02-theme1-expended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/mockup/maquette02-theme1-expended.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "name": "Twitter-Assistant", 4 | "repository": { 5 | "url": "git://github.com/DavidBruant/Twitter-Assistant.git", 6 | "type": "git" 7 | }, 8 | "author": "David Bruant", 9 | "bugs": { 10 | "url": "https://github.com/DavidBruant/Twitter-Assistant/issues" 11 | }, 12 | "version": "2.1.0", 13 | "scripts": { 14 | "test": "echo \"Error: no test specified\" && exit 1", 15 | "compile": "tsc --sourceMap --noImplicitAny -m commonjs --target ES5 firefox/**/*.ts firefox/**/**/*.ts server/index.ts", 16 | "xpi": "jpm xpi --addon-dir firefox", 17 | "run": "jpm run -b $(which firefox) --prefs prefs.json --debug --addon-dir firefox", 18 | "build": "browserify firefox/data/metrics-integration/main.ts -p [ tsify --noImplicitAny --sourceMap --target ES5 ] --debug -o firefox/data/metrics-integration/twitter-assistant-content.js --ignore WNdb --ignore lapack", 19 | "watch": "watchify firefox/data/metrics-integration/main.ts -p [ tsify --noImplicitAny --sourceMap --target ES5 ] --debug -o firefox/data/metrics-integration/twitter-assistant-content.js -v --ignore WNdb --ignore lapack" 20 | }, 21 | "devDependencies": { 22 | "watchify": "^3.3.1" 23 | }, 24 | "main": "index.js", 25 | "id": "jid1-BptUkbczEocdyg", 26 | "description": "Twitter Assistant ==============", 27 | "dependencies": { 28 | "body-parser": "^1.15.0", 29 | "browserify": "^11.0.1", 30 | "compression": "^1.6.1", 31 | "express": "^4.13.4", 32 | "jpm": "^1.0.6", 33 | "natural": "^0.2.1", 34 | "querystring": "^0.2.0", 35 | "request": "^2.69.0", 36 | "tsify": "^0.11.9", 37 | "typescript": "1.5.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /prefs.json: -------------------------------------------------------------------------------- 1 | { 2 | "devtools.chrome.enabled": true, 3 | "devtools.debugger.remote-enabled": true, 4 | "xpinstall.signatures.required": false 5 | } -------------------------------------------------------------------------------- /prefs.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "devtools.chrome.enabled": true, 3 | "devtools.debugger.remote-enabled": true, 4 | "xpinstall.signatures.required": false, 5 | "TWITTER_ASSISTANT_CONSUMER_KEY": "", 6 | "TWITTER_ASSISTANT_CONSUMER_SECRET": "" 7 | } -------------------------------------------------------------------------------- /screenshot-firefox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/screenshot-firefox.png -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | 3 | "use strict"; 4 | 5 | import request = require('request'); 6 | import qs = require('querystring'); 7 | 8 | import express = require('express'); 9 | import compression = require('compression'); 10 | import bodyParser = require('body-parser'); 11 | 12 | import oauthCredentials = require('./oauthCredentials.json'); 13 | 14 | Object.freeze(oauthCredentials); // preventive 15 | 16 | interface OauthData{ 17 | consumer_key: string, 18 | consumer_secret: string, 19 | token: string, 20 | token_secret: string, 21 | verifier: string 22 | } 23 | 24 | const oauthTokenToOauthData = new Map(); 25 | 26 | const app = express(); 27 | app.disable("x-powered-by"); 28 | app.disable("etag"); 29 | 30 | app.use(bodyParser.json({limit: "1mb"})); // for parsing application/json 31 | app.use(compression()); 32 | 33 | app.post('/twitter/oauth/request_token', function(req, res){ 34 | const callbackURL = req.body.callbackURL; 35 | console.log('callbackURL', callbackURL) 36 | // fresh object at each request 37 | const oauth = Object.assign({callback_url: encodeURIComponent(callbackURL)}, oauthCredentials); 38 | 39 | request.post( 40 | { 41 | url: 'https://api.twitter.com/oauth/request_token', 42 | oauth: oauth 43 | }, 44 | function(err, response, body){ 45 | if(err){ 46 | res.status(502); 47 | res.send('Error discussing with Twitter (request_token): '+String(err)); 48 | return; 49 | } 50 | 51 | console.log('status from Twitter request_token', response.statusCode, body); 52 | 53 | // Ideally, you would take the body in the response 54 | // and construct a URL that a user clicks on (like a sign in button). 55 | // The verifier is only available in the response after a user has 56 | // verified with twitter that they are authorizing your app. 57 | 58 | // step 2 59 | const oauthTokenResponse = qs.parse(body); 60 | const token = oauthTokenResponse.oauth_token; 61 | oauthTokenToOauthData.set(token, Object.assign( 62 | { 63 | token: token, 64 | token_secret: oauthTokenResponse.oauth_token_secret 65 | }, 66 | oauthCredentials 67 | )); 68 | 69 | const twitterAuthenticateURL = [ 70 | 'https://api.twitter.com/oauth/authenticate', 71 | '?', 72 | qs.stringify({oauth_token: token}) 73 | ].join(''); 74 | 75 | res.send(twitterAuthenticateURL); 76 | } 77 | ) 78 | }); 79 | 80 | 81 | app.get('/twitter/callback', function(req, res){ 82 | const query = req.query; 83 | const token = query.oauth_token; 84 | const verifier = query.oauth_verifier; 85 | 86 | const oauthData = oauthTokenToOauthData.get(token); 87 | 88 | oauthData.token = token; // "useless" because it should be the same value 89 | oauthData.verifier = verifier; // "useless" because it should be the same value 90 | 91 | request.post( 92 | { 93 | url: 'https://api.twitter.com/oauth/access_token', 94 | oauth: oauthData 95 | }, 96 | function(err, response, body){ 97 | if(err){ 98 | res.status(502); 99 | res.send('Error discussing with Twitter (access_token): '+String(err)); 100 | return; 101 | } 102 | 103 | console.log('status from Twitter access_token', response.statusCode, body); 104 | 105 | const finalOauthData = qs.parse(body); 106 | 107 | delete oauthData.verifier; // not necessary any longer 108 | oauthData.token = finalOauthData.oauth_token; 109 | oauthData.token_secret = finalOauthData.oauth_token_secret; 110 | 111 | res.status(200); 112 | res.send(''); 113 | } 114 | ); 115 | }); 116 | 117 | 118 | /* 119 | Generic route that forwards to the Twitter API and back 120 | */ 121 | app.post('/twitter/api', (req, res) => { 122 | const body = req.body; 123 | 124 | const url = body.url; 125 | const parameters = body.parameters; 126 | const token = body.token; 127 | 128 | const oauth = oauthTokenToOauthData.get(token); 129 | 130 | console.log('/twitter/api', url, parameters, oauth); 131 | 132 | if(!oauth){ 133 | res.status(403); 134 | res.send('Unknown token: '+token); 135 | return; 136 | } 137 | 138 | // sending request to Twitter and forwarding back to addon directly 139 | request.get({ url: url, oauth: oauth, qs: parameters, json: true }).pipe(res); 140 | }); 141 | 142 | 143 | app.listen('3737', function(){ 144 | console.log('listening', 'http://localhost:3737/') 145 | }); 146 | -------------------------------------------------------------------------------- /server/oauthCredentials.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "consumer_key": "XXXX", 3 | "consumer_secret": "YYYY" 4 | } -------------------------------------------------------------------------------- /server/whatevs.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidBruant/Twitter-Assistant/efd04f9634561106aa7dd6f9af4b4994e2aa6956/server/whatevs.d.ts -------------------------------------------------------------------------------- /typings/form-data/form-data.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for form-data 2 | // Project: https://github.com/felixge/node-form-data 3 | // Definitions by: Carlos Ballesteros Velasco 4 | // Definitions: https://github.com/borisyankov/DefinitelyTyped 5 | 6 | // Imported from: https://github.com/soywiz/typescript-node-definitions/form-data.d.ts 7 | 8 | declare module "form-data" { 9 | export class FormData { 10 | append(key: string, value: any, options?: any): FormData; 11 | getHeaders(): Object; 12 | // TODO expand pipe 13 | pipe(to: any): any; 14 | submit(params: string|Object, callback: (error: any, response: any) => void): any; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /typings/request/request.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for request 2 | // Project: https://github.com/mikeal/request 3 | // Definitions by: Carlos Ballesteros Velasco , bonnici , Bart van der Schoor 4 | // Definitions: https://github.com/borisyankov/DefinitelyTyped 5 | 6 | // Imported from: https://github.com/soywiz/typescript-node-definitions/d.ts 7 | 8 | /// 9 | /// 10 | 11 | declare module 'request' { 12 | import stream = require('stream'); 13 | import http = require('http'); 14 | import FormData = require('form-data'); 15 | import url = require('url'); 16 | 17 | export = RequestAPI; 18 | 19 | function RequestAPI(uri: string, options?: RequestAPI.Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): RequestAPI.Request; 20 | function RequestAPI(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): RequestAPI.Request; 21 | function RequestAPI(options: RequestAPI.Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): RequestAPI.Request; 22 | 23 | module RequestAPI { 24 | export function defaults(options: Options): typeof RequestAPI; 25 | 26 | export function request(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 27 | export function request(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 28 | export function request(options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 29 | 30 | export function get(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 31 | export function get(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 32 | export function get(options: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 33 | 34 | export function post(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 35 | export function post(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 36 | export function post(options: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 37 | 38 | export function put(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 39 | export function put(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 40 | export function put(options: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 41 | 42 | export function head(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 43 | export function head(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 44 | export function head(options: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 45 | 46 | export function patch(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 47 | export function patch(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 48 | export function patch(options: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 49 | 50 | export function del(uri: string, options?: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 51 | export function del(uri: string, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 52 | export function del(options: Options, callback?: (error: any, response: http.IncomingMessage, body: any) => void): Request; 53 | 54 | export function forever(agentOptions: any, optionsArg: any): Request; 55 | export function jar(): CookieJar; 56 | export function cookie(str: string): Cookie; 57 | 58 | export var initParams: any; 59 | 60 | export interface Options { 61 | url?: string; 62 | uri?: string; 63 | callback?: (error: any, response: http.IncomingMessage, body: any) => void; 64 | jar?: any; // CookieJar 65 | form?: any; // Object or string 66 | auth?: AuthOptions; 67 | oauth?: OAuthOptions; 68 | aws?: AWSOptions; 69 | hawk ?: HawkOptions; 70 | qs?: Object; 71 | json?: any; 72 | multipart?: RequestPart[]; 73 | agentOptions?: any; 74 | agentClass?: any; 75 | forever?: any; 76 | host?: string; 77 | port?: number; 78 | method?: string; 79 | headers?: Headers; 80 | body?: any; 81 | followRedirect?: boolean; 82 | followAllRedirects?: boolean; 83 | maxRedirects?: number; 84 | encoding?: string; 85 | pool?: any; 86 | timeout?: number; 87 | proxy?: any; 88 | strictSSL?: boolean; 89 | gzip?: boolean; 90 | } 91 | 92 | export interface RequestPart { 93 | headers?: Headers; 94 | body: any; 95 | } 96 | 97 | export interface Request extends stream.Stream { 98 | readable: boolean; 99 | writable: boolean; 100 | 101 | getAgent(): http.Agent; 102 | //start(): void; 103 | //abort(): void; 104 | pipeDest(dest: any): void; 105 | setHeader(name: string, value: string, clobber?: boolean): Request; 106 | setHeaders(headers: Headers): Request; 107 | qs(q: Object, clobber?: boolean): Request; 108 | form(): FormData.FormData; 109 | form(form: any): Request; 110 | multipart(multipart: RequestPart[]): Request; 111 | json(val: any): Request; 112 | aws(opts: AWSOptions, now?: boolean): Request; 113 | auth(username: string, password: string, sendInmediately?: boolean, bearer?: string): Request; 114 | oauth(oauth: OAuthOptions): Request; 115 | jar(jar: CookieJar): Request; 116 | 117 | on(event: string, listener: Function): Request; 118 | 119 | write(buffer: Buffer, cb?: Function): boolean; 120 | write(str: string, cb?: Function): boolean; 121 | write(str: string, encoding: string, cb?: Function): boolean; 122 | write(str: string, encoding?: string, fd?: string): boolean; 123 | end(): void; 124 | end(chunk: Buffer, cb?: Function): void; 125 | end(chunk: string, cb?: Function): void; 126 | end(chunk: string, encoding: string, cb?: Function): void; 127 | pause(): void; 128 | resume(): void; 129 | abort(): void; 130 | destroy(): void; 131 | toJSON(): string; 132 | } 133 | 134 | export interface Headers { 135 | [key: string]: any; 136 | } 137 | 138 | export interface AuthOptions { 139 | user?: string; 140 | username?: string; 141 | pass?: string; 142 | password?: string; 143 | sendImmediately?: boolean; 144 | } 145 | 146 | export interface OAuthOptions { 147 | callback?: string; 148 | consumer_key?: string; 149 | consumer_secret?: string; 150 | token?: string; 151 | token_secret?: string; 152 | verifier?: string; 153 | } 154 | 155 | export interface HawkOptions { 156 | credentials: any; 157 | } 158 | 159 | export interface AWSOptions { 160 | secret: string; 161 | bucket?: string; 162 | } 163 | 164 | export interface CookieJar { 165 | setCookie(cookie: Cookie, uri: string|url.Url, options?: any): void 166 | getCookieString(uri: string|url.Url): string 167 | getCookies(uri: string|url.Url): Cookie[] 168 | } 169 | 170 | export interface CookieValue { 171 | name: string; 172 | value: any; 173 | httpOnly: boolean; 174 | } 175 | 176 | export interface Cookie extends Array { 177 | constructor(name: string, req: Request): void; 178 | str: string; 179 | expires: Date; 180 | path: string; 181 | toString(): string; 182 | } 183 | } 184 | } 185 | --------------------------------------------------------------------------------