// ==UserScript== // @name Tumblr Dashboard Tracker // @namespace the.vindicar.scripts // @description Checks what's going on on your dashboard, loads new posts and plays a sound if something interesting happens. // @version 1.1.1 // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @require https://greasyfork.org/scripts/1884-gm-config/code/GM_config.js?version=4836 // @include http://www.tumblr.com/dashboard // @include https://www.tumblr.com/dashboard // @downloadURL https://update.greasyfork.icu/scripts/11828/Tumblr%20Dashboard%20Tracker.user.js // @updateURL https://update.greasyfork.icu/scripts/11828/Tumblr%20Dashboard%20Tracker.meta.js // ==/UserScript== (function(){ 'use strict'; // ====================================== Configuration ====================================== //If we have no access to Greasemonkey methods, we will need dummy replacements if (typeof GM_getValue !== 'function') GM_getValue = function (target, deflt) { return deflt; }; //configuration var cfg = { track_inbox : !!GM_getValue("track_inbox", true), track_answers : !!GM_getValue("track_answers", true), track_replies : !!GM_getValue("track_replies", true), track_mentions : !!GM_getValue("track_mentions", true), track_messages : !!GM_getValue("track_messages", true), track_reblogs_followed : !!GM_getValue("track_reblogs_followed", true), track_reblogs_other : !!GM_getValue("track_reblogs_other", false), track_everything : !!GM_getValue("track_everything", false), notify_mode_default : GM_getValue("notify_mode_default", "last"), notify_mode : GM_getValue("notify_mode", "auto"), notify_sound : !!GM_getValue("notify_sound", false), notify_soundurl : GM_getValue("notify_soundurl", ""), update_autoload : !!GM_getValue("update_autoload", true), update_stopafter : parseInt(GM_getValue("update_stopafter", "100"),10), update_unread_style : GM_getValue("update_unread_style", "border: 3px solid #529ECC;"), update_unmark_delay : 100, }; //we set up the configuration panel if possible if ( (typeof GM_config !== 'undefined') && (typeof GM_setValue === 'function') && (typeof GM_registerMenuCommand === 'function') ) { //this function is called when user presses "play" button in the configuration panel var playclick = function () { var doc = this.ownerDocument.documentElement; //since we're inside iframe, we have to access it's document element this way try { var sound = new Audio(doc.querySelector("#field_notify_soundurl").value); sound.play(); } catch (e) { alert("Couldn't play the sound."); } }; //configuration input fields var fields = { "track_inbox" : { "label" : "Inbox messages", "title" : "Receive notification if there are new unread messages in your inbox.", "section" : ["Tracking"], "type" : "checkbox", "default" : cfg.track_inbox, }, "track_answers" : { "label" : "Answers to your asks", "title" : "Receive notification every time someone answers an ask you sent.", "type" : "checkbox", "default" : cfg.track_answers, }, "track_replies" : { "label" : "Replies to your posts", "title" : "Receive notification every time someone replies to your post.", "type" : "checkbox", "default" : cfg.track_replies, }, "track_mentions" : { "label" : "Mentions", "title" : "Receive notification every time someone mentions you in their post.", "type" : "checkbox", "default" : cfg.track_mentions, }, "track_messages" : { "label" : "Messages", "title" : "Receive notification every time someone messages you via IM system.", "type" : "checkbox", "default" : cfg.track_messages, }, "track_reblogs_followed" : { "label" : "Reblogs by blogs you follow", "title" : "Receive notification every time a blog you follow reblogs your post.", "type" : "checkbox", "default" : cfg.track_reblogs_followed, }, "track_reblogs_other" : { "label" : "Reblogs by other blogs", "title" : "Receive notification every time some other blog reblogs your post.", "type" : "checkbox", "default" : cfg.track_reblogs_any, }, "track_everything" : { "label" : "Any posts", "title" : "Receive notification every time there are new posts on your dashboard. Are you sure?", "type" : "checkbox", "default" : cfg.track_everything, }, "notify_mode_default" : { "label" : "By default notifications are", "title" : "Default state of notifications when reloading the page.\nYou can change the current state with control next to the Tumblr logo.", "section" : ["Notifications"], "type" : "select", "options" : { "last" : "in the same state as before", "on" : "always enabled", "auto" : "enabled when not viewed", "off" : "always disabled", }, "default" : cfg.notify_mode_default, }, "notify_sound" : { "label" : "Play sound", "title" : "Play a sound every time you receive a notification.", "type" : "checkbox", "default" : cfg.notify_sound, }, "notify_soundurl" : { "label" : "Sound file URI", "title" : "This sound will play every time you receive a notification.\nHint: you can save a sound file right here if you convert it into a 'data:' URI.", "type" : "text", "default" : cfg.notify_soundurl, }, "notify_sound_play" : { "label" : "Play", "title" : "Press to play the sound above.", "type" : "button", "script" : "("+playclick.toString()+").call(this)", }, "update_autoload" : { "label" : "Load unread posts", "title" : "Unread posts will be loaded and added onto the top of the dashboard.", "section" : ["Autoupdate"], "type" : "checkbox", "default" : cfg.update_autoload, }, "update_stopafter" : { "label" : "Post autoloading limit", "title" : "How many posts should be loaded before disabling autoloading feature.", "type" : "text", "size" : "5", "default" : cfg.update_stopafter, }, "update_unread_style" : { "label" : "Unread posts style", "title" : "These CSS definitions will be applied to any unread posts that have been loaded. Example:\nborder: solid 1px #99F;", "type" : "text", "default" : cfg.update_unread_style, }, save: function() { var donotsave = ["notify_sound_play"]; for (var key in GM_config.values) { if (donotsave.indexOf(key) == -1) GM_setValue(key,GM_config.values[key]); } }, }; //styles for configuration form controls var configFormCSS = [ '.reset_holder { display: none !important; }', 'body {background-color: #FFF;}', '* {font-family: "Helvetica Neue","HelveticaNeue",Helvetica,Arial,sans-serif; color: #444;}', '#header {border-bottom: 2px solid #E5E5E5; font-size: 24px; font-weight: normal; line-height: 1; margin: 0px; padding-bottom: 28px;}', '.section_header {border: 0px none; margin: 16px 0px 16px 0px; padding: 0px; font-size: 20px; font-weight: normal; line-height: 1; background-color: transparent; color: inherit; text-decoration: none;}', '.config_var {padding: 2px 0px 2px 200px;}', '.config_var>* {vertical-align:middle;}', '.config_var .field_label {font-size: 14px !important;line-height: 1.2; display:inline-block; width:200px; margin: 0 0 0 -200px;}', '#field_notify_soundurl,#field_update_unread_style {width: 100%}', 'button {padding: 4px 7px 5px; font-weight: 700; border-width: 1px; border-style: solid; text-decoration: none; border-radius: 2px; cursor: pointer; display: inline-block; height: 30px; line-height: 20px;}', '#saveBtn {color: #FFF; border-color: #529ECC; background: #529ECC none repeat scroll 0% 0%;}', '#cancelBtn {color: #FFF; border-color: #9DA6AF; background: #9DA6AF none repeat scroll 0% 0%;}', ""].join("\n"); //style for the configuration form window. Since it's an iframe element inside the main window, we have to add this style to the main page GM_addStyle('#GM_config {border-radius: 3px !important; border: 0px none !important;}'); //style for unread posts, if autoloading is enabled if (cfg.update_autoload && (cfg.update_unread_style.indexOf("}") == -1)) GM_addStyle('.tracker-unread-post .post {'+cfg.update_unread_style+'}'); GM_config.init("Tumblr Dashboard Tracker Settings", fields, configFormCSS); GM_registerMenuCommand("Tumblr Dashboard Tracker Settings", function() {GM_config.open();}); } //global styles for notification mode switch var globalCSS = [ '#tumblr-dashboard-tracker-mode {width: 12px; height: 24px; background: transparent; overflow: hidden; padding: 0; margin: 3px 16px 0px 16px; display:inline-block; border-radius: 6px;}', '#tumblr-dashboard-tracker-mode > span { border : 0 none; border-radius: 6px; width: 12px; height: 12px; display:inline-block; position: relative; }', '#tumblr-dashboard-tracker-mode[data-state="on"] {border: 1px solid #FFFFFF;}', '#tumblr-dashboard-tracker-mode[data-state="auto"] {border: 1px solid #529ECC;}', '#tumblr-dashboard-tracker-mode[data-state="off"] {border: 1px solid #A1A1A1;}', '#tumblr-dashboard-tracker-mode[data-state="on"] > span {background-color: #FFFFFF; top: -4px;}', '#tumblr-dashboard-tracker-mode[data-state="auto"] > span {background-color: #529ECC; top: 3px;}', '#tumblr-dashboard-tracker-mode[data-state="off"] > span {background-color: #A1A1A1; top: 10px;}' ].join("\n"); GM_addStyle(globalCSS); // ====================================== Dashboard Tracker ====================================== function Tracker(cfg) { this.config = cfg; this.original = {}; this.loadcounter = 0; this.knownitems = {}; this.unread_interval_id = null; this.page_visible = true; this.sound = null; this.unreadcount = 0; this.inboxcount = 0; this.unreadmessagescount = 0; } //Tumblr.Thoth is a Tumblr module that updates unread posts & inbox messages counters. //we replace some of the methods there with our own, letting Tumblr handle the rest. //It's also easier to hook up to couple of Tumblr events than to track them ourselves. Tracker.prototype.install = function () { //we make sure event handlers are called in correct context (with correct 'this' value). var that = this; if (this.track_everything || this.config.track_reblogs_followed || this.config.update_autoload) { //we have to parse incoming posts to do any of these! this.original['parse_unread'] = unsafeWindow.Tumblr.Thoth.__proto__.parse_unread; unsafeWindow.Tumblr.Thoth.__proto__.parse_unread = exportFunction(function(heartbeat){ return that.parse_unread.call(that, heartbeat); //we completely replace the old function //return that.original.parse_unread.call(this, heartbeat); }, unsafeWindow); //we calculate hashes for all items currently displayed on the Dashboard. //it will allow us to find out if we have reached the items already displayed when dynamically updating the Dashboard. //Note: hashes for the items on the next pages are not needed. Probably. var dashboard = document.querySelectorAll('#posts>li'); for (var i=0; i this.inboxcount) { this.event({type:'inbox'}); } //we update the counters if necessary if (heartbeat.inbox != this.inboxcount) { this.inboxcount = heartbeat.inbox; this.update_counters(); } }; //Parse unread conversations. Tracker.prototype.parse_messages = function (heartbeat) { if (typeof heartbeat.unread_messages !== "object") return; var val = document.querySelector('#messaging_button .tab_notice_value'); var unread = (val && val.innerHTML) ? parseInt(val.innerHTML, 10) : 0; //if number of unread messages have increased since last time, we send another notification if (unread > this.unreadmessagescount) { this.event({type:'IM'}); } //we update the counters if necessary if (unread != this.unreadmessagescount) { this.unreadmessagescount = unread; this.update_counters(); } }; //Parse unread posts. This function completely takes over original Tumblr.Thoth method. Tracker.prototype.parse_unread = function (heartbeat) { if ((typeof heartbeat.unread !== "number")&&(typeof heartbeat.abacus !== "number")) return; if (this.unreadcount >= this.config.update_stopafter) { //we tell Tumblr to stop polling if too many unread posts have accumulated unsafeWindow.Tumblr.Thoth.options.check_posts = false; } var unread = unsafeWindow.Tumblr.Thoth.use_new_abacus ? heartbeat.abacus : heartbeat.unread; if (unread > 0) this.load_page(1, this.get_first_post_id()); return unread; }; //this function loads specified page of dashboard using Tumblr service URL. //Callback it uses can query next page if necessary Tracker.prototype.load_page = function (page, id) { var request = new XMLHttpRequest(); request.open('GET', 'https://www.tumblr.com/svc/dashboard/'+page+'/'+id); var that = this; request.onreadystatechange = function() { if ((request.readyState != 4) || (request.status != 200)) return; var data; try { data = JSON.parse(request.responseText); } catch (e) { data = {}; } if ((typeof data.meta !== 'object') || data.meta.status != 200) return; var postdata = data.response.DashboardPosts.body; if (postdata.indexOf("") !== -1) postdata = postdata.split("")[1].split("")[0]; that.parse_unread_success.call(that, postdata, page); }; request.send(); }; //parses the posts we have loaded, checks them for events of interest and attaches them to the top of the Dashboard Tracker.prototype.parse_unread_success = function (html, page) { //posts container var posts_container = document.querySelector('#posts'); //first seen element on the dashboard (aside from new post buttons) var first_seen = posts.querySelector('li:not(#new_post_buttons)'); //remember position of the first seen element var offset = first_seen.offsetTop; //position at which we will be adding new items var insertion_mark = first_seen.previousSibling; //flag indicating if we had met an item we already saw - in case we have more than one page of new posts var known_item_found = false; //last added post id var last_id; //load items into a DOM container var container = document.createElement('body'); container.innerHTML = html; //amount of posts that were loaded but not displayed (we should count them as unread) var not_diplayed_unread_count = 0; //look for posts var items = container.querySelectorAll('body>li'); //we process items in the order they appear - reverse chronological for (var i=0; i.tracker-unread-post").length + not_diplayed_unread_count; } else //otherwise, all unread posts are not loaded this.unreadcount = not_diplayed_unread_count; this.update_counters(); }; Tracker.prototype.get_first_post_id = function () { var post = document.querySelector('#posts .post'); if (post !== null) return post.getAttribute('data-id'); else throw "get_first_post_id(): No posts found at all!"; }; //computes simple hash of a dashboard item. //It's necessary for us to find which posts and notifications we have seen already, so we won't duplicate them. Tracker.prototype.get_item_hash = function (li) { if (/\bpost_container\b/.test(li.className)) { return 'POST:'+li.getAttribute('data-pageable'); } else if (/\bnotification\b/.test(li.className)) { return 'NOTIFICATION:'+li.querySelector('.notification_sentence').innerHTML; } else return 'UNKNOWN:'+li.innerHTML; }; //This methods updates unread posts counter. Unlike it's Tumblr prototype, it can remove the counter if it's reduced to zero. Tracker.prototype.update_counters = function () { //any place that show unread post counter var fields = document.querySelectorAll(".new_post_notice_container"); for (var i=0;i0) //if there are unread posts fields[i].className += ' tab-notice--active'; else //if there are none fields[i].className = fields[i].className.replace(/\s*\btab-notice--active\b/, ''); } //remove post counter in the title (if any) var title = document.title.replace(/^(\{[0-9+]+\})?\s*(\([0-9+]\))?\s*(\[[0-9+]+\])?/,''); //if there are unread posts, we display it in the title too if (this.unreadmessagescount>0) title = '{'+this.unreadmessagescount.toString(10)+'}'+title; if (this.inboxcount>0) title = '['+this.inboxcount.toString(10)+']'+title; if (this.unreadcount>0) title = '('+this.unreadcount.toString(10)+')'+title; document.title = title; }; //This method gets called after user has been scrolling. //It checks if any posts got scrolled by (their top is not hidden above the viewport) and removes unread mark from them. Tracker.prototype.unmark_unread_posts = function () { //find all unread posts var unread = document.querySelectorAll("#posts>.tracker-unread-post"); //we check them in reverse order, from bottom to top. //it's important, because we can stop the moment we find one that's still hidden var changed = false; for (var i=unread.length-1;i>=0;i--) { var rect = unread[i].getBoundingClientRect(); if (rect.top<0) //if top of the post is above the window border break; //we consider it invisible, as well as all posts above it changed = true; //mark it as read unread[i].className = unread[i].className.replace(/\s*\btracker-unread-post\b/, ''); } if (changed) {//if anything has changed, we correct the numbers //the unread post count this.unreadcount = document.querySelectorAll("#posts>.tracker-unread-post").length; this.update_counters(); } }; // ====================================== Helpers ====================================== //Calls func() every time page visibility changes, with one boolean parameter keeping current visibility state function setVisibilityHandler(func, invoke_now) { //Page Visibility API var method; var methods = { 'hidden':'visibilitychange', 'mozHidden':'mozvisibilitychange', 'webkitHidden':'webkitvisibilitychange', 'msHidden':'msvisibilitychange', 'oHidden':'ovisibilitychange' }; var handler = function(event) { event = event || window.event; if ((event.type == "focus") || (event.type == "focusin")) func(true); else if ((event.type == "blur") || (event.type == "focusout")) func(false); else func(!document[method]); }; window.addEventListener('blur', handler, false); window.addEventListener('focus', handler, false); for (method in methods) if (methods.hasOwnProperty(method) && (method in document)) { document.addEventListener(methods[method], handler, false); if (invoke_now) func(!document[method]); return; } }; // ====================================== Main ====================================== var tracker = new Tracker(cfg); tracker.install(); /* unsafeWindow.Tumblr.Prima.Events.listenTo(unsafeWindow.Tumblr.Prima.Events, 'all', exportFunction(function(){ console.log(arguments); }, unsafeWindow)); */ })();