Last.fm Reply tracker

Adds a reply tracker to your profile page

  1. // ==UserScript==
  2. // @name Last.fm Reply tracker
  3. // @description Adds a reply tracker to your profile page
  4. // @namespace slosd@freedig.org
  5. // @version 17
  6. //
  7. // @include http://www.last.fm/home
  8. // @include http://www.lastfm.*/home
  9. // @include http://www.last.fm/user/*
  10. // @include http://www.lastfm.*/user/*
  11. // @exclude http://www.last.fm/user/*/*
  12. // @exclude http://www.lastfm.*/user/*/*
  13. //
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_xmlhttpRequest
  17. // ==/UserScript==
  18.  
  19. function GMY_optionExits(key) {
  20. var value = GM_getValue(key, "undefined");
  21. return (value != "undefined" && value != "");
  22. }
  23.  
  24. var Thread = function(properties, options) {
  25. this.options = options;
  26. this.elements = new Object();
  27. this.url = properties.url;
  28. var parts = this.url.replace(/^.*\/forum\/(\d+?)\/_\/(\d+?)\/_\/(\d+)$/, "$1,$2,$3").split(",");
  29. this.forum_id = parts[0];
  30. this.id = parts[1];
  31. this.last_post_id = parts[2];
  32. this.title = properties.title;
  33. this.title_short = this.title.replace(/^.*?\'(.*)\'$/, "$1");
  34. this.user = properties.description.replace(/^.*<a[^>]*>(.*?)<\/a>.*$/, "$1");
  35. this.date = properties.date;
  36. this.date_str = this.date.toLocaleString();
  37. this.status = this.getStatus();
  38. this.buildRow().setStyle(this.status);
  39. }
  40.  
  41. Thread.prototype.setStatus = function(status) {
  42. if(status == "read" || status == "spam")
  43. GM_setValue("thread_list", this._readlist(true) + "/" + this._identifier(status == "read" ? 3 : 2));
  44. else if(status == "unread")
  45. GM_setValue("thread_list", this._readlist(true));
  46. this.status = status;
  47. this.setStyle(status);
  48. return this;
  49. }
  50.  
  51. Thread.prototype.setStyle = function(status) {
  52. var background, opacity, img_tip = "Spam thread", spam_button = "http://cdn.last.fm/flatness/global/icon_delete.2.png";
  53. switch(status) {
  54. case "read":
  55. background = "url(http://cdn.last.fm/flatness/icons/activity/2/created.png) left center no-repeat";
  56. opacity = 0.4;
  57. break;
  58. case "unread":
  59. background = "url(http://cdn.last.fm/flatness/icons/activity/2/recommended.png) left center no-repeat";
  60. opacity = 1;
  61. break;
  62. case "spam":
  63. background = "url(http://cdn.last.fm/flatness/icons/activity/2/disconnected.png) left center no-repeat";
  64. opacity = 0.4;
  65. img_tip = "Don't spam thread";
  66. spam_button = "http://cdn.last.fm/flatness/messageboxes/success.png";
  67. break;
  68. }
  69. this.elements.row.style.background = background;
  70. this.elements.row.style.opacity = opacity;
  71. this.elements.spam.alt = img_tip;
  72. this.elements.spam.title = img_tip;
  73. this.elements.spam.src = spam_button;
  74. return this;
  75. }
  76.  
  77. Thread.prototype.getStatus = function() {
  78. var list = this._readlist();
  79. if(list.match(new RegExp("/" + this._identifier() + "(/|$)")))
  80. return "read";
  81. else if(list.match(new RegExp("/" + this._identifier(2) + "(/|$)")))
  82. return "spam";
  83. else
  84. return "unread";
  85. }
  86.  
  87. Thread.prototype._readlist = function(clean) {
  88. var list = GM_getValue("thread_list", "");
  89. if(clean) return list.replace(new RegExp("/" + this._identifier(2) + ".*?(/|$)", "g"), "$1");
  90. else return list;
  91. }
  92.  
  93. Thread.prototype._identifier = function(depth) {
  94. if(!depth) var depth = 3;
  95. return new Array(this.forum_id, this.id, this.last_post_id).slice(0, depth).join("%");
  96. }
  97.  
  98. Thread.prototype.buildRow = function() {
  99. this.elements.row = document.createElement("li");
  100. this.elements.row.style.background = "none";
  101. this.elements.row.style.paddingLeft = "30px";
  102. this.elements.row.addEventListener("mouseover", (function(klass) {
  103. return function() {
  104. klass.elements.spam.style.visibility = "visible";
  105. }
  106. })(this), false);
  107. this.elements.row.addEventListener("mouseout", (function(klass) {
  108. return function() {
  109. klass.elements.spam.style.visibility = "hidden";
  110. }
  111. })(this), false);
  112. this.elements.spam = document.createElement("img");
  113. this.elements.spam.style.cssFloat = "right";
  114. this.elements.spam.style.cursor = "pointer";
  115. this.elements.spam.style.visibility = "hidden";
  116. this.elements.spam.addEventListener("click", (function(klass) {
  117. return function() {
  118. klass.setStatus.apply(klass, [(klass.status == "spam" ? "unread" : "spam")]);
  119. }
  120. })(this), false);
  121. this.elements.row.appendChild(this.elements.spam);
  122. this.elements.link = document.createElement("a");
  123. this.elements.link.href = this.url;
  124. this.elements.link.innerHTML = this.title_short;
  125. this.elements.link.addEventListener("mouseup", (function(klass) {
  126. return function() {
  127. klass.setStatus.apply(klass, ["read"]);
  128. }
  129. })(this), false);
  130. this.elements.row.appendChild(this.elements.link);
  131. this.elements.date = document.createElement("span");
  132. this.elements.date.className = "date";
  133. if(this.options.show_date == "yes") {
  134. this.elements.row.appendChild(document.createElement("br"));
  135. this.elements.date.innerHTML = this.date_str;
  136. }
  137. if(this.options.show_user == "yes") this.elements.date.innerHTML += ' from <a href="/user/' + this.user + '">' + this.user + '</a>';
  138. this.elements.row.appendChild(this.elements.date);
  139. return this;
  140. }
  141.  
  142. var ReplyTracker = function() {
  143. this.loadTracker();
  144. }
  145.  
  146. ReplyTracker.prototype.user_options = [
  147. // these two options must be the first in the array!
  148. { name: "show_profile", label: "Display on your profile", default: "yes" },
  149. { name: "show_home", label: "Display on your home page", default: "yes" },
  150.  
  151. // these options are not visible in the "Settings"
  152. { name: "@index", label: "Index", default: 0, hidden: true },
  153. { name: "@max_length", label: "Max. rows", default: 5 },
  154. { name: "@update_interval", label: "Update interval (sec)", default: 60 },
  155. { name: "@show_date", label: "Show dates (yes/no)", default: "yes" },
  156. { name: "@show_user", label: "Show usernames (yes/no)", default: "yes" },
  157. { name: "@show_read", label: "Show read threads (yes/no)", default: "yes" },
  158. { name: "@show_spam", label: "Show spammed threads (yes/no)", default: "no" }
  159. ];
  160.  
  161. ReplyTracker.prototype.options = new Object();
  162. ReplyTracker.prototype.elements = new Object();
  163. ReplyTracker.prototype.threads = new Array();
  164.  
  165. ReplyTracker.prototype.loadTracker = function() {
  166. if (!unsafeWindow.LFM || !unsafeWindow.LFM.Session || !unsafeWindow.LFM.Session.userName) return;
  167.  
  168. this.options.username = unsafeWindow.LFM.Session.userName;
  169. if(GM_getValue("show_profile", this.user_options[0].default) == "yes" && this.isOwnProfile(this.options.username)) {
  170. this.target = "profile";
  171. } else if(GM_getValue("show_home", this.user_options[1].default) && document.location.href.indexOf("/home") != -1) {
  172. this.target = "home";
  173. } else {
  174. return;
  175. }
  176.  
  177. this.loadOptions();
  178. this.updateFeed();
  179. }
  180.  
  181. ReplyTracker.prototype.reloadTracker = function() {
  182. delete this.threads;
  183. this.threads = new Array();
  184. this.options = new Object();
  185. this.loadTracker();
  186. }
  187.  
  188. ReplyTracker.prototype.loadOptions = function() {
  189. for(var i = 0, c = this.user_options.length, name; i < c; i++) {
  190. name = this.user_options[i].name;
  191. this.options[name.replace(/@/g, "")] = GM_getValue(name.replace(/@/g, this.target+"_"), this.user_options[i].default);
  192. }
  193. return true;
  194. }
  195.  
  196. ReplyTracker.prototype.isOwnProfile = function(username) {
  197. var page_username = document.location.href.replace(/^.*?\/user\/(.+?)((#|\/).*|$)/, "$1");
  198. return username === page_username;
  199. }
  200.  
  201. ReplyTracker.prototype.showOptions = function() {
  202. if(!this.buildOptions())
  203. this.elements.options.style.display = "block";
  204. if(typeof this.elements.feed != "undefined")
  205. this.elements.feed.style.display = "none";
  206. this.stopUpdater();
  207. }
  208.  
  209. ReplyTracker.prototype.buildBody = function() {
  210. if(typeof this.elements.body != "undefined")
  211. return false;
  212. var klass = this,
  213. first = this.getSibling();
  214.  
  215. this.elements.header = document.createElement("h2");
  216. this.elements.header.setAttribute("id", "gmreplytracker");
  217. this.elements.header.setAttribute("class", "heading");
  218. this.elements.header.innerHTML = '<span class="h2Wrapper"><span style="float:right;font-weight:normal;font-size:11px;"><a class="mEdit icon" href="#" style="background:url(http://cdn.last.fm/flatness/icons/settings.2.png) left center no-repeat;padding-left: 12px;"><span>Settings</span></a></span><a href="/user/' + this.options.username + '/replytracker" title="">Reply Tracker</a></span>';
  219. this.elements.header.firstChild.firstChild.firstChild.addEventListener("click", function(e) {
  220. klass.showOptions.apply(klass);
  221. e.preventDefault();
  222. return false;
  223. }, false);
  224. this.elements.body = document.createElement("div");
  225. this.elements.footer = document.createElement("div");
  226. this.elements.footer.style.textAlign = "right";
  227. var button_down = document.createElement("a");
  228. button_down.href = "#";
  229. button_down.innerHTML = "&darr;";
  230. button_down.style.padding = "3px 6px";
  231. button_down.style.marginRight = "4px";
  232. button_down.style.backgroundColor = "#eee";
  233. button_down.addEventListener("click", function(e) {
  234. e.preventDefault();
  235. var index = klass.options["index"] = parseInt(klass.options["index"]) + 1;
  236. GM_setValue(klass.target+"_index", index);
  237. klass.updateBody.call(klass);
  238. }, false);
  239. var button_up = document.createElement("a");
  240. button_up.href = "#";
  241. button_up.innerHTML = "&uarr;";
  242. button_up.style.padding = "3px 6px";
  243. button_up.style.marginRight = "4px";
  244. button_up.style.backgroundColor = "#eee";
  245. button_up.addEventListener("click", function(e) {
  246. e.preventDefault();
  247. var index = parseInt(klass.options["index"]) - 1;
  248. if(index < 0)
  249. return;
  250. klass.options["index"] = index;
  251. GM_setValue(klass.target+"_index", index);
  252. klass.updateBody.call(klass);
  253. }, false);
  254. this.elements.footer.appendChild(button_down);
  255. this.elements.footer.appendChild(button_up);
  256.  
  257. if(this.target == "home") {
  258. var body = document.createElement("div");
  259. body.setAttribute("id", "gmreplytracker");
  260. body.setAttribute("class", "home-group");
  261.  
  262. var header = document.createElement("div");
  263. header.setAttribute("class", "home-group-header");
  264. header.appendChild(this.elements.header);
  265.  
  266. var content = document.createElement("div");
  267. content.setAttribute("class", "home-group-content");
  268. content.appendChild(this.elements.body);
  269. content.appendChild(this.elements.footer);
  270.  
  271. body.appendChild(header);
  272. body.appendChild(content);
  273. first.parentNode.insertBefore(body, first);
  274. }
  275. else {
  276. first.parentNode.insertBefore(this.elements.header, first);
  277. first.parentNode.insertBefore(this.elements.body, first);
  278. first.parentNode.insertBefore(this.elements.footer, first);
  279. }
  280. return true;
  281. }
  282.  
  283. ReplyTracker.prototype.getSibling = function() {
  284. var element;
  285. var index = GM_getValue(this.target+"_index", 0);
  286. var first = document.getElementById("LastAd_mpu").nextElementSibling;
  287.  
  288. if(this.target == "profile") {
  289. while((first.id == "gmreplytracker" || (first.className != "first heading" && first.className != "heading" && first.className != "module" || index-- > 0)) && first.nextElementSibling)
  290. first = first.nextElementSibling;
  291. }
  292. else {
  293. while((first.id == "gmreplytracker" || (index-- > 0)) && first.nextElementSibling)
  294. first = first.nextElementSibling;
  295. }
  296. if(index > 0) {
  297. this.options["index"] = GM_getValue(this.target+"_index", index) - index;
  298. GM_setValue(this.target+"_index", this.options["index"]);
  299. }
  300. return first;
  301. }
  302.  
  303. ReplyTracker.prototype.updateBody = function() {
  304. var first = this.getSibling();
  305. if(this.target == "home") {
  306. first.parentNode.insertBefore(this.elements.header.parentNode.parentNode, first);
  307. }
  308. else {
  309. first.parentNode.insertBefore(this.elements.header, first);
  310. first.parentNode.insertBefore(this.elements.body, first);
  311. first.parentNode.insertBefore(this.elements.footer, first);
  312. }
  313. }
  314.  
  315. ReplyTracker.prototype.buildOptions = function() {
  316. if(typeof this.elements.options != "undefined")
  317. return false;
  318. var klass = this;
  319. this.buildBody();
  320. this.elements.options = document.createElement("ul");
  321. for(var i = 0, c = this.user_options.length, li, label, input, name; i < c; i++) {
  322. if(this.user_options[i].hidden)
  323. continue;
  324. name = this.user_options[i].name.replace(/@/g, "");
  325. li = document.createElement("li");
  326. li.style.lineHeight = "25px";
  327. label = document.createElement("label");
  328. label.innerHTML = this.user_options[i].label;
  329. label.setAttribute("for", name);
  330. input = document.createElement("input");
  331. if(typeof this.options[name] != "undefined")
  332. input.setAttribute("value", this.options[name]);
  333. with(input) {
  334. setAttribute("id", name);
  335. setAttribute("name", name);
  336. setAttribute("size", 4);
  337. with(style) {
  338. border = "1px solid #999";
  339. background = "#fff";
  340. color = "#222";
  341. cssFloat = "right";
  342. }
  343. }
  344. this.user_options[i].element = input;
  345. li.appendChild(input);
  346. li.appendChild(label);
  347. this.elements.options.appendChild(li);
  348. }
  349. li = document.createElement("li");
  350. li.style.textAlign = "center";
  351. input = document.createElement("input");
  352. input.setAttribute("type", "button");
  353. input.setAttribute("value", "Save Options");
  354. input.addEventListener("click", function() {
  355. klass.saveOptions.apply(klass);
  356. return false;
  357. }, false);
  358. li.appendChild(input);
  359. this.elements.options.appendChild(li);
  360. this.elements.body.appendChild(this.elements.options);
  361. return true;
  362. }
  363.  
  364. ReplyTracker.prototype.buildFeed = function() {
  365. if(typeof this.elements.feed != "undefined")
  366. return false;
  367. this.buildBody();
  368. this.elements.feed = document.createElement("ul");
  369. this.elements.feed.className = "minifeedSmall";
  370. this.elements.body.appendChild(this.elements.feed);
  371. return true;
  372. }
  373.  
  374. ReplyTracker.prototype.saveOptions = function() {
  375. for(var i = 0, l = this.user_options.length, name, value; i < l; i++) {
  376. if(this.user_options[i].hidden)
  377. continue;
  378. name = this.user_options[i].name;
  379. value = this.user_options[i].element.value;
  380. this.options[name.replace(/@/, "")] = value;
  381. GM_setValue(name.replace(/@/, this.target+"_"), value);
  382. }
  383. this.elements.options.style.display = "none";
  384. if(typeof this.elements.feed != "undefined")
  385. this.elements.feed.style.display = "block";
  386. this.reloadTracker();
  387. }
  388.  
  389. ReplyTracker.prototype._feed = function() {
  390. return "http://ws.audioscrobbler.com/1.0/user/" + this.options.username + "/replytracker.rss";
  391. }
  392.  
  393. ReplyTracker.prototype.startUpdater = function() {
  394. var klass = this;
  395. if(!this.update)
  396. this.update = window.setInterval(function() {
  397. klass.updateFeed.apply(klass);
  398. }, this.options.update_interval * 1000);
  399. }
  400.  
  401. ReplyTracker.prototype.stopUpdater = function() {
  402. window.clearInterval(this.update);
  403. delete this.update;
  404. }
  405.  
  406. ReplyTracker.prototype.updateFeed = function() {
  407. this.getFeed(this._feed());
  408. }
  409.  
  410. ReplyTracker.prototype.getFeed = function(feed_url) {
  411. var klass = this;
  412. this.buildFeed();
  413. GM_xmlhttpRequest({
  414. "method": "GET",
  415. "url": feed_url,
  416. "onload": function(response) {
  417. var parser = new DOMParser();
  418. var xmlDoc = parser.parseFromString(response.responseText, "text/xml");
  419. klass.processFeed.call(klass, xmlDoc);
  420. },
  421. "onerror": function() {
  422. }
  423. });
  424. this.startUpdater();
  425. }
  426.  
  427. ReplyTracker.prototype.processFeed = function(xml) {
  428. this.elements.feed.innerHTML = "";
  429. var feed = xml.getElementsByTagName("channel").item(0);
  430. var replies = feed.getElementsByTagName("item");
  431. var feed_length = this.options.max_length > replies.length ? replies.length : this.options.max_length;
  432. for(var i = 0, added = 0; i < replies.length && added < this.options.max_length; i++)
  433. added += this.addItem(replies[i]);
  434. if(this.threads.length > 0)
  435. this.threads[this.threads.length-1].elements.row.className = "last";
  436. }
  437.  
  438. ReplyTracker.prototype.addItem = function(item) {
  439. var thread = new Thread({
  440. "title": item.getElementsByTagName("title").item(0).firstChild.data,
  441. "description": item.getElementsByTagName("description").item(0).firstChild.data,
  442. "url": item.getElementsByTagName("link").item(0).firstChild.data,
  443. "date": new Date(item.getElementsByTagName("pubDate").item(0).firstChild.data)
  444. }, this.options);
  445. if(thread.status == "read" && this.options.show_read == "no" || thread.status == "spam" && this.options.show_spam == "no")
  446. return false;
  447. this.elements.feed.appendChild(thread.elements.row);
  448. this.threads.push(thread);
  449. return true;
  450. }
  451.  
  452. new ReplyTracker();