├── Chrome ├── icon.png ├── icon19.png ├── icon38.png ├── icon48.png ├── login_success.js ├── manifest.json ├── options.css ├── stackexchange-inbox-api-chrome.js ├── options-chromeonly.js ├── localStorage-proxy.js ├── options.html ├── inbox.html ├── inbox.js ├── stackexchange-inbox-api.js ├── options.js └── using-websocket.js ├── .gitignore ├── screenshots ├── chrome_notif.png ├── chrome_options.png ├── chrome_inbox_panel.png ├── firefox_notif_win7.png ├── firefox_inbox_panel.png ├── firefox_notif_linux.png ├── firefox_notif_winxp.png ├── stackapps-screenshot.png ├── stackapps-thumbnail.png ├── firefox_options_widget.png └── firefox_inbox_panel_after_login.png ├── se-logo.svg ├── Firefox ├── manifest.json ├── README.md └── storage-sync-polyfill.js ├── Makefile └── README.md /Chrome/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/Chrome/icon.png -------------------------------------------------------------------------------- /Chrome/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/Chrome/icon19.png -------------------------------------------------------------------------------- /Chrome/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/Chrome/icon38.png -------------------------------------------------------------------------------- /Chrome/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/Chrome/icon48.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Firefox/* 2 | !Firefox/manifest.json 3 | !Firefox/storage-sync-polyfill.js 4 | !Firefox/README.md 5 | -------------------------------------------------------------------------------- /screenshots/chrome_notif.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/chrome_notif.png -------------------------------------------------------------------------------- /screenshots/chrome_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/chrome_options.png -------------------------------------------------------------------------------- /screenshots/chrome_inbox_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/chrome_inbox_panel.png -------------------------------------------------------------------------------- /screenshots/firefox_notif_win7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/firefox_notif_win7.png -------------------------------------------------------------------------------- /screenshots/firefox_inbox_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/firefox_inbox_panel.png -------------------------------------------------------------------------------- /screenshots/firefox_notif_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/firefox_notif_linux.png -------------------------------------------------------------------------------- /screenshots/firefox_notif_winxp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/firefox_notif_winxp.png -------------------------------------------------------------------------------- /screenshots/stackapps-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/stackapps-screenshot.png -------------------------------------------------------------------------------- /screenshots/stackapps-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/stackapps-thumbnail.png -------------------------------------------------------------------------------- /screenshots/firefox_options_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/firefox_options_widget.png -------------------------------------------------------------------------------- /screenshots/firefox_inbox_panel_after_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/stackexchange-notifications/HEAD/screenshots/firefox_inbox_panel_after_login.png -------------------------------------------------------------------------------- /Chrome/login_success.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | if (!/[#&]state=robw(&|$)/.test(location.hash)) { 3 | // OAuth not initiated by my application. 4 | return; 5 | } 6 | 7 | // Example of hash: #access_token=ZxqGlCmJzvrr99D(9dcEwA))&state=robw&expires=86400 8 | var token = location.hash.match(/\baccess_token=([^&]+)/)[1]; 9 | 10 | var x = new XMLHttpRequest(); 11 | x.open('GET', 'https://api.stackexchange.com/2.2/access-tokens/' + token); 12 | x.responseType = 'json'; 13 | x.onloadend = function() { 14 | var account_id = x.response && x.response.items[0].account_id; 15 | chrome.runtime.sendMessage({ 16 | auth_token: token, 17 | account_id: account_id, 18 | }); 19 | }; 20 | x.send(); 21 | })(); 22 | -------------------------------------------------------------------------------- /Chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Desktop Notifications for Stack Exchange", 3 | "description": "Real-time desktop notifications for the Stack Exchange.", 4 | "homepage_url": "https://stackapps.com/q/3780/9699", 5 | "version": "2.2", 6 | "manifest_version": 2, 7 | "background": { 8 | "scripts": [ 9 | "localStorage-proxy.js", 10 | "stackexchange-inbox-api.js", 11 | "stackexchange-inbox-api-chrome.js", 12 | "using-websocket.js", 13 | "bridge.js" 14 | ] 15 | }, 16 | "content_scripts": [{ 17 | "matches": ["https://stackexchange.com/oauth/login_success*"], 18 | "run_at": "document_end", 19 | "js": ["login_success.js"] 20 | }], 21 | "options_ui": { 22 | "page": "options.html" 23 | }, 24 | "optional_permissions": [ 25 | "background" 26 | ], 27 | "browser_action": { 28 | "default_popup": "inbox.html", 29 | "default_icon": { 30 | "19": "icon19.png", 31 | "38": "icon38.png" 32 | } 33 | }, 34 | "icons": { 35 | "48": "icon48.png", 36 | "128": "icon.png" 37 | }, 38 | "permissions": [ 39 | "https://api.stackexchange.com/*", 40 | "notifications", 41 | "storage", 42 | "tabs" 43 | ], 44 | "web_accessible_resources": [ 45 | "icon.png" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /se-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | image/svg+xml 4 | -------------------------------------------------------------------------------- /Firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Desktop Notifications for Stack Exchange", 3 | "description": "Real-time desktop notifications for the Stack Exchange.", 4 | "homepage_url": "https://stackapps.com/q/3780/9699", 5 | "version": "2.2", 6 | "manifest_version": 2, 7 | "background": { 8 | "scripts": [ 9 | "storage-sync-polyfill.js", 10 | "localStorage-proxy.js", 11 | "stackexchange-inbox-api.js", 12 | "stackexchange-inbox-api-chrome.js", 13 | "using-websocket.js", 14 | "bridge.js" 15 | ] 16 | }, 17 | "content_scripts": [{ 18 | "matches": ["https://stackexchange.com/oauth/login_success*"], 19 | "run_at": "document_end", 20 | "js": ["login_success.js"] 21 | }], 22 | "options_ui": { 23 | "page": "options.html" 24 | }, 25 | "browser_action": { 26 | "default_popup": "inbox.html", 27 | "default_icon": { 28 | "19": "icon19.png", 29 | "38": "icon38.png" 30 | } 31 | }, 32 | "icons": { 33 | "48": "icon48.png", 34 | "128": "icon.png" 35 | }, 36 | "permissions": [ 37 | "https://api.stackexchange.com/*", 38 | "notifications", 39 | "storage", 40 | "tabs" 41 | ], 42 | "web_accessible_resources": [ 43 | "icon.png" 44 | ], 45 | "applications": { 46 | "gecko": { 47 | "id": "stackexchange-notifications@jetpack" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: firefox chrome 2 | .PHONY: firefox chrome 3 | 4 | define mkicon 5 | inkscape -z -w "$2" -h "$2" se-logo.svg -e "$1icon$2.png" 6 | pngcrush -q -brute "$1icon$2.png"{,.} && mv "$1icon$2.png"{.,} 7 | endef 8 | 9 | chrome: 10 | cd Chrome && 7z u -tzip ../extension.zip * 11 | 12 | # After copying the source from Chrome, 13 | # remove unsupported keys (optional_permissions background), 14 | # and add applications.gecko.id. 15 | firefox: 16 | rsync -av Chrome/ Firefox/ --delete --exclude='.*' \ 17 | --exclude=README.md \ 18 | --exclude=storage-sync-polyfill.js 19 | cat Chrome/manifest.json | \ 20 | tr '\n' '\t' | \ 21 | sed 's/"localStorage-proxy.js"/"storage-sync-polyfill.js",\t \0/' | \ 22 | sed 's/"optional_permissions":[^]]\+][^"]\+//' | \ 23 | sed 's/\]\t\}/],\t "applications": {\t "gecko": {\t "id": "stackexchange-notifications@jetpack"\t }\t }\t}/' | \ 24 | tr '\t' '\n' > Firefox/manifest.json 25 | cat Chrome/options.html | \ 26 | tr '\n' '\t' | \ 27 | sed 's/ 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /Chrome/inbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SE Notification settings 6 | 7 | 136 | 137 | 138 |
139 |

Inbox

140 | 141 | all items 142 |
143 | 151 | 152 | 153 | 168 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /Chrome/inbox.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var markingAsRead = false; 4 | var markedAsRead = false; 5 | document.getElementById('mark-as-read').onclick = function(e) { 6 | e.preventDefault(); 7 | if (markingAsRead) { 8 | return; 9 | } 10 | markingAsRead = true; 11 | chrome.runtime.sendMessage('markAsRead', function(success) { 12 | document.getElementById('mark-as-read').hidden = true; 13 | if (!success) { 14 | reportError('innerHTML', 'Failed to mark inbox items as read. You have to log in at stackexchange.com before the inbox items can be marked as read.'); 15 | return; 16 | } 17 | markedAsRead = true; 18 | var unreadItmes = document.querySelectorAll('.unread-item'); 19 | for (var i = 0; i < unreadItmes.length; ++i) { 20 | unreadItmes[i].classList.remove('unread-item'); 21 | } 22 | }); 23 | }; 24 | 25 | function fetchUnreadContent() { 26 | chrome.runtime.sendMessage('getUnreadInboxApiUrl', function({apiUrl, inboxLink}) { 27 | document.querySelector('.all-items-link').href = 28 | document.querySelector('.all-items-bottom-link').href = inboxLink; 29 | 30 | if (apiUrl) { 31 | console.assert(apiUrl.startsWith('https://api.stackexchange.com/'), apiUrl + ' is an API URL'); 32 | // Note: No network error handling. StackExchange itself is also 33 | // poor at handling network errors; it just closes the inbox. 34 | fetch(apiUrl).then(function(res) { 35 | return res.json(); 36 | }).then(renderInbox, function(e) { 37 | reportError('textContent', 'Failed to load the inbox content. ' + e); 38 | }); 39 | } else { 40 | // Not sure if there are unread comments. 41 | // Don't show the mark-as-read option anyway, since checking the 42 | // succesfulness of markAsRead is difficult without authentication. 43 | // document.getElementById('mark-as-read').hidden = false; 44 | document.getElementById('tokenNote').hidden = false; 45 | } 46 | }); 47 | } 48 | 49 | // Prepend error to inbox and scroll to it if needed. 50 | function reportError(innerHTMLOrTextContent, htmlMessage) { 51 | var div = document.createElement('div'); 52 | div[innerHTMLOrTextContent] = htmlMessage; 53 | 54 | var li = document.createElement('li'); 55 | li.className = 'inbox-item inbox-error'; 56 | li.appendChild(div); 57 | 58 | var ul = document.querySelector('ul'); 59 | ul.insertBefore(li, ul.firstElementChild); 60 | document.querySelector('.modal-content').scrollTop = 0; 61 | } 62 | 63 | function htmlToText(html) { 64 | var template = document.createElement('template'); 65 | template.innerHTML = html; 66 | return template.content.textContent; 67 | } 68 | 69 | function renderInbox(inboxResponse) { 70 | // apiResponse = https://api.stackexchange.com/docs/inbox-unread 71 | var fragment = document.createDocumentFragment(); 72 | var inboxItemTemplate = document.getElementById('inboxItemTemplate').content.querySelector('li'); 73 | var hasAnyUnread = false; 74 | inboxResponse.items.forEach(function(item) { 75 | // https://api.stackexchange.com/docs/types/inbox-item 76 | var li = inboxItemTemplate.cloneNode(true); 77 | if (item.is_unread && !markedAsRead) { 78 | li.classList.add('unread-item'); 79 | hasAnyUnread = true; 80 | } 81 | if (/^https?:/.test(item.link)) { 82 | li.querySelector('.item-link').href = item.link; 83 | } 84 | if (item.site) { 85 | li.querySelector('.site-icon').name = item.site.name; 86 | li.querySelector('.site-icon').style.backgroundImage = 'url(' + item.site.favicon_url + ')'; 87 | } 88 | li.querySelector('.item-type').textContent = formatItemType(item.item_type); 89 | 90 | var date = new Date(item.creation_date * 1000); 91 | li.querySelector('.item-creation').title = absoluteTime(item.creation_date); 92 | li.querySelector('.item-creation').textContent = friendlyTime(item.creation_date); 93 | 94 | li.querySelector('.item-location').textContent = htmlToText(item.title); 95 | li.querySelector('.item-summary').textContent = htmlToText(item.body); 96 | fragment.appendChild(li); 97 | }); 98 | 99 | if (hasAnyUnread) { 100 | document.getElementById('mark-as-read').hidden = false; 101 | } 102 | 103 | var ul = document.querySelector('ul'); 104 | // Prepend before the "show all item content" link. 105 | ul.insertBefore(fragment, ul.lastElementChild); 106 | // Show "show all item content" so that the inbox always looks non-empty and not overflown. 107 | ul.lastElementChild.hidden = false; 108 | } 109 | 110 | function formatItemType(itemType) { 111 | // https://api.stackexchange.com/docs/types/inbox-item 112 | // "one of comment, chat_message, new_answer, careers_message, 113 | // careers_invitations, meta_question, post_notice, or moderator_message" 114 | // 115 | // "Be aware that the types of items returned by this method are subject to 116 | // change at any time. In particular, new types may be introduced without 117 | // warning. Applications should deal with these changes gracefully." 118 | 119 | if (!itemType) { 120 | // Unexpected...? 121 | return ''; 122 | } 123 | return itemType.replace(/_/g, ' '); 124 | } 125 | 126 | // Below time formatters are from https://dev.stackoverflow.com/content/Js/full-anon.en.js 127 | var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 128 | function pad(n) { 129 | return n < 10 ? '0' + n : n; 130 | } 131 | function absoluteTime(ut) { 132 | var date = new Date(); 133 | date.setTime(ut * 1000); 134 | 135 | return [ 136 | date.getUTCFullYear(), 137 | '-', pad(date.getUTCMonth() + 1), 138 | '-', pad(date.getUTCDate()), 139 | ' ', pad(date.getUTCHours()), 140 | ':', pad(date.getUTCMinutes()), 141 | ':', pad(date.getUTCSeconds()), 142 | 'Z' 143 | ].join(''); 144 | } 145 | function friendlyTime(dt) { 146 | var utcNow = Math.floor((new Date()).getTime() / 1000); 147 | 148 | var delta = utcNow - dt; 149 | var seconds = delta % 60; 150 | var minutes = Math.floor(delta / 60); 151 | var hours = Math.floor(delta / 3600); 152 | if (delta < 1) { 153 | return 'just now'; 154 | } 155 | if (delta < 60) { 156 | return (function(n) { 157 | return n.seconds == 1 ? n.seconds + ' sec ago' : n.seconds + ' secs ago'; 158 | })({ 159 | seconds: seconds 160 | }); 161 | } 162 | if (delta < 3600) // 60 mins * 60 sec 163 | { 164 | return (function(n) { 165 | return n.minutes == 1 ? n.minutes + ' min ago' : n.minutes + ' mins ago'; 166 | })({ 167 | minutes: minutes 168 | }); 169 | } 170 | if (delta < 86400) // 24 hrs * 60 mins * 60 sec 171 | { 172 | return (function(n) { 173 | return n.hours == 1 ? n.hours + ' hour ago' : n.hours + ' hours ago'; 174 | })({ 175 | hours: hours 176 | }); 177 | } 178 | 179 | var days = Math.floor(delta / 86400); 180 | 181 | if (days == 1) { 182 | return 'yesterday'; 183 | } else if (days <= 2) { 184 | return (function(n) { 185 | return n.__count == 1 ? n.__count + ' day ago' : n.__count + ' days ago'; 186 | })({ 187 | __count: days 188 | }); 189 | } 190 | 191 | var date = new Date(dt * 1000); 192 | return (function(n) { 193 | return n.month + ' ' + n.date + ' at ' + n.hours + ':' + n.minutes; 194 | })({ 195 | month: months[date.getMonth()], 196 | date: date.getDate(), 197 | hours: date.getHours(), 198 | minutes: pad(date.getMinutes()) 199 | }); 200 | } 201 | 202 | fetchUnreadContent(); 203 | -------------------------------------------------------------------------------- /Chrome/stackexchange-inbox-api.js: -------------------------------------------------------------------------------- 1 | (function(exports) { 2 | // Rob's API details 3 | var API_KEY = 'vNbKvxXtPqz2b7HO*24E2A(('; 4 | var API_CLIENT_ID = '903'; 5 | 6 | // URLs for getting API token. Sufficed URL with "robw" to create a semi-unique URL to avoid conflicts with others 7 | // See also https://stackapps.com/questions/8233/oauth-redirect-uri-to-https-stackexchange-com-oauth-login-successxxx-strips-p 8 | var _api_auth_redirect_url = 'https://stackexchange.com/oauth/login_success'; 9 | var API_AUTH_URL = 'https://stackexchange.com/oauth/dialog?' + 10 | 'client_id=' + API_CLIENT_ID + 11 | '&scope=no_expiry,read_inbox' + 12 | '&state=robw' + 13 | '&redirect_uri=' + encodeURIComponent(_api_auth_redirect_url); 14 | 15 | // Multiple API filters to bypass the cache 16 | // Calculate filters at https://api.stackexchange.com/docs/inbox#pagesize=20&filter=!%29r4d5VTgcrvtUU_Bu3I5&run=true 17 | // 18 | // `is_unread` is the information we need. 19 | // `site` alone does not return extra information 20 | // `question_id`, `item_type`, `answer_id`, `comment_id` are reasonably small values 21 | // 22 | // Using 10 filters, and 60 different page sizes, we can new values 600 times. 23 | var API_FILTERS = [ 24 | '!)r4d5VTgcrvtUU_Bu3I5', // is_unread 25 | '!LSz0qj2Wk0sCvrPsvrxAs1', // is_unread + site 26 | '!)r4d5VTgcj78PZBzhVfa', // is_unread + item_type 27 | '!LSz0qj2Wk0s476KxYakcBX', // is_unread + item_type + site 28 | '!LSz0qj2Weapn)PFIwGRjd*', // is_unread + question_id 29 | '!LSz0qj2Wk0MEDssKb7Z((X', // is_unread + question_id + site 30 | '!LSz0qj2Wcn70m_zKsxpXR1', // is_unread + comment_id 31 | '!0UYY-ITj0_U7w4VST9Dmw-S0*', // is_unread + comment_id + site 32 | '!)r4d5VTgcrvstSSV)86a', // is_unread + answer_id 33 | '!LSz0qj2Wk0sCvqoqo6.FgX' // is_unread + answer_id + site 34 | ]; 35 | var API_MINIMUM_PAGESIZE = 40; 36 | var API_MAXIMUM_PAGESIZE = 100; 37 | 38 | // basic = body + creation_date + is_unread + item_type + link + site + title + site.favicon_url + site.name (+unsafe filter) 39 | // https://api.stackexchange.com/docs/inbox#filter=)-wpJO8qkn.bBXNMrk*hhvIVGJM 40 | var API_CONTENT_FILTERS = [ 41 | ')-wpJO8qkn.bBXNMrk*hhvIVGJM', // basic 42 | '00A4G31njJrnVua5SFynYaYA', // basic + question_id 43 | ')-wpJO8qkn.bBXNsIUQRs0MFdgr', // basic + comment_id 44 | ')-wpJO8qkn.bBXNMSIc*YZ9p2Lj', // basic + answer_id 45 | '00A4G31njJrnVV8jm5besN_Y', // basic + question_id + answer_id 46 | '00A4G31njJro)LKYBQ3rIyvf ', // basic + question_id + comment_id 47 | '00A4G31njJrsptLF7H_G9SEf', // basic + comment_id + answer_id 48 | ')IfYcugX7YVvCSCm6_PswS2', // basic + question_id + comment_id + answer_id 49 | ]; 50 | 51 | ///////////////////// 52 | // API Definition // 53 | ///////////////////// 54 | /** 55 | * Emitted events: 56 | * - change:unread 57 | * - change:token 58 | * - error 59 | */ 60 | var StackExchangeInbox = { 61 | auth: { 62 | requestToken: requestToken, // void requestToken() 63 | getToken: getToken, // string getToken() 64 | setToken: setToken, // void setToken(string token) 65 | API_AUTH_URL: API_AUTH_URL 66 | }, 67 | fetchUnreadCount: fetchUnreadCount, // void fetchUnreadCount( function callback(unreadCount) ) 68 | getUnreadInboxApiUrl: getUnreadInboxApiUrl, // string getUnreadInboxApiUrl() 69 | markAsRead: markAsRead, // void markAsRead( function(isSuccess) ) 70 | // Very simple event emitter 71 | _callbacks: {}, 72 | emit: function(method, data) { 73 | var callbacks = this._callbacks[method] || []; 74 | for (var i=0; i API_MINIMUM_PAGESIZE) { 110 | // Page can be decreased 111 | _api_pagesize--; 112 | } else { 113 | // We've hit the bottom page size. Go to the max page size and change filter 114 | _api_pagesize = API_MAXIMUM_PAGESIZE; 115 | ++_api_filter; 116 | if (_api_filter >= API_FILTERS.length) { 117 | // Next filter does not exists, use first one. 118 | _api_filter = 0; 119 | } 120 | } 121 | var url = 'https://api.stackexchange.com/2.1/inbox'; 122 | url += '?key=' + API_KEY; 123 | url += '&access_token=' + StackExchangeInbox.auth.getToken(); 124 | url += '&filter=' + API_FILTERS[_api_filter]; 125 | url += '&pagesize=' + _api_pagesize; 126 | return url; 127 | } 128 | 129 | var _api_content_filter = 0; 130 | function getUnreadInboxApiUrl() { 131 | if (!StackExchangeInbox.auth.getToken()) { 132 | return ''; 133 | } 134 | // Let's keep this simple, without paging. 135 | var url = 'https://api.stackexchange.com/2.1/inbox'; 136 | url += '?key=' + API_KEY; 137 | url += '&access_token=' + StackExchangeInbox.auth.getToken(); 138 | url += '&filter=' + API_CONTENT_FILTERS[_api_content_filter]; 139 | url += '&pagesize=32'; 140 | 141 | _api_content_filter = (_api_content_filter + 1) % API_CONTENT_FILTERS.length; 142 | 143 | return url; 144 | } 145 | 146 | // Get inbox entries 147 | function fetchUnreadCount(callback) { 148 | callback = callback || function(unreadCount) {}; 149 | if (!StackExchangeInbox.auth.getToken()) { 150 | // No token? No request! 151 | StackExchangeInbox.emit('error', 'No access token found, cannot connect to StackExchange API'); 152 | callback(-1); 153 | return; 154 | } 155 | var url = generateStackExchangeAPIURL(); 156 | var x = new XMLHttpRequest(); 157 | x.open('GET', url); 158 | x.onload = function() { 159 | var data = JSON.parse(x.responseText); 160 | if (data.error_id) { 161 | StackExchangeInbox.emit('error', 'API responded with ' + data.error_id + ' ' + data.error_name + ', ' + data.error_message); 162 | if (data.error_id == 403) { 163 | // Token is invalid, Discard it 164 | StackExchangeInbox.auth.setToken(''); 165 | } 166 | callback(-1); 167 | return; 168 | } 169 | var unreadCount = data.items.reduce(function(unreadCount, item) { 170 | return unreadCount += item.is_unread ? 1 : 0; 171 | }, 0); 172 | StackExchangeInbox.emit('change:unread', unreadCount); 173 | callback(unreadCount); 174 | }; 175 | x.onerror = function() { 176 | StackExchangeInbox.emit('error', 'Failed to get unread count: ' + x.status + ' ' + x.statusText); 177 | callback(-1); 178 | }; 179 | x.send(); 180 | } 181 | 182 | function markAsRead(callback) { 183 | var loader = new Image(); 184 | loader.onload = loader.onerror = function() { 185 | loader.onload = loader.onerror = null; 186 | fetchUnreadCount(function(unreadCount) { 187 | // successfully marked as unread if the count is 0. 188 | callback(unreadCount === 0); 189 | }); 190 | }; 191 | loader.src = 'https://stackexchange.com/topbar/inbox?_=' + Date.now(); 192 | } 193 | 194 | exports.StackExchangeInbox = StackExchangeInbox; 195 | })(typeof exports == 'undefined' ? this : exports); 196 | -------------------------------------------------------------------------------- /Chrome/options.js: -------------------------------------------------------------------------------- 1 | /* globals chrome */ 2 | var optionsPort; 3 | 4 | function initOptionsPort(onReady, retryAttempt) { 5 | optionsPort = null; 6 | if (retryAttempt) { 7 | console.warn('Retry after disconnect from background, attempt ' + retryAttempt); 8 | } 9 | var port = chrome.runtime.connect({name: 'options-to-bg'}); 10 | port.onDisconnect.addListener(function() { 11 | optionsPort = null; 12 | setTimeout(initOptionsPort, 50, onReady, retryAttempt + 1); 13 | }); 14 | port.onMessage.addListener(function(msg) { 15 | switch (msg.type) { 16 | case 'ready': 17 | optionsPort = port; 18 | retryAttempt = 0; 19 | 20 | _initDefaultLinkExample(msg.defaultLinkExample); 21 | 22 | socketEventListener(msg.socketStatus == 1 ? 'open' : 'close'); 23 | _uidChange(msg.uid); 24 | _linkChange(msg.link); 25 | _unreadChange(msg.unreadCount); 26 | _tokenChange(msg.token); 27 | validateUIDInput(); 28 | uidToName(msg.uid); 29 | onReady(); 30 | break; 31 | case 'emit:socket': 32 | socketEventListener(msg.data); 33 | break; 34 | case 'emit:change:uid': 35 | _uidChange(msg.data); 36 | break; 37 | case 'emit:change:link': 38 | _linkChange(msg.data); 39 | break; 40 | case 'emit:change:unread': 41 | _unreadChange(msg.data); 42 | break; 43 | case 'emit:change:token': 44 | _tokenChange(msg.data); 45 | break; 46 | case 'emit:found:account_id': 47 | _foundAccountID(msg.data); 48 | break; 49 | } 50 | }); 51 | } 52 | 53 | var uid = document.getElementById('uid'); 54 | var link = document.getElementById('link'); 55 | var tokenButton = document.getElementById('grant-token'); 56 | var save = document.getElementById('save'); 57 | 58 | var statusSpan = document.getElementById('status'); 59 | var socketStart = document.getElementById('socket-start'); 60 | var socketStop = document.getElementById('socket-stop'); 61 | 62 | var autostart = document.getElementById('autostart'); 63 | var use_desktop_notifications = document.getElementById('use_desktop_notifications'); 64 | 65 | function _initDefaultLinkExample(defaultLinkExample) { 66 | if (link.placeholder) return; // Run once. 67 | link.placeholder = defaultLinkExample; 68 | link.title += ' Defaults to ' + defaultLinkExample; 69 | document.getElementById('default-link').textContent = defaultLinkExample; 70 | } 71 | 72 | tokenButton.onclick = function() { 73 | optionsPort.postMessage({type: 'requestToken'}); 74 | }; 75 | 76 | // When no preference is set, set autostart to true 77 | autostart.checked = localStorage.getItem('autostart') != '0'; 78 | autostart.onchange = function() { 79 | localStorage.setItem('autostart', this.checked ? '1' : '0'); 80 | }; 81 | 82 | use_desktop_notifications.checked = localStorage.getItem('use_desktop_notifications') != '0'; 83 | use_desktop_notifications.onchange = function() { 84 | localStorage.setItem('use_desktop_notifications', this.checked ? '1' : '0'); 85 | }; 86 | 87 | 88 | function updateSaveButtonState() { 89 | // Disable Save if the fields are not changed and/or invalid 90 | save.disabled = !document.querySelector('.changed:not(.invalid)'); 91 | } 92 | updateSaveButtonState(); 93 | // Save uid and link settings 94 | var saveFields = save.onclick = function() { 95 | optionsPort.postMessage({type: 'setUserID', data: uid.value}); 96 | optionsPort.postMessage({type: 'setLink', data: link.value}); 97 | uid.classList.remove('changed'); 98 | link.classList.remove('changed'); 99 | updateSaveButtonState(); 100 | }; 101 | link.oninput = function(ev) { 102 | if (this.value != this.defaultValue) { 103 | this.classList.add('changed'); 104 | } else { 105 | this.classList.remove('changed'); 106 | } 107 | updateSaveButtonState(); 108 | }; 109 | uid.onkeydown = link.onkeydown = function(ev) { 110 | if (ev.which == 13) saveFields(); 111 | }; 112 | 113 | // UID change & name lookup 114 | var _api_xhr; 115 | var _currentlyCheckingUID = -1; 116 | var _l1_cache = {} 117 | var _l2_cache = {}; 118 | var _timedoutRequest; 119 | function getUIDFromInput(input_string) { 120 | var match = /^\s*(\d+)\s*$/.exec(input_string); 121 | return match ? match[1] : null; 122 | } 123 | var validateUIDInput = uid.oninput = function() { 124 | clearTimeout(_timedoutRequest); 125 | // Change the appearance only if the uid really changed 126 | var val = getUIDFromInput(uid.value); 127 | if (val != getUIDFromInput(uid.defaultValue)) { 128 | uid.classList.add('changed'); 129 | } else { 130 | uid.classList.remove('changed'); 131 | } 132 | if (getUIDFromInput(uid.value) === null) { 133 | uid.classList.add('invalid'); 134 | } else { 135 | uid.classList.remove('invalid'); 136 | document.getElementById('display-name').textContent = ''; 137 | if (val > 0) { 138 | if (_l1_cache[val]) { 139 | // Already cached, try to get the value 140 | siteuidToName(_l1_cache[val], val); 141 | } else { 142 | _timedoutRequest = setTimeout(uidToName, 300, val); 143 | } 144 | } 145 | } 146 | updateSaveButtonState(); 147 | }; 148 | // Given a UID, a name is fetched using the Stack Exchange API 149 | function uidToName(val) { 150 | val = +val; 151 | if (!(val > 0)) return; 152 | if (_l1_cache[val]) { 153 | siteuidToName(_l1_cache[val], val); 154 | return; 155 | } 156 | if (_currentlyCheckingUID == val) return; 157 | 158 | // No concurrent requests 159 | if (_api_xhr) try { 160 | var tmp = _api_xhr; 161 | _api_xhr = null; 162 | tmp.abort(); 163 | }catch(e){} 164 | 165 | _api_xhr = new XMLHttpRequest(); 166 | _api_xhr.onload = function() { 167 | if (this === _api_xhr) _api_xhr = null; 168 | var result = JSON.parse(this.responseText); 169 | if (result && result.items && result.items[0]) { 170 | _l1_cache[val] = result.items[0]; 171 | siteuidToName(_l1_cache[val], val); 172 | } else { 173 | _l1_cache[val] = {}; 174 | document.getElementById('display-name').textContent = 'N/A'; 175 | } 176 | }; 177 | _api_xhr.onabort = 178 | _api_xhr.onerror = function() { 179 | if (this === _api_xhr) { 180 | _api_xhr = null; 181 | document.getElementById('display-name').textContent = ''; 182 | } 183 | }; 184 | _api_xhr.open('GET', 'https://api.stackexchange.com/2.1/users/' + val + '/associated?pagesize=1&filter=!T*uyp79PvoVzKR.KV1'); 185 | _currentlyCheckingUID = val; 186 | document.getElementById('display-name').innerHTML = ''; 192 | _api_xhr.send(); 193 | } 194 | // Intended to only be called by uidToName. 195 | function siteuidToName(result, /*number*/ uid_value) { 196 | var _tmp = getUIDFromInput(uid.value); 197 | if (_tmp === null || +_tmp !== uid_value) { 198 | // ID changed during request. Don't look further 199 | return; 200 | } 201 | var site = result.site_url; 202 | if (site) site = site.split('//').pop(); 203 | var siteuid = result.user_id; 204 | if (!site) { 205 | document.getElementById('display-name').textContent = 'N/A'; 206 | return; 207 | } 208 | if (_l2_cache[site + siteuid]) { 209 | document.getElementById('display-name').textContent = _l2_cache[site + siteuid]; 210 | return; 211 | } 212 | 213 | // No concurrent requests 214 | if (_api_xhr) try { 215 | var tmp = _api_xhr; 216 | _api_xhr = null; 217 | tmp.abort(); 218 | }catch(e){} 219 | 220 | _api_xhr = new XMLHttpRequest(); 221 | _api_xhr.onload = function() { 222 | if (this === _api_xhr) _api_xhr = null; 223 | var result = JSON.parse(this.responseText); 224 | result = result.items && result.items[0] ? result.items[0].display_name || '(empty)' : 'N/A'; 225 | _l2_cache[site + siteuid] = result; 226 | document.getElementById('display-name').textContent = result; 227 | }; 228 | _api_xhr.onabort = 229 | _api_xhr.onerror = function() { 230 | if (this === _api_xhr) { 231 | _api_xhr = null; 232 | document.getElementById('display-name').textContent = ''; 233 | } 234 | }; 235 | _api_xhr.open('GET', 'https://api.stackexchange.com/2.1/users/' + siteuid + '?site=' + site + '&filter=!)RwZ73MVf)pA)F7A2gmXoGZm'); 236 | _api_xhr.send(); 237 | } 238 | 239 | // Start/stop socket feature 240 | socketStart.onclick = function() { 241 | optionsPort.postMessage({type: 'startSocket'}); 242 | }; 243 | socketStop.onclick = function() { 244 | optionsPort.postMessage({type: 'stopSocket'}); 245 | }; 246 | 247 | var __uid = ''; 248 | var __socketStatus = 0; 249 | 250 | // Event emitter event listeners 251 | function _uidChange(value) { 252 | __uid = value; 253 | uid.defaultValue = uid.value = value; 254 | // TODO: unreachable code in if. 255 | if (value != uid.value) { 256 | uidToName(value); 257 | } 258 | // If socket has been started, then disable button 259 | socketStart.disabled = __socketStatus == 1; 260 | } 261 | function _linkChange(value) { 262 | link.defaultValue = link.value = value; 263 | } 264 | function _unreadChange(unreadCount) { 265 | unreadCount = unreadCount ? '(' + unreadCount + ')' : ''; 266 | document.getElementById('unread-count').textContent = unreadCount; 267 | } 268 | function _tokenChange(token) { 269 | if (token) { 270 | tokenButton.value = 'Token accepted'; 271 | tokenButton.disabled = true; 272 | } else { 273 | tokenButton.value = 'Grant token'; 274 | tokenButton.disabled = false; 275 | } 276 | } 277 | 278 | function _foundAccountID(account_id) { 279 | // Happens after successful authentication 280 | uid.value = account_id; 281 | validateUIDInput(); 282 | } 283 | 284 | function socketEventListener(status) { 285 | if (status == 'open') { 286 | statusSpan.textContent = 'listening'; 287 | socketStart.disabled = true; 288 | socketStop.disabled = false; 289 | } else if (status == 'close') { 290 | statusSpan.textContent = 'stopped'; 291 | // Only show if uid is defined 292 | socketStart.disabled = !__uid; 293 | socketStop.disabled = true; 294 | } 295 | } 296 | 297 | window.optionsInitPromise = window.localStoragePromise.then(function() { 298 | return new Promise(function(resolve) { 299 | initOptionsPort(resolve, 0); 300 | }); 301 | }); 302 | -------------------------------------------------------------------------------- /Chrome/using-websocket.js: -------------------------------------------------------------------------------- 1 | /* globals chrome, StackExchangeInbox */ 2 | // Highly specific piece of code for subscribing to inbox notifications 3 | var METHOD; // UID + '-inbox'; 4 | 5 | // Maintain WebSocket connection 6 | var SOCKET_URL = 'wss://qa.sockets.stackexchange.com'; 7 | 8 | // Very simple event emitter 9 | var eventEmitter = { 10 | _callbacks: {}, 11 | emit: function(method, data) { 12 | var callbacks = this._callbacks[method] || []; 13 | for (var i=0; i 6) { 166 | console.log('Last heartbeat was ' + diff + ' seconds ago. Resetting socket...'); 167 | // Reset socket when the socket died (heartbeat should occur every 5 minutes) 168 | restartSocket(); 169 | } 170 | }, 5000); // Small delay, because getting a timestamp and calculating the diff is inexpensive 171 | 172 | eventEmitter.emit('socket', 'open'); 173 | }; 174 | ws.onmessage = function(ev) { 175 | var message = JSON.parse(ev.data); 176 | if (message.action == 'hb') { 177 | ws.send(message.data); 178 | lastHeartbeat = Date.now(); 179 | } 180 | if (message.action == method) { 181 | if (typeof message.data == 'string') { 182 | // The data appears to be JSON-encoded twice. So unpack it again. 183 | message.data = JSON.parse(message.data); 184 | } 185 | if (message.data && message.data.Inbox) { 186 | setUnreadCount(message.data.Inbox.UnreadInboxCount); 187 | } 188 | } 189 | 190 | eventEmitter.emit('socket', 'message'); 191 | }; 192 | ws.onclose = function() { 193 | console.log('Closed WebSocket'); 194 | restartSocket(); 195 | }; 196 | ws.onerror = function() { 197 | console.log('WebSocket failed'); 198 | restartSocket(); 199 | }; 200 | } 201 | function getSocketStatus() { 202 | return ws ? ws.readyState : 0; 203 | } 204 | function stopSocket(explicitStop) { 205 | if (explicitStop) socket_keep_alive = false; 206 | clearTimeout(delayedSocketStarter); 207 | if (!ws) { 208 | return; 209 | } 210 | try { 211 | ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null; 212 | clearInterval(ws._socketWatcher); 213 | if (ws.readyState !== 2 && ws.readyState !== 3) { // Not closed yet. 214 | console.log('Closing (existing) WebSocket'); 215 | ws.close(); 216 | } 217 | } catch(e) {} 218 | ws = null; 219 | eventEmitter.emit('socket', 'close'); 220 | } 221 | 222 | function restartSocket() { 223 | if (!socket_keep_alive) return; 224 | stopSocket(); // Will automatically reconnect because socket_keep_alive is true; 225 | delayedSocketStarter = setTimeout(startSocket, getReconnectDelay()); 226 | } 227 | 228 | // Stack Exchange User ID 229 | function getUserID() { 230 | return localStorage.getItem('stackexchange-user-id'); 231 | } 232 | function setUserID(id) { 233 | id = /\d*/.exec(id)[0]; 234 | var previousID = getUserID(); 235 | localStorage.setItem('stackexchange-user-id', id); 236 | if (id != previousID) eventEmitter.emit('change:uid', id); 237 | } 238 | 239 | 240 | // Link on click 241 | function getLink() { 242 | return localStorage.getItem('open-on-click'); 243 | } 244 | function generateDefaultLink(uid) { 245 | return 'https://stackexchange.com/users/' + (uid || getUserID()) + '/?tab=inbox'; 246 | } 247 | function setLink(link) { 248 | var previousLink = getLink(); 249 | localStorage.setItem('open-on-click', link); 250 | if (previousLink != link) eventEmitter.emit('change:link', link); 251 | } 252 | 253 | 254 | // Get count 255 | var _unreadCount = 0; 256 | function setUnreadCount(count) { 257 | _unreadCount = +count; 258 | eventEmitter.emit('change:unread', _unreadCount); 259 | } 260 | function getUnreadCount() { 261 | return _unreadCount; 262 | } 263 | 264 | // Notification 265 | var _notification, _currentNotificationID; 266 | var CHROME_NOTIFICATION_ID = 'se-notifications'; 267 | var CONFIG_NOTIFICATION_ID = 'CONFIG_PROMPT'; 268 | var chromeNotificationSupportsClick = true; 269 | var chromeNotificationSupportsPersistence = true; 270 | if (chrome.notifications) { 271 | chrome.notifications.onClicked.addListener(function(notificationId) { 272 | if (notificationId === CHROME_NOTIFICATION_ID) { 273 | openTab(getLink() || generateDefaultLink()); 274 | chrome.notifications.clear(notificationId, function() {}); 275 | } 276 | if (notificationId === CONFIG_NOTIFICATION_ID) { 277 | ensureOneOptionsPage(); 278 | chrome.notifications.clear(notificationId, function() {}); 279 | } 280 | }); 281 | 282 | try { 283 | chrome.notifications.update('', {requireInteraction: false}, function() {}); 284 | } catch (e) { 285 | // This feature shipped in Chrome 50.0.2638.0 (https://crbug.com/574763) 286 | chromeNotificationSupportsPersistence = false; 287 | } 288 | 289 | try { 290 | chrome.notifications.update('', {isClickable: false}, function() {}); 291 | } catch (e) { 292 | // This feature was added in 32.0.1676.0 (http://crbug.com/304923) 293 | chromeNotificationSupportsClick = false; 294 | } 295 | } 296 | 297 | function chromeNotificationsCreate(notificationId, notificationOptions) { 298 | if (chromeNotificationSupportsClick) { 299 | notificationOptions.isClickable = true; 300 | } 301 | if (chromeNotificationSupportsPersistence) { 302 | notificationOptions.requireInteraction = localStorage.getItem('persist_notification') !== ''; 303 | } 304 | chrome.notifications.create(notificationId, notificationOptions, function() {}); 305 | } 306 | 307 | function showNotification() { 308 | var notID = _currentNotificationID = new Date().getTime(); 309 | if (_notification) _notification.cancel(); 310 | else if (chrome.notifications) chrome.notifications.clear(CHROME_NOTIFICATION_ID, function() {}); 311 | if (getUnreadCount() > 0) { 312 | var iconURL = chrome.runtime.getURL('icon.png'); 313 | var head = getUnreadCount() + ' unread messages in your inbox'; 314 | var body = ''; 315 | if (!window.webkitNotifications) { 316 | var notificationOptions = { 317 | type: 'basic', 318 | iconUrl: iconURL, 319 | title: head, 320 | message: body 321 | }; 322 | chromeNotificationsCreate(CHROME_NOTIFICATION_ID, notificationOptions); 323 | return; 324 | } 325 | _notification = webkitNotifications.createNotification(iconURL, head, body); 326 | _notification.onclose = function() { 327 | if (_currentNotificationID == notID) { 328 | _notification = null; 329 | } 330 | }; 331 | _notification.onclick = function() { 332 | openTab(getLink() || generateDefaultLink()); 333 | _notification.cancel(); 334 | }; 335 | _notification.show(); 336 | } 337 | } 338 | function updateBageText() { 339 | chrome.browserAction.setBadgeText({ 340 | text: String(getUnreadCount() || ''), 341 | }); 342 | } 343 | 344 | // When the UID changes, restart socket (socket will be closed if UID is empty) 345 | eventEmitter.on('change:uid', function(id) { 346 | if (localStorage.getItem('autostart') != '0') startSocket(); 347 | }); 348 | // When unread count is set, show a notification 349 | eventEmitter.on('change:unread', function() { 350 | if (localStorage.getItem('use_desktop_notifications') != '0') showNotification(); 351 | }); 352 | eventEmitter.on('change:unread', updateBageText); 353 | StackExchangeInbox.on('change:unread', setUnreadCount); 354 | StackExchangeInbox.on('error', function(error_message) { 355 | console.log(error_message); 356 | }); 357 | StackExchangeInbox.on('found:account_id', function(account_id) { 358 | setUserID(account_id); 359 | }); 360 | 361 | window.localStoragePromise.then(function() { 362 | // Start socket with default settings if possible 363 | if (localStorage.getItem('autostart') != '0') startSocket(); 364 | }); 365 | --------------------------------------------------------------------------------