// ==UserScript==
// @name Not a Fan
// @namespace com.tuggy.nathan
// @description Highlights, fades, or removes Facebook's fan page suggestions
// @include http://www.facebook.com/*find-friends/*
// @include http://www.facebook.com/*home.php*
// @include http://www.facebook.com/*reqs.php*
// @require http://usocheckup.dune.net/60045.js
// @require http://userscripts.org/scripts/source/49700.user.js
// @require http://userscripts.org/scripts/source/50018.user.js
// @resource configCSS http://www.tuggycomputer.com/nathan/software/userscripts/not_a_fan_config.css
//
// @copyright 2009-2010 (C) Nathan Tuggy (; )
// @license GPL version 3 or any later version;
// @timestamp 1269833803600
// @version 2.2.2.01
// ==/UserScript==
/* This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
/* Notes on the code:
Comment markers divide into loose functional sections
Constants and hacks are grouped together for faster reaction to Facebook
changes
Debug-level log messages starting with a lower-case letter are, by
convention, for temporary use only; none should normally be present in
released builds
JSLint has been used to ensure code quality;
accordingly, the script begins with some related house-cleaning
*/
/* ===== JSLint Metadata ===== */
/*jslint white: true, onevar: true, browser: true, undef: true, eqeqeq: true, bitwise: true, newcap: true, immed: true, maxerr: 100, indent: 2 */
/*members DEBUG, ERROR, FIRST_ORDERED_NODE_TYPE, INFO,
ORDERED_NODE_SNAPSHOT_TYPE, WARNING, __defineGetter__, _def,
addEventListener, appendChild, applyCurrent, autoHide, autoHideDelay,
autoHideWithDelay, backgroundColor, backgroundImage, backgroundPosition,
blockContent, childNodes, className, close,
createElement, createEvent, cssFloat, dispatchEvent, evaluate, fade,
fadeIn, fadeOut, fadePic, get, getComputedStyle, getElementById,
getElementsByTagName, getTime, height, hideLink, hideLinkImagePosition,
href, id, indexOf, init, initMouseEvent, innerHTML, isSameNode, join,
label, length, location, logLevel, margin, message, opacity, open,
options, overflow, parentNode, position, preventDefault, prototype,
push, reload, removeEventListener, replace, requests, resizeFrame, save,
selected, selectedSuggestionAction, set, setTime, setTimeout,
singleNodeValue, snapshotItem, snapshotLength, src, strikeOut, style,
suggestionBlock, suggestionGrid, suggestionGridRequestsPage,
suggestionLink, suggestions, target, textContent, textDecoration,
tipsBlock, tipsContainer, tipsTopLevelContainer, title,
toLocaleTimeString, toString, top, type, utilityImagePosition,
utilityImageUrl, width
*/
/*global GM_config, GM_log, GM_getResourceText, XPathResult, window,
findHideLink, getSuggestionGrid, checkForReplacementSuggestion, addNewSuggestionListeners
*/
var gm_config = GM_config, gm_log = GM_log, gm_getResourceText = GM_getResourceText;
/* ===== Constants, Base Functions, and Hacks ===== */
function Logger() {
var that = this;
that.__defineGetter__("ERROR", function () {
return 2;
});
that.__defineGetter__("WARNING", function () {
return 1;
});
that.__defineGetter__("INFO", function () {
return 0;
});
that.__defineGetter__("DEBUG", function () {
return -1;
});
function getLevelName(level) {
for (var m in that) {
if (that.__lookupGetter__(m) && that[m] === level) {
return m;
}
}
return null;
}
that.isLevelValid = function (level) {
return -1 !== [this.ERROR, this.WARNING, this.INFO, this.DEBUG].indexOf(level);
};
that.log = function (message, level) {
var logThreshold = gm_config.get("logLevel");
//gm_log(message + " - " + level + " + " + logThreshold + " = " + that[logThreshold]);
logThreshold = that[logThreshold];
if (!that.isLevelValid(logThreshold)) {
gm_config.openToCorrectError("logLevel");
}
if (that.isLevelValid(level)) {
if (level >= logThreshold) {
gm_log("Not a Fan " + getLevelName(level) + ": " + message.toString());
}
else {
// Do nothing
}
}
else {
throw new Error(level + " is not a valid message level; unable to log '" + message + "' appropriately.");
}
};
}
logger = new Logger();
function HacksCollection() {
// Conveniently imitate the options icon in FB Chat -- could break with a resounding clatter
var utilityImageUrl = null;
this.__defineGetter__("utilityImageUrl", function () {
var firstXButton;
if (!utilityImageUrl) {
firstXButton = findHideLink(getSuggestionGrid());
if (firstXButton) {
utilityImageUrl = window.getComputedStyle(firstXButton, null).backgroundImage
.replace(/^url\((.*)\)$/, "$1");
}
else {
logger.log("Unable to find any suggestion hide links to use for background picture information.", logger.WARNING);
}
}
return utilityImageUrl;
});
this.__defineGetter__("utilityImagePosition", function () {
return "-623px -101px";
});
this.__defineGetter__("hideLinkImagePosition", function () {
return "-857px -65px";
});
}
var HACKS = new HacksCollection();
var CLASSES = {
tipsTopLevelContainer: "UISuggestionList_ItemContainer",
tipsContainer: "UIMentor",
tipsBlock: "UIImageBlock",
suggestionBlock: "friend_grid_col",
suggestionLink: "as_link",
blockContent: "UIImageBlock_Content",
hideLink: "fg_action_hide"
};
var IDS = {
suggestionGrid: "pymk_ajax_grid",
suggestionGridRequestsPage: "pymk_grid_/reqs.php",
requestGrid: "fbpage_fan_confirm"
};
gm_config.openToCorrectError = function (prefName) {
if (prefName) {
logger.log("Invalid " + prefName + " (" + this.get(prefName) +
")! Opening config window.", logger.ERROR);
}
this.open();
// TODO: Highlight bad pref, insert alert header
window.alert("Hey, check and fix your config settings!");
};
/* ===== Configuration Settings ===== */
gm_config.init("Options for Not a Fan",
{
selectedAction: {
label: "Suggestion Action",
title: "An action to take on each matching suggestion",
type: "select",
_def: "autoHideWithDelay",
options: {
fade: "Fade entire suggestion",
fadePic: "Fade suggestion picture",
autoHide: "Hide suggestion completely",
autoHideWithDelay: "Hide suggestion after delay"
}
},
autoHideDelay: {
label: "Auto-Hide Delay",
title: "Only applicable if action is set to hide after a delay; number of seconds to delay before hiding suggestions",
type: "int",
_def: 120
},
selectedRequestAction: {
label: "Request Action",
title: "An action to take on each matching request",
type: "select",
_def: "strikeOut",
options: {
strikeOut: "Strike out the link to the fan page"
}
},
/* hideInteractionSuggestions: {
label: "Automatically hide interaction suggestions",
title: "Whether to automatically hide all suggestions from Facebook that you " +
"post on a friend's wall, send a friend a message, poke a friend, and so forth",
type: "checkbox",
_def: false
}, */
logLevel: {
label: "Minimum log output level",
title: "Log only entries of this importance or greater to the Error Console",
type: "select",
_def: "INFO",
options: {
DEBUG: "Debug messages",
INFO: "Informational messages",
WARNING: "Warnings",
ERROR: "Errors"
}
}
},
gm_getResourceText("configCSS"),
{
save: function () {
document.location.reload();
},
open: function () {
gm_config.resizeFrame("50%");
gm_config.fadeOut();
},
close: function () {
gm_config.fadeIn();
}
}
);
/* ===== XPath ===== */
function $xi(xpath, root, type) {
return document.evaluate(xpath, root || document, null, type, null);
}
function $x(xpath, root) {
return $xi(xpath, root, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
}
function $x1(xpath, root) {
return $xi(xpath, root, XPathResult.FIRST_ORDERED_NODE_TYPE).singleNodeValue;
}
/* ===== Click on an element (borrowed from Facebook Fixer, @namespace http://userscripts.org/people/14536) ===== */
function click(elm) {
var evt = document.createEvent('MouseEvents');
evt.initMouseEvent('click', true, true, window, 0, 1, 1, 1, 1, false, false, false, false, 0, null);
elm.dispatchEvent(evt);
}
/* ===== Internal Utilities ===== */
function getSuggestionGrid() {
return document.getElementById(IDS.suggestionGrid) || document.getElementById(IDS.suggestionGridRequestsPage);
}
function getRequestGrid() {
return document.getElementById(IDS.requestGrid);
}
function getTipGrid() {
function xpContains(className) {
return "div[contains(@class, '" + className + "')]";
}
var firstTipLink = $x1("//" + xpContains(CLASSES.tipsTopLevelContainer) +
"//" + xpContains(CLASSES.tipsContainer) +
"/" + xpContains(CLASSES.tipsBlock));
while (firstTipLink) {
firstTipLink = firstTipLink.parentNode;
logger.log("tip class: " + firstTipLink.className, logger.DEBUG);
if (firstTipLink.className.indexOf(CLASSES.tipsTopLevelContainer) > -1) {
return firstTipLink;
}
}
return null;
}
function getContainingSuggestionBlock(el) {
if (el.id && el.className && el.className.indexOf(CLASSES.suggestionBlock) >= 0) {
return el;
}
else {
return getContainingSuggestionBlock(el.parentNode);
}
}
function findHideLink(block) {
return $x1("descendant::*[contains(@class, '" + CLASSES.hideLink + "')]", block);
}
function findIgnoreButton(block) {
return $x1("descendant::input[name='actions[reject]']", block);
}
function fadeInHideLink(block) {
var hideLink = findHideLink(block);
if (hideLink) {
hideLink.style.backgroundPosition = HACKS.hideLinkImagePosition;
hideLink.style.backgroundColor = "black";
hideLink.style.textDecoration = "none";
// TODO: Make hover work as expected (i.e. turn blue)
logger.log("Faded in hide link for block with ID '" + getContainingSuggestionBlock(hideLink).id + "'.", logger.INFO);
return true;
}
return false;
}
function strikeOut(block) {
var textContainer = $x1("descendant::div[contains(@class, '" + CLASSES.blockContent + "')]", block);
if (textContainer) {
textContainer.style.textDecoration = "line-through";
return true;
}
return false;
}
function getSuggestionLinks(linkTextPossibilities, suggestionContainer) {
var criteria = [], i, results,
xpath = ".//button[contains(@class, '" + CLASSES.suggestionLink + "')]/span";
if (linkTextPossibilities && linkTextPossibilities.length > 0) {
for (i = 0; i < linkTextPossibilities.length; i++) {
criteria.push("contains(., '" + linkTextPossibilities[i] + "')");
}
xpath += "[" + criteria.join(" or ") + "]";
}
results = $x(xpath, suggestionContainer);
logger.log(xpath + " returned " + results.snapshotLength + " results", logger.DEBUG);
return results;
}
function getRequestBlocks(requestContainer) {
return $x(".//tr", requestContainer);
}
function containsUnwantedSuggestion(linkTextPossibilities, block) {
var links = getSuggestionLinks(linkTextPossibilities, block);
return (links !== null && links.snapshotLength > 0);
}
function positionOfNodeInParent(node) {
var i, cn = node.parentNode.childNodes, nodeCheck;
for (i = 0; i < cn.length; i++) {
nodeCheck = cn[i];
if (nodeCheck.isSameNode(node)) { // HACK: *Loose* check for equality
return i;
}
}
return -1;
}
/* ===== Suggestion/Request Actions ===== */
function ActionList(actions, selectedConfigName) {
this.applyCurrent = function (block) {
var action = this[this.selected], fContinue;
if (typeof action === "function") {
fContinue = action(block);
logger.log("Performed action " + this.selected + " on suggestion with id of " + block.id, logger.DEBUG);
return fContinue;
}
else {
throw new Error("Invalid action " + this.selected + " (" + (this.length) + ")");
}
};
this.length = 0;
for (var m in actions) {
if (typeof actions[m] === "function") {
this[m] = actions[m];
this.length++;
}
else {
logger.log("ignoring " + m + " (" + typeof actions[m] + " is not function type)", logger.DEBUG);
}
}
this.__defineGetter__("selected", function () {
//logger.log("selected: " + gm_config.get(selectedConfigName), logger.DEBUG);
return gm_config.get(selectedConfigName);
});
if (!selectedConfigName) {
throw new Error("No action selected ().");
}
}
var actions = {
suggestions: new ActionList({
fade: function (block) {
block.style.opacity = "0.35";
logger.log("Faded out suggestion block with ID '" + block.id + "'.", logger.INFO);
fadeInHideLink(block);
return true;
},
fadePic: function (block) {
var pic = $x1("descendant::img", block); // HACKy, but there's (currently) only the one tag
if (pic) {
pic.style.opacity = "0.25";
}
logger.log("Faded out picture '" + pic.src + "' for suggestion block with ID '" + block.id + "'.", logger.INFO);
fadeInHideLink(block);
return true;
},
autoHide: function (block) {
var hideLink = findHideLink(block);
if (hideLink) {
click(hideLink);
logger.log("Hid suggestion block with ID '" + block.id + "'.", logger.INFO);
return true;
}
else {
logger.log("Failed to hide suggestion block with innerHTML '" + block.innerHTML + "'; unable to find a hide button.", logger.WARNING);
return true; // ?
}
},
autoHideWithDelay: function (block) {
var delaySeconds = gm_config.get("autoHideDelay"), delay = delaySeconds * 1000, targetTime = new Date();
targetTime.setTime(targetTime.getTime() + delay);
window.setTimeout(this.autoHide, delay, block);
logger.log("Preparing to automatically hide suggestion block with ID '" + block.id + "' at " + targetTime.toLocaleTimeString() + ".", logger.INFO);
strikeOut(block);
return true;
}
}, "selectedAction"),
requests: new ActionList({
fade: function (block) {
block.style.opacity = "0.35";
logger.log("Faded out request block with ID '" + block.id + "'.", logger.INFO);
return true;
},
fadePic: function (block) {
var pic = $x1("descendant::img", block); // HACKy, but there's (currently) only the one tag
if (pic) {
pic.style.opacity = "0.25";
logger.log("Faded out picture '" + pic.src + "' for request block with ID '" + block.id + "'.", logger.INFO);
}
return true;
},
autoHide: function (block) {
var ignoreButton = findIgnoreButton(block);
if (ignoreButton) {
click(ignoreButton);
logger.log("Hid request block with ID '" + block.id + "'.", logger.INFO);
return true;
}
else {
logger.log("Failed to ignore request block with innerHTML '" + block.innerHTML + "'; unable to find an ignore button.", logger.WARNING);
return true; // ?
}
},
autoHideWithDelay: function (block) {
var delaySeconds = gm_config.get("autoHideDelay"), delay = delaySeconds * 1000, targetTime = new Date();
targetTime.setTime(targetTime.getTime() + delay);
window.setTimeout(this.autoHide, delay, block);
logger.log("Preparing to automatically hide suggestion block with ID '" + block.id + "' at " + targetTime.toLocaleTimeString() + ".", logger.INFO);
strikeOut(block);
return true;
}
/* strikeOut: function (block) {
var headerLink = $x1("./td[@class='info']//b/a", block);
if (headerLink) {
headerLink.style.textDecoration = "line-through";
}
return true;
} */
}, "selectedRequestAction")
};
var linkTextPossibilities = ["Become a Fan", "Become a Supporter"];
var suggestionGrid, requestGrid;
// TODO: Generalize and collapse these functions together -- probably move into ActionList
function actOnUnwantedSuggestions() {
var links = getSuggestionLinks(linkTextPossibilities, suggestionGrid), i, link, block;
if (!links || links.snapshotLength === 0) {
logger.log("No fan page suggestions found.", logger.DEBUG);
}
else {
for (i = 0; i < links.snapshotLength; i++) {
try {
link = links.snapshotItem(i);
block = getContainingSuggestionBlock(link);
if (!actions.suggestions.applyCurrent(block)) {
logger.log("Suggestion action " + actions.suggestions.selected + " requested action-chain stop. No more actions will be processed.", logger.DEBUG);
break;
}
}
catch (e) {
logger.log(e.message, logger.ERROR);
gm_config.openToCorrectError("selectedAction");
break;
}
}
}
}
function actOnUnwantedRequests() {
var blocks = getRequestBlocks(requestGrid), block, i;
if (!blocks || blocks.snapshotLength === 0) {
logger.log("No fan page requests found.", logger.DEBUG);
}
else {
for (i = 0; i < blocks.snapshotLength; i++) {
try {
block = blocks.snapshotItem(i);
if (!actions.requests.applyCurrent(block)) {
logger.log("Request action " + actions.requests.selected +
" requested action-chain stop. No more actions will be processed.", logger.DEBUG);
break;
}
}
catch (e) {
logger.log(e.message, logger.ERROR);
gm_config.openToCorrectError("selectedRequestAction");
break;
}
}
}
}
/* ===== Replacement-Suggestion Checking ===== */
function scheduleCheckForReplacementSuggestion(elemOriginalRow, idxOriginalColumn, idOriginal) {
logger.log("Scheduling a check for a suggestion to replace element " + idOriginal +
" in position (" + positionOfNodeInParent(elemOriginalRow) + "," + idxOriginalColumn + ")", logger.DEBUG);
window.setTimeout(checkForReplacementSuggestion, 250, elemOriginalRow, idxOriginalColumn, idOriginal);
}
function onNewSuggestion(evt) {
var elemOriginalRow, idxOriginalColumn, idOriginal;
elemOriginalRow = evt.target.parentNode;
idxOriginalColumn = positionOfNodeInParent(evt.target);
idOriginal = evt.target.id;
scheduleCheckForReplacementSuggestion(elemOriginalRow, idxOriginalColumn, idOriginal);
}
function removeNewSuggestionListeners() {
var links = getSuggestionLinks(null, suggestionGrid), block, i;
if (!links || links.snapshotLength === 0) {
logger.log("No suggestions of any sort found, so unable to stop listening for new suggestions on this page -- odd.", logger.DEBUG);
}
else {
for (i = 0; i < links.snapshotLength; i++) {
block = getContainingSuggestionBlock(links.snapshotItem(i));
if (block) {
block.removeEventListener("submit", onNewSuggestion, false);
logger.log("Removed submit listener from block " + (block.id || block.innerHTML), logger.DEBUG);
}
else {
logger.log("Found no containing block for suggestion " + links.snapshotItem(i), logger.WARNING);
}
}
}
}
function checkForReplacementSuggestion(elemOriginalRow, idxOriginalColumn, idOriginal) {
var block, removeListeners, targetLink = null;
if (idxOriginalColumn !== null) {
targetLink = elemOriginalRow.childNodes[idxOriginalColumn];
}
else {
// If idxOriginalColumn was not set, chances are the element was already gone.
logger.log("No column index was provided to checkForReplacementSuggestion!", logger.WARNING);
return; // Give up
}
if (targetLink) {
block = getContainingSuggestionBlock(targetLink);
}
if (block) {
if (block.id !== idOriginal) {
if (containsUnwantedSuggestion(linkTextPossibilities, block)) {
removeListeners = !actions.suggestions.applyCurrent(block);
if (removeListeners) {
logger.log("before removing listeners", logger.DEBUG);
removeNewSuggestionListeners();
logger.log("after removing listeners", logger.DEBUG);
}
else {
// HACK: Ensure listeners are present by (re-)adding them
addNewSuggestionListeners();
}
}
else {
logger.log("No unwanted suggestions found in replacement block " + block.id, logger.DEBUG);
}
return;
}
else {
logger.log("Replacement block " + block.id + " is still the same as the original", logger.DEBUG);
}
}
else {
logger.log("No replacement block available yet for checking", logger.DEBUG);
}
scheduleCheckForReplacementSuggestion(elemOriginalRow, idxOriginalColumn, idOriginal);
}
function addNewSuggestionListeners() {
var links = getSuggestionLinks(null, suggestionGrid), block, i;
if (!links || links.snapshotLength === 0) {
logger.log("No suggestions of any sort found, so unable to listen for new suggestions on this page.", logger.WARNING);
}
else {
for (i = 0; i < links.snapshotLength; i++) {
block = getContainingSuggestionBlock(links.snapshotItem(i));
if (block) {
block.addEventListener("submit", onNewSuggestion, false);
}
else {
logger.log("Found no containing block for suggestion " + links.snapshotItem(i), logger.WARNING);
}
}
}
}
/* ===== Top-Level Functionality ===== */
function addConfigLink() {
var grid = getTipGrid(), elemOptions = document.createElement("small"),
elemOptionsLink, elemIcon, s;
if (!grid) {
logger.log("Unable to find tip grid", logger.INFO);
grid = suggestionGrid;
}
grid.appendChild(elemOptions); //suggestionGrid.parentNode.appendChild(elemOptions);
elemOptionsLink = elemOptions.appendChild(document.createElement("a"));
elemOptionsLink.addEventListener("click", function (e) {
e.preventDefault();
gm_config.open();
}, false);
elemOptionsLink.textContent = "Naf Options";
elemOptionsLink.href = "#";
elemOptionsLink.title = "Open configuration for Not a Fan";
s = elemOptionsLink.style;
s.position = "relative";
s.top = "-0.5em";
s.cssFloat = "right";
elemIcon = elemOptionsLink.appendChild(document.createElement("div"));
s = elemIcon.style;
s.backgroundImage = "url(" + HACKS.utilityImageUrl + ")";
s.backgroundPosition = HACKS.utilityImagePosition;
s.margin = "3px 4px 0 0";
s.width = (s.height = "8px");
s.overflow = "hidden";
s.cssFloat = "left";
}
function onload() {
suggestionGrid = getSuggestionGrid();
if (suggestionGrid) {
addConfigLink();
actOnUnwantedSuggestions();
addNewSuggestionListeners();
}
else {
logger.log("Couldn't find suggestion grid!", logger.ERROR);
}
requestGrid = getRequestGrid();
if (requestGrid) {
actOnUnwantedRequests();
}
else {
// Assume there merely are none or it's not even the right page
// TODO: Log DEBUG only if it's the Requests page
logger.log("Didn't find any fan page requests.", logger.DEBUG);
}
}
window.addEventListener("load", onload, false);