Last.fm - Shoutbox Quick Reply

By kepp Last update Oct 21, 2007 — Installed 3,375 times.
// ==UserScript==
// @name            Last.fm - Shoutbox Quick Reply
// @namespace       http://lastfm.shoutbox.quick.reply/kepp/
// @description     Allows you to reply to posts in your shoutbox from your shoutbox
// @include         http://www.last.fm/user/*
// @include         http://www.last.fm/dashboard/*
// ==/UserScript==

/**
 *    v1.1.3 (10/20/2007)
 *    - Fix for breakage on some profiles??
 *    - Moved reply note toggle into page
 *    - Updated version history with the correct numbers...
 *    - Fix for change of script location/getting user's resid
 *
 *    v1.1.2 (3/04/2007)
 *    - Fix so it handles users with ? in their username
 *
 *    v1.1.1 (1/26/2007)
 *    - Fix so it works on hosts other than "www.last.fm"
 *
 *    v1.1 (11/16/2006)
 *    - Copy checkbox checked state is persisted
 *    - Copied replies are no longer truncated too short
 *
 *    v1.0.7 (11/16/2006)
 *    - Site update fixes, no more image reply button :(
 *
 *    v1.0.6 (10/31/2006)
 *    - Added support for the shoutbox popup
 *
 *    v1.0.5 (10/23/2006)
 *    - Update for site changes
 *
 *    v1.0.4 (9/19/2006)
 *    - More cosmetic changes
 *    - Fixed for cached and new data not being handled the same
 *
 *    v1.0.3 (9/16/2006)
 *    - Cosmetic changes
 *
 *    v1.0.2 (9/15/2006)
 *    - Woops, fix for dashboard page
 *
 *    v1.0.1 (9/15/2006)
 *    - Updated for site changes
 *
 *    v1.0 (9/04/2006)
 *    - Tf the copy checkbox is checked the reply is copied back to your shoutbox
 *    - There's a pref in the menu so a note is added to copied replies
 *    - Clicking the reply button toggles the reply box
 *    - Clicking the cancel button toggles the reply box and resets it
 *    - In the textarea,
 *        esc: resets and hides the reply box, like pressing the cancel button
 *        shift + esc: hides the reply box
 *        ctrl + enter: sends the reply
 *
 *
 *    bugs? -> kepp on Last.fm
 **/

const PREFIX = "gm_shoutbox_quick_reply_";
const MAX_LENGTH = 400;
const SENDING_SRC = "http://static.last.fm/tageditor/progress_active.gif";
const TOGGLE_SRC = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAALCAMAAAB4W0xQAAAAElBMVEXX19eMjIxwcHDx8fH///////+2TxzQAAAABnRSTlP//////wCzv6S/AAAAP0lEQVR42lXLQQ6AUAwC0dLf3v/KDmiMzoLkLaj9FUr6UDMvrTOi0DrjJIiefKmuG0VgMegEMdoEPWaCuGG6ALeMAh6VC6SsAAAAAElFTkSuQmCC";
const LOCALE = {en: ["Reply to %#", "reply", "Saving...", "%# characters used",
                     "Copy", "Reply", "Cancel", "Reply note: ", "on", "off"]};

var s = getStrings();

function getStrings() {
  var lang = document.documentElement.getAttribute("lang");
  if (lang in LOCALE) {
    return LOCALE[lang];
  }
  return LOCALE["en"];
}

function $x(query, context, array) {
  var result = document.evaluate(query, (context || document), null,
               XPathResult.ANY_TYPE, null);

  if (array) {
    var nodes = new Array(), node;
    while (node = result.iterateNext()) {
      nodes.push(node);
    }
    return nodes;
  }

  switch (result.resultType) {
    case result.STRING_TYPE:  return result.stringValue;
    case result.NUMBER_TYPE:  return result.numberValue;
    case result.BOOLEAN_TYPE: return result.booleanValue;
  }

  return result.iterateNext();
}




function cacheResId(user, resId) {
  GM_setValue(user + "_resId", resId);
}

function getCachedResId(user) {
  return GM_getValue(user + "_resId");
}



// get the resid of a user from their profile data feed
function getResId(user, element) {
try {
  var resId = getCachedResId(user);

  if (!resId) {

    GM_xmlhttpRequest({
      method: "GET",
      url: "http://ws.audioscrobbler.com/1.0/user/" +
           encodeURIComponent(user) + "/profile.xml",
      onload: function(details) {
        var profile = new XML(details.responseText
                      .replace(/^<\?xml version[^]+?>/, ""));
        resId = Number(profile.@id);
        cacheResId(user, resId);
        element.setAttribute("resid", user + "=" + resId);
      }
    });
  } else {
    element.setAttribute("resid", user + "=" + resId);
  }
} catch (e) {
  GM_log("getResId:" + e);
}
}

// used to get your own resid
function getUserResId(user, pageType, element) {
try {
  var resId = getCachedResId(user);

  if (resId) {
    element.setAttribute("resid", resId);
  } else {

    if (pageType == "dashboard") {
      getResId(user, element);
    } else {
      var scriptsPath = "/html/body//*[@id='shoutboxPanel']/following-sibling::script";
      var scripts = $x(scriptsPath);
      resId = scripts.innerHTML.match(/resid=(\d+)/)[1];
      GM_setValue(user + "_resId", resId);
      element.setAttribute("resid", user + "=" + resId);
    }
  }
} catch (e) {
  GM_log("getUserResId:" + e);
}
}



// image link to toggle the display of the reply form
function addReplyLink(shoutboxMsg) {
try {
  addReplyForm(shoutboxMsg);

  var poster = $x("a/strong/span", shoutboxMsg).textContent;
  var replyInfo = s[0].replace("%#", poster);

  var replyLink = document.createElement("a");
  replyLink.innerHTML = s[1];
  replyLink.href = "javascript:;";
  replyLink.title = replyInfo;
  replyLink.className = PREFIX + "reply_link";
  replyLink.addEventListener("click", toggleReplyForm, true);

  var date = $x("small[last()]", shoutboxMsg);
  date.innerHTML = date.innerHTML.replace(/\s+$/, " - ")
  date.appendChild(replyLink);
} catch (e) {
  GM_log("addReplyLink:" + e);
}
}

// add the reply forms to the shoutboxes, initially hidden
function addReplyForm(shoutboxMsg) {
try {
  var replyForm = createReplyForm();
  var replyStatus = createReplyStatus();
  var textArea = createTextArea(replyForm);
  var commands = createCommands(replyForm);

  replyForm.addEventListener("DOMAttrModified", function(event) {
                             updateStatus(event, replyForm); }, false);
  textArea.addEventListener("keyup", function(event) {
    setTimeout(function(event, replyForm) { // lag reducer
        updateStatus(event, replyForm); }, 0, event, replyForm)
    }, false);

  replyForm.appendChild(replyStatus);
  replyForm.appendChild(textArea);
  replyForm.appendChild(commands);

  shoutboxMsg.appendChild(replyForm);
} catch (e) {
  GM_log("addReplyForm:" + e);
}
}

// image used to indicate the message is being sent
function addSendingImage(shoutboxMsg) {
try {
  var sendingBox = document.createElement("div");
  sendingBox.className = PREFIX + "sending";
  sendingBox.style.display = "none";

  var sendingImage = document.createElement("img");
  sendingImage.src = SENDING_SRC;
  sendingImage.alt = "Sending image";

  sendingBox.appendChild(sendingImage);
  sendingBox.appendChild(document.createTextNode(s[2]));
  shoutboxMsg.appendChild(sendingBox);
} catch (e) {
  GM_log("addSendingImage:" + e);
}
}


// create the container form for all the stuff to send a reply
function createReplyForm(boxClass) {
  var replyForm = document.createElement("form");
  replyForm.className = PREFIX + "reply_form";
  replyForm.style.display = "none";

  return replyForm;
}

// create a status label for the status of your reply, this includes a
// character count and a loading icon
function createReplyStatus() {
  var replyStatus = document.createElement("div");
  replyStatus.className = PREFIX + "status";

  var loadStatus = document.createElement("img");
  loadStatus.src = SENDING_SRC;

  var charCount = document.createElement("span");
  charCount.innerHTML = s[3].replace("%#", "0/" + MAX_LENGTH);

  replyStatus.appendChild(loadStatus);
  replyStatus.appendChild(charCount);

  return replyStatus;
}

// create the text box to type in
function createTextArea(replyForm) {
  var textArea = document.createElement("textarea");
  textArea.addEventListener("keyup", function(event) {
    if (event.ctrlKey && event.keyCode == 13) {
        processReply(replyForm);
    } else if (event.shiftKey && event.keyCode == 27) {
        replyForm.style.display = "none";
    } else if (event.keyCode == 27) {
        resetReplyForm(replyForm);
    }
  }, false);

  return textArea;
}

// create a form input field, used to make the cancel and reply buttons
function createInput(type, value) {
  var input = document.createElement("input");
  input.type = type;
  if (value) { input.value = value; }

  return input;
}

// make a checkbox to select if you want to copy the reply to yourself
function createCopySelect() {
  var copySelect = document.createElement("span");
  var checkbox = createCopyCheckbox();
  var copyNote = createCopyNote(checkbox);

  copySelect.insertBefore(checkbox, copySelect.firstChild);
  copySelect.appendChild(copyNote);

  return copySelect;
}

function createCopyCheckbox() {
  var checkbox = document.createElement("input");
  checkbox.type = "checkbox";
  checkbox.addEventListener("change", function(event) {
    GM_setValue("copy", Boolean(event.target.checked));
  }, false);
  checkbox.addEventListener("keypress", function(event) {
    if (event.charCode == 43) {
        checkbox.checked = true;
    } else if (event.charCode == 45) {
        checkbox.checked = false;
  }}, false);
  return checkbox;
}

function createCopyNote(checkbox) {
  var noteToggle = document.createElement("a");
  noteToggle.href = "javascript:;";
  noteToggle.innerHTML = s[4];
  noteToggle.addEventListener("click", function(event) {
    var checked = !checkbox.checked;
    checkbox.checked = checked;
    GM_setValue("copy", checked);
  }, true);
  return noteToggle;
}

function createCommands(replyForm) {
  var copySelect = createCopySelect();
  var replyButton = createInput("button", s[5]);
  replyButton.disabled = true;
  var cancelButton = createInput("button", s[6]);

  replyButton.addEventListener("click", function(event) {
          processReply(replyForm); }, false);
  cancelButton.addEventListener("click", function(event) {
          resetReplyForm(replyForm); }, false);

  var commands = document.createElement("div");
  commands.appendChild(copySelect);

  var buttons = document.createElement("span");
  buttons.appendChild(cancelButton);
  buttons.appendChild(replyButton);
  commands.appendChild(buttons);

  return commands;
}



// update the status label and reply button according to the current state
function updateStatus(event, replyForm) {
try {
  var textArea = $x("textarea", replyForm);
  var label = $x("div[1]/span", replyForm);
  var replyButton = $x("div[2]/span[2]/input[2]", replyForm);

  var hasIds = hasResIds(textArea.parentNode);

  // color the textarea and label
  if (textArea.value.length > MAX_LENGTH) {
    textArea.style.backgroundColor = "#D01F3C";
    replyButton.disabled = true;
  } else if (textArea.value.length > 0) {
    textArea.style.backgroundColor = "white";
    replyButton.disabled = !hasIds;
  } else {
    // need this for the initial showing of a fresh form
    replyButton.disabled = !hasIds;
  }

  if (hasIds) { // hide the loading image
    $x("div/img", replyForm).style.visibility = "hidden";
  }

  // update the character count as stuff is typed
  if (event.target.localName == "TEXTAREA") {
    label.innerHTML = textArea.value.length + "/" + MAX_LENGTH;
  }
} catch (e) {
  GM_log("updateStatus:" + e);
}
}

// test if the resids have been retrieved
function hasResIds(replyForm) {
try {
  var hasPosterResId = replyForm.hasAttribute("resid");
  var hasUserResId = true;
  if ($x("div[2]/span/input", replyForm).checked) {
    hasUserResId = $x("../../..", replyForm).hasAttribute("resid");
  }
  return (hasPosterResId && hasUserResId);
} catch (e) {
  GM_log("hasResIds:" + e);
}
}

// send your reply to the other user's shoutbox, and your own if selected
function processReply(replyForm) {
  var message = $x("textarea", replyForm).value;
  var copyCheckbox = $x("div[2]/span/input", replyForm);
  var poster = replyForm.getAttribute("resid").split("=");

  if (copyCheckbox.checked) {
    sendReply(message, poster[1], replyForm, false);

    // add the note indicating it's a reply
    // auto-truncate the message if needed
    var me = $x("../../../@resid", replyForm).value;
    var note = "\u25BA " + poster[0] + ": ";
    if (GM_getValue("add_reply_note", false)) {
      message = note + message.slice(0, MAX_LENGTH - note.length);
    }
    setTimeout(function() { // there's some kind of flood protection?
      sendReply(message, me, replyForm, true); }, 5000);
  } else {
    sendReply(message, poster[1], replyForm, true);
  }
}

function sendReply(message, resId, replyForm, last) {
  setStatusSending(true, replyForm);
  message = encodeURIComponent(message);

  GM_xmlhttpRequest({
    method: "POST",
    headers:{ "Content-type": "application/x-www-form-urlencoded" },
    url: "http://" + location.host + "/shoutbox/",
    data: "message=" + message + "&restype=4&resid=" + resId + "&lang=en",
    onload: function(details) {
      if (last) { // assume that it's done
        setStatusSending(false, replyForm);
        if ($x("div[2]/span/input", replyForm).checked) {
          prependShout(replyForm, details.responseText);
        }
      }
    }
  });
}

function setStatusSending(show, replyForm) {
  if (show) {
    replyForm.style.display = "none";
    replyForm.nextSibling.style.display = "block";
  } else {
    replyForm.nextSibling.style.display = "none";
    resetReplyForm(replyForm);
  }
}

function prependShout(replyForm, html) {
  var firstShout = $x("../../li", replyForm);
  var newShout = document.createElement("div");
  newShout.innerHTML = html;

  if (firstShout.id == "insertShoutBeforeHere") { // insert new copied shout
    newInsertBefore = document.createElement("div");
    newInsertBefore.id = "insertShoutBeforeHere";

    firstShout.id = "shoutmsgMeow23";
    firstShout.parentNode.insertBefore(newInsertBefore, firstShout);
    firstShout.parentNode.insertBefore(newShout, firstShout);
  } else {
    firstShout.parentNode.replaceChild(newShout, firstShout);
  }
}

// cancel the reply you were typing and reset the reply form
// this is used when the cancel button is clicked and on the succcessful
// posting of a reply
function resetReplyForm(replyForm) {
  replyForm.style.display = "none";
  var charCount = $x("div/span", replyForm);
  var textArea = $x("textarea", replyForm);
  var copyCheckbox = $x("div[2]/span/input", replyForm);

  // reset the character count, textarea, and checkbox out of sight
  window.setTimeout(function() {
    copyCheckbox.checked = false;
    textArea.style.backgroundColor = "";
    textArea.value = "";

    // changing the above calls updateStatus, etc, so keep this after
    charCount.style.color = "";
    charCount.innerHTML = s[3].replace("%#", "0/" + MAX_LENGTH);
  }, 0);
}

// toggle whether the reply form is shown or hidden
function toggleReplyForm(event) {
  event.stopPropagation();
  event.preventDefault();

  var shoutboxMsg = this.parentNode.parentNode; // <_<

  // toggle display
  var formClass = PREFIX + "reply_form";
  var replyForm = $x("form[@class='" + formClass + "']", shoutboxMsg);

  if (replyForm.style.display == "none") {

    // get the id of the person who posted
    var poster = $x("a/strong/span", shoutboxMsg).textContent;
    getResId(poster, replyForm);

    var copy = $x("div[2]/span/input", replyForm);
    copy.checked = GM_getValue("copy", false);

    replyForm.style.display = "block";
    $x("textarea", replyForm).focus();
  } else {
    replyForm.style.display = "none";
  }
}


function addToggle() {
  var toggle = document.createElement("div");
  toggle.innerHTML = s[7];

  var status = document.createElement("a");
  status.href = "";
  status.textContent = GM_getValue("add_reply_note", true) ? s[8] : s[9];
  status.addEventListener("click", function(event) {
    event.preventDefault();
    var setting = GM_getValue("add_reply_note", false);
    GM_setValue("add_reply_note", !setting);
    this.textContent = setting ? s[9] : s[8];
  }, false);
  toggle.appendChild(status);

  $x("id('shoutPostToggler')|//dd[@id='shoutboxPanel']").appendChild(toggle);
}

(function() {
  var css = 
    "." + PREFIX + "status span {" +
    "  float: left; }" +
    "." + PREFIX + "status {" +
    "  padding-top: 5px; }" +
    "." + PREFIX + "status img {" +
    "  float: right;" +
    "  height: 16px; }" +
    "." + PREFIX + "sending {" +
    "  text-align: center;" +
    "  height: 75px;" +
    "  padding: 5px;" +
    "  margin: 10px 0 0 -5px; }" +
    "." + PREFIX + "sending img {" +
    "  vertical-align: middle;" +
    "  padding: 4px; }" +
    "." + PREFIX + "reply_form textarea {" +
    "  width: 160px;" +
    "  height: 64px; }" +
    "." + PREFIX + "reply_form input[type=button] {" +
    "  font-size: 9px;" +
    "  height: 18px;" +
    "  width: 45px;" +
    "  padding: 0 0 15px 0;" +
    "  margin: 1px 1px 0 0;}" +
    "." + PREFIX + "reply_form div:last-child span:first-child {" +
    "  float: left; }" +
    "." + PREFIX + "reply_form div:last-child {" +
    "  height: 18px; }" +
    "." + PREFIX + "reply_form div:last-child span:last-child {" +
    "  float: right; }" +
    "." + PREFIX + "links {" +
    "  width: 27px;" +
    "  clear: both;" +
    "  float: right; }";
  GM_addStyle(css);

  // get your username from the panel in the upper right, if it's there
  try {
    var user = $x("id('profileImage')/@href").value.split("/")[2];
  } catch (e) {}

  // get the username of the page owner, and the page type, from the url
  var url = location.href.split("/");
  var owner = url[4].match(/[^?]+/);
  var pageType = url[3];

  if (user == owner || pageType == "dashboard") {
try {
    // get the shoutbox messages or the most recent shout
    var shoutbox = $x("id('c_shoutboxPanel')|//dd[@id='shoutboxPanel']");
    var messages = $x("ul/li[a]", shoutbox, true);

    getUserResId(user, pageType, shoutbox); // get your user id

    for each (var message in messages) {
      addReplyLink(message);    // toggle for the reply boxes
      addSendingImage(message); // sending status image
    }

    addToggle();
} catch (e) {
  GM_log("anon:" + e);
}

  }

})();