var settings, cache, utils, button, reddit, background; settings = new Store('settings', { 'cacheTime': 3, 'timeoutLength': 5, 'freshCutoff': 7, 'popupWidth': 640 }); cache = new Store('cache'); /** * Create a new framework of utility functions. * @classDescription Creates a new framework of utility functions. * @type {Object} * @return {Boolean} Returns true. * @constructor */ function MHUtils() { return true; } /** * Escape special RegExp characters. * @alias MHUtils.regexEscape(str) * @param {String} str The string to be escaped * @return {String} Returns an escaped string * @method */ MHUtils.prototype.regexEscape = function (str) { return str.replace(/([.?*+\^$\[\]\\(){}\-])/g, "\\$1"); }; /** * Find the UNIX epoch time. * @alias MHUtils.epoch() * @return {Number} Returns the current epoch time * @method */ MHUtils.prototype.epoch = function () { return Math.floor(new Date().getTime() / 1000); }; /** * Iterate over an object. * @alias MHUtils.forEachIn(object, action) * @return {Boolean} Returns true. * @method */ MHUtils.prototype.forEachIn = function (object, action) { for (var property in object) { if (object.hasOwnProperty(property)) { action(property, object[property]); } } return true; }; /** * Iterates over an array. * @alias MHUtils.forEach(array, action) * @return {Boolean} Returns true. * @method */ MHUtils.prototype.forEach = function (array, action) { for (var i = 0; i < array.length; i++) { action(array[i]); } return true; } /* * JavaScript Pretty Date * Thanks to Dean Landolt's comment on * http://ejohn.org/blog/javascript-pretty-date/#postcomment */ // Takes an ISO time and returns a string representing how // long ago the date represents. MHUtils.prototype.prettyDate = function (date_str) { var time_formats = [ [60, 'just now', 1], // 60 [120, '1 minute ago', '1 minute from now'], // 60*2 [3600, 'minutes', 60], // 60*60, 60 [7200, '1 hour ago', '1 hour from now'], // 60*60*2 [86400, 'hours', 3600], // 60*60*24, 60*60 [172800, 'yesterday', 'tomorrow'], // 60*60*24*2 [604800, 'days', 86400], // 60*60*24*7, 60*60*24 [1209600, 'last week', 'next week'], // 60*60*24*7*4*2 [2419200, 'weeks', 604800], // 60*60*24*7*4, 60*60*24*7 [4838400, 'last month', 'next month'], // 60*60*24*7*4*2 [29030400, 'months', 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 [58060800, 'last year', 'next year'], // 60*60*24*7*4*12*2 [2903040000, 'years', 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 [5806080000, 'last century', 'next century'], // 60*60*24*7*4*12*100*2 [58060800000, 'centuries', 2903040000] // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 ]; var time = ('' + date_str).replace(/-/g,"/").replace(/[TZ]/g," "); then = new Date(time); utcTime = Date.UTC(then.getFullYear(), then.getMonth(), then.getDate(), then.getHours(), then.getMinutes(), then.getSeconds(), then.getMilliseconds()) var seconds = (new Date - new Date(utcTime)) / 1000; var token = 'ago', list_choice = 1; if (seconds < 0) { seconds = Math.abs(seconds); token = 'from now'; list_choice = 2; } var i = 0, format; while (format = time_formats[i++]) if (seconds < format[0]) { if (typeof format[2] == 'string') return format[list_choice]; else return Math.floor(seconds / format[2]) + ' ' + format[1] + ' ' + token; } return time; }; /* * ISO 8601 Formatted Dates * Gotten from the Mozilla Developer Center */ MHUtils.prototype.ISODateString = function (d) { function pad(n){return n<10 ? '0'+n : n} return d.getUTCFullYear()+'-' + pad(d.getUTCMonth()+1)+'-' + pad(d.getUTCDate())+'T' + pad(d.getUTCHours())+':' + pad(d.getUTCMinutes())+':' + pad(d.getUTCSeconds())+'Z' }; utils = new MHUtils(); /** * Create a new instance of the browser action button and its popup. ONLY CALL THIS ONCE! * @classDescription Creates a new browser icon. * @type {Object} * @return {Boolean} Returns true. * @constructor */ function BrowserAction() { return true; } /** * Set the browser icon badge to its defaults. * @alias BrowserAction.setBadgeDefaults(tabId) * @param {Number} tabId If given, only sets badge defaults for this tab. * @return {Boolean} Returns true. * @method */ BrowserAction.prototype.setBadgeDefaults = function (tabId) { chrome.browserAction.setBadgeText({'text': '?', 'tabId': tabId}); chrome.browserAction.setTitle({'title': 'Click to load data.', 'tabId': tabId}); chrome.browserAction.setBadgeBackgroundColor({'color': [192, 192, 192, 255], 'tabId': tabId}); chrome.browserAction.setPopup({popup: '', tabId: tabId}); chrome.browserAction.onClicked.addListener(function(tab) { reddit.getInfo(tab.url, tab.id); }); return true; }; /** * Set the browser icon badge to its loading state. * @alias BrowserAction.setBadgeLoading(tabId) * @param {Number} tabId If given, only sets loading state for this tab. * @return {Boolean} Returns true. * @method */ BrowserAction.prototype.setBadgeLoading = function (tabId) { chrome.browserAction.setBadgeText({'text': '', 'tabId': tabId}); chrome.browserAction.setTitle({'title': 'Loading data...', 'tabId': tabId}); chrome.browserAction.setPopup({popup: '', tabId: tabId}); return true; }; /** * Set the browser icon badge to its error state. * @alias BrowserAction.setBadgeError(tabId, text) * @param {Number} tabId If given, only sets loading state for this tab. * @param {String} text Sets the badge's hover text. * @return {Boolean} Returns true. * @method */ BrowserAction.prototype.setBadgeError = function (tabId, text) { chrome.browserAction.setBadgeText({'text': '×', 'tabId': tabId}); chrome.browserAction.setTitle({'title': text, 'tabId': tabId}); chrome.browserAction.setBadgeBackgroundColor({'color': [200, 0, 0, 255], 'tabId': tabId}); chrome.browserAction.setPopup({popup: '', tabId: tabId}); chrome.browserAction.onClicked.addListener(function(tab) { reddit.getInfo(tab.url, tab.id); }); return true; }; /** * Set the browser icon badge for a page. * @alias BrowserAction.setBadgeFor(url, tabId) * @param {String} url Sets the badge according to this URL. * @param {Number} tabId Sets the badge for this tab, if specified. * @return {Boolean} Returns true. * @method */ BrowserAction.prototype.setBadgeFor = function (url, tabId) { var cachedPosts; cachedPosts = cache.get(url); if (cachedPosts.isCommentsPage === true) { chrome.browserAction.setBadgeText({'text': '...', 'tabId': tabId}); chrome.browserAction.setTitle({'title': 'You are currently viewing the comments for this page.', 'tabId': tabId}); chrome.browserAction.setBadgeBackgroundColor({'color': [95, 153, 207, 255], 'tabId': tabId}); chrome.browserAction.setPopup({popup: '/html/popup.html', tabId: tabId}); } else if (cachedPosts.count === 0) { chrome.browserAction.setBadgeText({'text': '+', 'tabId': tabId}); chrome.browserAction.setTitle({'title': 'Submit this page.', 'tabId': tabId}); chrome.browserAction.setBadgeBackgroundColor({'color': [0, 0, 0, 255], 'tabId': tabId}); chrome.browserAction.setPopup({popup: '/html/popup.html', tabId: tabId}); } else { chrome.browserAction.setBadgeText({'text': cachedPosts.count.toString(), 'tabId': tabId}); chrome.browserAction.setTitle({'title': 'This page has been submitted ' + cachedPosts.count.toString() + ' times.', 'tabId': tabId}); chrome.browserAction.setBadgeBackgroundColor({'color': [255, 69, 0, 255], 'tabId': tabId}); chrome.browserAction.setPopup({popup: '/html/popup.html', tabId: tabId}); } return true; }; button = new BrowserAction(); /** * Create a new instance of a reddit-powered website. * @classDescription Creates a new reddit-powered website. * @param {String} domain The base domain of the reddit-powered website. (e.g. 'www.reddit.com') * @type {Object} * @return {Boolean} Returns true. * @constructor */ function RedditAPI(domain) { if (domain) { this.domain = domain; } else { this.domain = 'www.reddit.com'; } this.commentsMatchPattern = new RegExp('https?:\/\/' + utils.regexEscape(this.domain) + '(\/r\/(.+?))?\/comments\/(.+?)\/.*'); return true; } /** * Transmits info to the API and returns the response. * @alias RedditAPI.apiTransmit(type, url, async, data) * @param {String} type The type of HTTP request: 'GET' or 'POST'. * @param {String} url The URL to request. * @param {Object} data If it exists, send this as a FormData() object. * @param {Function} cback If it exists, call this function when the request is complete. (doesn't work yet) * @return {Object} Returns the API's response as an object. * @method */ RedditAPI.prototype.apiTransmit = function (type, url, data, cback) { var req, apiTimeout; function processResponse () { if (req.readyState === 4) { if (req.status === 200) { if (JSON.parse(req.responseText).jquery && JSON.parse(req.responseText).jquery[3][3][0] === '.error.USER_REQUIRED') { throw 'You have to be logged in to do that. Click to open the login page.'; } clearTimeout(apiTimeout); if (cback) { cback(JSON.parse(req.responseText)); } return JSON.parse(req.responseText); } else { throw 'API Error. HTTP Status: ' + req.status + '. Try again?'; } } } function handleTimeout () { req.abort(); throw 'API Timeout after ' + settings.get('timeoutLength') + ' seconds. Click to try again.'; } req = new XMLHttpRequest(); req.open(type, url, true); req.onreadystatechange = processResponse; req.send(data); if (settings.get('timeoutLength') !== 16) { apiTimeout = setTimeout(handleTimeout, settings.get('timeoutLength') * 1000); } }; /** * Grabs info about a URL via the reddit API and caches it, then sets the browser button. * @alias RedditAPI.getInfo(url) * @param {String} url The URL of the page to grab info about. * @param {Number} tabId The ID of the tab to prepare. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.getInfo = function (url, tabId) { var apiTimeout, isCommentsPage, reqUrl, req, postsObj, postCount; function processResponse () { if (req.readyState === 4) { if (req.status === 200) { var response; clearTimeout(apiTimeout); response = JSON.parse(req.responseText); cache.set('modhash', response.data.modhash); postsObj = {}; postCount = 0; for (i = 0; i < response.data.children.length; i++) { var child; child = response.data.children[i]; postsObj[child.data.name] = { 'url': url, 'data': child.data }; postCount++; } cache.set(url, { 'count': postCount, 'posts': postsObj, 'cacheDate': utils.epoch(), 'isCommentsPage': isCommentsPage }); button.setBadgeFor(url, tabId); return true; } else { button.setBadgeError(tabId, 'API Error. HTTP Status: ' + req.status + '. Click to try again.'); } } } function handleTimeout () { req.abort(); button.setBadgeError(tabId, 'API Timeout after ' + settings.get('timeoutLength') + ' seconds. Click to try again.'); } button.setBadgeLoading(tabId); isCommentsPage = this.commentsMatchPattern.test(url); if (isCommentsPage) { var matches; matches = url.match(this.commentsMatchPattern); reqUrl = 'http://' + this.domain + '/by_id/t3_' + matches[3] + '.json'; } else { reqUrl = 'http://' + this.domain + '/api/info.json?url=' + encodeURIComponent(url); } req = new XMLHttpRequest(); req.open('GET', reqUrl, true); req.onreadystatechange = processResponse; req.send(null); if (settings.get('timeoutLength') !== 16) { apiTimeout = setTimeout(handleTimeout, settings.get('timeoutLength') * 1000); } }; /** * Votes a post up. * @alias RedditAPI.voteUpPost(event) * @param {String} thing The FULLNAME of the thing to vote up. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.voteUpPost = function (e) { var listItem, fullName, url, reqUrl, oldCache, voteWas, formData; listItem = e.srcElement.parentNode.parentNode; fullName = listItem.id; voteWas = listItem.getAttribute('data-dir'); url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/vote'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); if (voteWas === '1') { formData.append('dir','0'); listItem.setAttribute('data-dir','0'); oldCache.posts[fullName].data.likes = null; } else if (voteWas === '0') { formData.append('dir','1'); listItem.setAttribute('data-dir','1'); oldCache.posts[fullName].data.likes = true; } else if (voteWas === '-1') { formData.append('dir','1'); listItem.setAttribute('data-dir','1'); oldCache.posts[fullName].data.likes = true; } cache.set(url, oldCache); this.apiTransmit('POST', reqUrl, formData); }; /** * Votes a post down. * @alias RedditAPI.voteDownPost(event) * @param {String} thing The FULLNAME of the thing to vote up. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.voteDownPost = function (e) { var listItem, fullName, url, reqUrl, oldCache, voteWas, formData; listItem = e.srcElement.parentNode.parentNode; fullName = listItem.id; voteWas = listItem.getAttribute('data-dir'); url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/vote'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); if (voteWas === '1') { formData.append('dir','-1'); listItem.setAttribute('data-dir','-1'); oldCache.posts[fullName].data.likes = false; } else if (voteWas === '0') { formData.append('dir','-1'); listItem.setAttribute('data-dir','-1'); oldCache.posts[fullName].data.likes = false; } else if (voteWas === '-1') { formData.append('dir','0'); listItem.setAttribute('data-dir','0'); oldCache.posts[fullName].data.likes = null; } cache.set(url, oldCache); this.apiTransmit('POST', reqUrl, formData); }; /** * Saves a post. * @alias RedditAPI.savePost(event) * @param {String} thing The FULLNAME of the thing to save. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.savePost = function (e) { var listItem, fullName, url, reqUrl, oldCache, formData; listItem = e.srcElement.parentNode.parentNode.parentNode; fullName = listItem.id; url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/save'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); listItem.setAttribute('data-saved', 'true'); listItem.className.replace(/\bsaved\b/,''); listItem.className += ' unsave'; e.srcElement.innerHTML = 'unsave'; e.srcElement.onclick = function (event) {reddit.unsavePost(event)}; oldCache.posts[fullName].data.saved = true; cache.set(url, oldCache); this.apiTransmit('POST', reqUrl, formData); }; /** * Unsaves a post. * @alias RedditAPI.unsavePost(event) * @param {String} thing The FULLNAME of the thing to unsave. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.unsavePost = function (e) { var listItem, fullName, url, reqUrl, oldCache, formData; listItem = e.srcElement.parentNode.parentNode.parentNode; fullName = listItem.id; url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/unsave'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); listItem.setAttribute('data-saved', 'false'); listItem.className.replace(/\bunsave\b/,''); listItem.className += ' saved'; e.srcElement.innerHTML = 'save'; e.srcElement.onclick = function (event) {reddit.savePost(event)}; oldCache.posts[fullName].data.saved = false; cache.set(url, oldCache); this.apiTransmit('POST', reqUrl, formData); }; /** * Hides a post. * @alias RedditAPI.hidePost(event) * @param {String} thing The FULLNAME of the thing to hide. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.hidePost = function (e) { var listItem, fullName, url, reqUrl, oldCache, formData; listItem = e.srcElement.parentNode.parentNode.parentNode; fullName = listItem.id; url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/hide'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); listItem.setAttribute('data-hidestatus', 'true'); e.srcElement.innerHTML = 'unhide'; e.srcElement.onclick = function (event) {reddit.unhidePost(event)}; oldCache.posts[fullName].data.hidden = true; cache.set(url, oldCache); this.apiTransmit('POST', reqUrl, formData); }; /** * Unhides a post. * @alias RedditAPI.unhidePost(event) * @param {String} thing The FULLNAME of the thing to unhide. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.unhidePost = function (e) { var listItem, fullName, url, reqUrl, oldCache, formData; listItem = e.srcElement.parentNode.parentNode.parentNode; fullName = listItem.id; url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/unhide'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); listItem.setAttribute('data-hidestatus', 'false'); e.srcElement.innerHTML = 'hide'; e.srcElement.onclick = function (event) {reddit.hidePost(event)}; oldCache.posts[fullName].data.hidden = false; cache.set(url, oldCache); this.apiTransmit('POST', reqUrl, formData); }; /** * Brings up the report confirmation dialog. * @alias RedditAPI.confirmReport(event) * @param {Object} event The event object. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.confirmReport = function (e) { e.srcElement.className = 'report-confirm'; e.srcElement.removeAttribute('onclick'); e.srcElement.innerHTML = 'are you sure? yes/no'; return true; }; /** * Resets the report link. * @alias RedditAPI.denyReport(event) * @param {Object} event The event object. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.denyReport = function (e) { e.srcElement.parentNode.className = 'report'; e.srcElement.parentNode.setAttribute('onclick', 'reddit.confirmReport(event)'); e.srcElement.parentNode.innerHTML = 'report'; return true; }; /** * Reports a post. * @alias RedditAPI.reportPost(event) * @param {Object} event The event object. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.reportPost = function (e) { var listItem, fullName, url, reqUrl, oldCache, formData; listItem = e.srcElement.parentNode.parentNode.parentNode.parentNode; fullName = listItem.id; url = listItem.parentNode.getAttribute('data-url'); reqUrl = 'http://' + this.domain + '/api/report'; oldCache = cache.get(url); formData = new FormData(); formData.append('id', fullName); formData.append('uh', cache.get('modhash')); this.apiTransmit('POST', reqUrl, formData); listItem.setAttribute('data-hidestatus', 'true'); e.srcElement.parentNode.parentNode.childNodes[3].innerHTML = 'unhide'; e.srcElement.parentNode.parentNode.childNodes[3].onclick = function (event) {reddit.unhidePost(event)}; oldCache.posts[fullName].data.hidden = true; cache.set(url, oldCache); listItem.setAttribute('data-reportstatus', 'true'); e.srcElement.parentNode.innerHTML = 'reported!'; }; /** * Submits a comment. * @alias RedditAPI.submitComment(event) * @param {Object} event The event object. * @return {Boolean} Returns true. * @method */ RedditAPI.prototype.submitComment = function (e) { var listItem, fullName, status, submitButton, cancelButton, textarea, formData; function afterSubmission (response) { var url, oldCache; status.innerHTML = ''; submitButton.innerHTML = 'saved'; cancelButton.innerHTML = 'hide this form'; submitButton.setAttribute('disabled'); textarea.setAttribute('readonly'); textarea.onkeyup = function () {return true;}; url = document.getElementById(fullName).parentNode.getAttribute('data-url'); oldCache = cache.get(url); console.log(oldCache); oldCache.posts[fullName].savedCommentText = ''; console.log(oldCache); cache.set(url, oldCache); } submitButton = e.srcElement; listItem = submitButton.parentNode.parentNode.parentNode; fullName = listItem.id; status = submitButton.parentNode.getElementsByClassName('status')[0]; cancelButton = submitButton.parentNode.getElementsByClassName('cancel')[0]; textarea = e.srcElement.parentNode.getElementsByTagName('textarea')[0]; if (textarea.value === '') { status.innerHTML = 'There needs to be something here.'; } else { formData = new FormData(); formData.append('thing_id', fullName); formData.append('text', textarea.value); formData.append('uh', cache.get('modhash')); status.innerHTML = 'submitting...'; try { reddit.apiTransmit('POST', 'http://www.reddit.com/api/comment', formData, afterSubmission); } catch (error) { status.innerHTML = error; } } }; reddit = new RedditAPI('www.reddit.com'); /** * Creates a new framework of background processes * @classDescription Creates a new framework of background processes. * @type {Object} * @return {Boolean} Returns true. * @constructor */ function Background() { return true; } /** * Prepare the browser action (badge, popup, etc.) for a given tab. * @alias Background.prepareBrowserAction(tabId, info, tab) * @param {Number} tabId The ID of the tab to get data for. * @param {Object} info The info for the change as sent by Chrome. * @param {Object} tab The info for the tab as sent by Chrome. * @return {Boolean} Returns true. * @method */ Background.prototype.prepareBrowserAction = function (tabId, info, tab) { if (info.status === 'loading') { if (cache.get(tab.url) === undefined || cache.get(tab.url).cacheDate - utils.epoch() < -60 * settings.get('cacheTime')) { console.log('Grabbing data from the API...'); reddit.getInfo(tab.url, tabId); } else { console.log('Grabbing data from the cache...'); button.setBadgeFor(tab.url, tabId); } } return true; }; /** * Creates a new framework of popup processes * @classDescription Creates a new framework of background processes. * @type {Object} * @return {Boolean} Returns true. * @constructor */ function Popup() { return true; } /** * Create and store the HTML for a list of posts. * @alias Popup.createListHTML(url) * @param {String} url The URL of the page to create the HTML for. * @return {String} Returns the generated HTML. * @method */ Popup.prototype.createListHTML = function (url) { var listHTML, staleCounter; if (cache.get(url) === undefined) { throw 'Cannot create list HTML for a non-cached URL.'; } listHTML = '