Skip to content

Instantly share code, notes, and snippets.

@qqueue
Last active December 16, 2015 05:28
Show Gist options
  • Save qqueue/5384225 to your computer and use it in GitHub Desktop.
Save qqueue/5384225 to your computer and use it in GitHub Desktop.
A "modular" 4chan userscript project, now defunct. My other project `html5chan` is now renamed `c4`, so I've moved this code to a gist for archival.

c4: The Inevitability of Code Rot: 4chan Edition

c4 was a modular set of userscripts I made and wanted to maintain, but then 4chan got its own "native" extension. I'll merge the features from here into there at some point through pull requests. In the meantime, enjoy the historic experience.

Components

Relative Times (Install)

Reformats all post dates relatively, e.g. '38 seconds ago' or '2 hours ago'. The times will also dynamically update relative to the current time.

Image Hover Previews (Install)

Shows a lightbox-style preview when you mouse over an image, like a tooltip, but shown as large as possible with a dark background.

Updater (Install)

A (currently) bare-bones automatic thread updater, that appends new replies to a thread as they appear. All other c4 scripts and vanilla 4chan settings will work on the dynmically updated replies, but other scripts will probably not.

Unicode (re)Markup (Install)

Converts posts with Mathematical Alphanumeric Symbols (that Aeosynth's 4ChanX recently added to emulate italics, bold, and code markdown) back into regular text, wrapped in the appropriate <i>, <b>, or <code> tag. Among other things, this means they'll display correctly in Chrome on Windows.

This feature no longer works or exists, but the code is still neat.

Planned Additions

  • Page title updater with unread count.
  • Better backlinking implementation than vanilla 4chan.
  • Better post hover previews than vanilla.
  • Inline post expansion.
  • URL linkification
  • Youtube embedding
  • Thread/post hiding/filtering
  • Image expansion / preloading
  • Image source links
// ==UserScript==
// @name c4 -- image hover previews
// @author queue
// @namespace https://github.com/queue-/c4
// @description Lightbox-style image previews for 4chan
//
// @version 0.1
//
// @match http://boards.4chan.org/*
// @match https://boards.4chan.org/*
// @exclude http://boards.4chan.org/f/*
// @exclude https://boards.4chan.org/f/*
//
// ==/UserScript==
"use strict";
var delay = 200, //ms, time until preview is created after no movement
dead_zone = 10, //px, where mouse movement won't remove preview
margin = 10; //px from edge of viewport to preview
var hover_preview = function (e) {
var dimensions, width, height, docheight, docwidth, ratio, timeout,
reset_preview, create_preview, remove_preview, destroy, preview, x, y, self;
// avoid previewing inline-expanded images
if ( this.firstElementChild.className === "expandedImg" ) { return; }
// the current mouse position, for deadzone checking
x = e.clientX;
y = e.clientY;
// original dimensions are parsed from the <div.fileInfo>
// before the <div.fileThumb> (this)
dimensions = /(\d+)x(\d+)/.exec( this.previousElementSibling.textContent );
width = parseInt( dimensions[1], 10 );
height = parseInt( dimensions[2], 10 );
// the dimensions of the viewport excluding scrollbars
// see http://code.google.com/p/doctype-mirror/wiki/ArticleViewportSize
docheight = document.documentElement.clientHeight;
docwidth = document.documentElement.clientWidth;
// scale the dimensions to fit in the viewport minus margin
ratio = Math.min( 1, ( docheight - margin * 2 ) / height, ( docwidth - margin * 2 ) / width );
width *= ratio;
height *= ratio;
// prevents the preview from being created until [delay] ms
// without mouse movement (like a native tooltip)
reset_preview = function (e) {
clearTimeout( timeout );
timeout = setTimeout( create_preview, delay );
x = e.clientX;
y = e.clientY;
};
self = this;
// this function runs as a timeout, so 'this' bind is needed
create_preview = function () {
preview = document.createElement( 'img' );
preview.src = self.href;
preview.width = width;
preview.height = height;
// abuse the alt tag for useful loading text
preview.alt = 'Loading...';
preview.addEventListener( 'load', function () {
this.removeAttribute( 'alt' );
this.style.opacity = '';
});
preview.addEventListener( 'error', function () {
this.alt = 'Unable to load image.';
});
preview.style.position = 'fixed';
preview.style.top = 0;
preview.style.left = 0;
preview.style.pointerEvents = 'none'; // lets mouse events pass through
// center image with [margin]
// and expands darkened background to entire screen
preview.style.backgroundColor = 'rgba( 0, 0, 0, .5 )';
preview.style.padding =
( ( docheight - height ) / 2 )
+ 'px '
+ ( ( docwidth - width ) / 2 )
+ 'px';
// because of firefox's incredibly stupid behavior
// of restarting gif playback once the image is fully loaded
// I indicate the gif is still loading by making it slightly transparent
if( /\.gif$/.test( preview.src ) ) {
preview.style.opacity = 0.7;
}
// switch mousemove handler to remove
self.removeEventListener( 'mousemove', reset_preview );
self.addEventListener( 'mousemove', remove_preview );
document.body.appendChild( preview );
};
remove_preview = function (e) {
if ( Math.abs( x - e.clientX ) > dead_zone || Math.abs( y - e.clientY ) > dead_zone ) {
document.body.removeChild( preview );
preview = void 0; // undefined
// restart tooltip timeout
timeout = setTimeout( create_preview, delay );
this.addEventListener( 'mousemove', reset_preview );
this.removeEventListener( 'mousemove', remove_preview );
}
};
// start initial timeout
timeout = setTimeout( create_preview, delay );
this.addEventListener( 'mousemove', reset_preview );
// remove previewed image and handlers when image is expanded (on click)
// or on mouseout
destroy = function () {
// removeChild throws exception when trying to remove
// a non-child node. Seems kind of stupid.
if ( preview && preview.parentNode === document.body ) {
document.body.removeChild( preview );
}
clearTimeout( timeout );
this.removeEventListener( 'mouseout', destroy );
this.removeEventListener( 'click', destroy );
this.removeEventListener( 'mousemove', reset_preview );
this.removeEventListener( 'mousemove', remove_preview );
};
this.addEventListener( 'click', destroy );
this.addEventListener( 'mouseout', destroy );
};
// the handler attaches to all images using a delegated event handler
// e.g. jQuery.live(), so that the handler will survive DOM changes from
// updates as well as 4chan's shitty native backlinker
//
// also, moot's shitty markup has an id on the delform around <div.board>
// but it's <div class=board> instead of an id. what a faggot
document.getElementById('delform').addEventListener( 'mouseover', function (e) {
// since the images take the mouseover event from their containing <a>
// because they're this strange "rendered content" type, and technically
// rendered as display: inline, we check the parent element instead
var anchor = e.target.parentNode;
if ( anchor.classList.contains('fileThumb') ) {
hover_preview.call( anchor, e );
}
});
// ==UserScript==
// @name c4 -- relative times
// @author queue
// @namespace https://github.com/queue-/c4
// @description Pretty relative post times for 4chan
//
// @version 0.1
//
// @match http://boards.4chan.org/*
// @match https://boards.4chan.org/*
// @exclude http://boards.4chan.org/f/*
// @exclude https://boards.4chan.org/f/*
//
// ==/UserScript==
"use strict";
// cheap pluralize that takes advantage of banker's rounding
// and the fact that none of the time units have weird pluralizations
var pluralize = function (number, unit) {
return Math.round( number ) + ' ' + unit + ( number >= 1.5 ? 's' : '' ) + ' ago';
};
var make_relative = function (el) {
var time;
time = parseInt( el.dataset.utc, 10 ) * 1000 ;
// retain original absolute time as title text
el.title = el.textContent;
// updates a <span.dateTime> as often as necessary
// with a pretty time
// adapted partially from John Resig's pretty date
(function relative_time () {
var diff, days, hours, minutes, seconds;
if ( ( days = ( ( diff = Date.now() - time ) / 86400000 ) ) > 1 ) {
el.textContent = pluralize( days, 'day' );
setTimeout( relative_time, 86400000 ); // just in case
} else if ( ( hours = days * 24 ) > 1 ) {
el.textContent = pluralize( hours, 'hour' );
setTimeout( relative_time, 3600000 );
} else if ( ( minutes = hours * 60 ) > 1 ) {
el.textContent = pluralize( minutes, 'minute' );
setTimeout( relative_time, 300000 ); // every 5 minutes
} else if ( ( seconds = minutes * 60 ) >= 1 ) {
el.textContent = pluralize( seconds, 'second' );
// every 60 seconds because updating freezes scrolling
// because of DOM manip, which is annoying
setTimeout( relative_time, 60000 );
} else {
el.textContent = 'from the future!';
setTimeout( relative_time, diff ); // once it is no longer the future
}
})(); // call it for the first time
};
// forEach is used instead of a for loop, because the el needs to be
// captured in a scope anyway (so el doesn't refer to the same element )
Array.prototype.forEach.call( document.getElementsByClassName('dateTime'), make_relative );
// support for c4-updated threads
window.addEventListener( 'beforeupdate', function (e) {
// document fragments don't have getElementsByClassName
Array.prototype.forEach.call( e.detail.fragment.querySelectorAll('.dateTime'), make_relative );
});
// ==UserScript==
// @name c4 -- unicode markup
// @author queue
// @namespace https://github.com/queue-/c4
// @description converts unicode bold/italic/monospace back to real letters and HTML
//
// @version 0.2
//
// @match http://boards.4chan.org/*
// @match https://boards.4chan.org/*
// @exclude http://boards.4chan.org/f/*
// @exclude https://boards.4chan.org/f/*
//
// ==/UserScript==
"use strict";
var alphanum, bold, italic, bold_italic, monospace, regex, char_regex, CharMap, map, replacer, replace, markup;
alphanum = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"];
bold = ["𝟎", "𝟏", "𝟐", "𝟑", "𝟒", "𝟓", "𝟔", "𝟕", "𝟖", "𝟗", "𝐚", "𝐛", "𝐜", "𝐝", "𝐞", "𝐟", "𝐠", "𝐡", "𝐢", "𝐣", "𝐤", "𝐥", "𝐦", "𝐧", "𝐨", "𝐩", "𝐪", "𝐫", "𝐬", "𝐭", "𝐮", "𝐯", "𝐰", "𝐱", "𝐲", "𝐳", "𝐀", "𝐁", "𝐂", "𝐃", "𝐄", "𝐅", "𝐆", "𝐇", "𝐈", "𝐉", "𝐊", "𝐋", "𝐌", "𝐍", "𝐎", "𝐏", "𝐐", "𝐑", "𝐒", "𝐓", "𝐔", "𝐕", "𝐖", "𝐗", "𝐘", "𝐙"];
italic = ["𝑎", "𝑏", "𝑐", "𝑑", "𝑒", "𝑓", "𝑔", "ℎ", "𝑖", "𝑗", "𝑘", "𝑙", "𝑚", "𝑛", "𝑜", "𝑝", "𝑞", "𝑟", "𝑠", "𝑡", "𝑢", "𝑣", "𝑤", "𝑥", "𝑦", "𝑧", "𝐴", "𝐵", "𝐶", "𝐷", "𝐸", "𝐹", "𝐺", "𝐻", "𝐼", "𝐽", "𝐾", "𝐿", "𝑀", "𝑁", "𝑂", "𝑃", "𝑄", "𝑅", "𝑆", "𝑇", "𝑈", "𝑉", "𝑊", "𝑋", "𝑌", "𝑍"];
bold_italic = ["𝒂", "𝒃", "𝒄", "𝒅", "𝒆", "𝒇", "𝒈", "𝒉", "𝒊", "𝒋", "𝒌", "𝒍", "𝒎", "𝒏", "𝒐", "𝒑", "𝒒", "𝒓", "𝒔", "𝒕", "𝒖", "𝒗", "𝒘", "𝒙", "𝒚", "𝒛", "𝑨", "𝑩", "𝑪", "𝑫", "𝑬", "𝑭", "𝑮", "𝑯", "𝑰", "𝑱", "𝑲", "𝑳", "𝑴", "𝑵", "𝑶", "𝑷", "𝑸", "𝑹", "𝑺", "𝑻", "𝑼", "𝑽", "𝑾", "𝑿", "𝒀", "𝒁"];
monospace = ["𝟶", "𝟷", "𝟸", "𝟹", "𝟺", "𝟻", "𝟼", "𝟽", "𝟾", "𝟿", "𝚊", "𝚋", "𝚌", "𝚍", "𝚎", "𝚏", "𝚐", "𝚑", "𝚒", "𝚓", "𝚔", "𝚕", "𝚖", "𝚗", "𝚘", "𝚙", "𝚚", "𝚛", "𝚜", "𝚝", "𝚞", "𝚟", "𝚠", "𝚡", "𝚢", "𝚣", "𝙰", "𝙱", "𝙲", "𝙳", "𝙴", "𝙵", "𝙶", "𝙷", "𝙸", "𝙹", "𝙺", "𝙻", "𝙼", "𝙽", "𝙾", "𝙿", "𝚀", "𝚁", "𝚂", "𝚃", "𝚄", "𝚅", "𝚆", "𝚇", "𝚈", "𝚉"];
// Javascript's unicode support in regex ranges can't actually match the
// extended mathematical alphanumeric symbols that 4chanX uses, as they're
// double-byte characters (except for italic h), so these are instead compiled
// as alternations (a|b|c...).
regex = {
b: new RegExp( '(?:' + bold.join('|') + ')+', 'g' ),
i: new RegExp( '(?:' + italic.join('|') + ')+', 'g' ),
bi: new RegExp( '(?:' + bold_italic.join('|') + ')+', 'g' ),
m: new RegExp( '(?:' + monospace.join('|') + ')+', 'g' )
};
// these match a _single_ (double-byte) unicode character, so
// they can be replaced one by one with their alphanumeric equivalent
// used in replace.*
char_regex = {
b: new RegExp( bold.join('|'), 'g' ),
i: new RegExp( italic.join('|'), 'g' ),
bi: new RegExp( bold_italic.join('|'), 'g' ),
m: new RegExp( monospace.join('|'), 'g' )
};
// constructor for a map from unicode char to alpha char,
// exploiting JS's objects as string -> obj hashmaps
CharMap = function (type) {
for ( var i = 0, len = type.length; i < len; ++i ) {
this[ type[i] ] = alphanum[i];
}
};
// and to avoid dealing with fromCharCode and unicode, these are manual maps
// unicode back to the equivalent ASCII
map = {
b: new CharMap( bold ),
// there aren't italic numbers, but to make the indices line up, we add
// regular alpha numbers to the beginning of italics
i: new CharMap( ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].concat( italic ) ),
bi: new CharMap( ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].concat( bold_italic ) ),
m: new CharMap( monospace )
};
// the 2nd argument to replace() inside {replace} that converts from unicode
// math to a regular alphanum, using map
replacer = {
b: function (c) { return map.b[c]; },
i: function (c) { return map.i[c]; },
bi: function (c) { return map.bi[c]; },
m: function (c) { return map.m[c]; }
}
// the 2nd argument to replace inside markup() that converts the
// entire string using char_regex and replacer and adds HTML markup
replace = {
b: function (str) {
return '<b>' + str.replace( char_regex.b, replacer.b ) + '</b>';
},
i: function (str) {
return '<i>' + str.replace( char_regex.i, replacer.i ) + '</i>';
},
bi: function (str) {
return '<i><b>' + str.replace( char_regex.bi, replacer.bi ) + '</b></i>';
},
m: function (str) {
return '<code>' + str.replace( char_regex.m, replacer.m ) + '</code>';
}
};
// TODO instead use splitTextNode to avoid messing with event listeners
// attached to quotelinks and shit.
markup = function (comment) {
var nodes, node, text, span;
// need a non-live childNodes array
nodes = Array.prototype.slice.call( comment.childNodes );
for ( var i = 0, len = nodes.length; i < len; ++i ) {
node = nodes[i];
// markup quotes and spoilers too
if ( node.nodeName === 'SPAN' ) {
text = node.textContent;
// TODO fix code repetition, it's unsightly
if ( regex.b.test( text ) || regex.i.test( text ) || regex.bi.test( text ) || regex.m.test( text ) ) {
node.innerHTML = node.innerHTML
.replace( regex.b, replace.b )
.replace( regex.i, replace.i )
.replace( regex.bi, replace.bi )
.replace( regex.m, replace.m );
}
} else if ( node.nodeType === Node.TEXT_NODE ) {
text = node.textContent;
// test to see if node contains unicode text
if ( regex.b.test( text ) || regex.i.test( text ) || regex.bi.test( text ) || regex.m.test( text ) ) {
// replace it with a span containing marked up text.
span = document.createElement( 'span' );
span.innerHTML = text
.replace( regex.b, replace.b )
.replace( regex.i, replace.i )
.replace( regex.bi, replace.bi )
.replace( regex.m, replace.m );
comment.replaceChild( span, node );
}
}
}
};
Array.prototype.forEach.call( document.querySelectorAll('.postMessage'), markup );
// support for c4 updated threads
window.addEventListener( 'beforeupdate', function (e) {
Array.prototype.forEach.call( e.detail.fragment.querySelectorAll('.postMessage'), markup );
});
// ==UserScript==
// @name c4 -- updater
// @author queue
// @namespace https://github.com/queue-/c4
// @description Thread auto-updater for 4chan
//
// @version 0.1.1
//
// @match http://boards.4chan.org/*
// @match https://boards.4chan.org/*
// @exclude http://boards.4chan.org/f/*
// @exclude https://boards.4chan.org/f/*
//
// ==/UserScript==
"use strict";
var delay = 10000; // ms
// on thread pages
if ( /res/.test( document.location.pathname ) ) {
var thread, last_update;
thread = document.querySelector( '.thread' );
last_update = Date.now();
setTimeout( function update () {
var xhr = new XMLHttpRequest;
xhr.open( 'GET', document.URL );
xhr.addEventListener( 'load', function () {
var html, fragment, last_id, _;
// for some reason, xhr treats 404 as success...
if ( this.status === 404 ) { return; }
// skip parsing if there isn't an update
// can't directly check for a 304 (as 4chanX does), since the browser
// will send 200 OK if the XHR hits the local browser cache, for some
// unknown reason. (4cX circumvents this by appending a cache-busting
// parameter to the url, which forces a real server response
// this version is slightly nicer to 4chan's servers
if ( new Date( this.getResponseHeader( 'Last-Modified' ) ).getTime() > last_update ) {
// console.log( 'update detected, parsing' );
last_update = Date.now();
// parse response as HTML
// as xhr.responseType = 'document' isn't supported by Opera
html = document.createElement('div');
html.innerHTML = this.response;
fragment = document.createDocumentFragment();
last_id = parseInt( thread.lastElementChild.id.substring(2), 10 );
for ( var i = (_ = html.querySelectorAll( '.replyContainer' )).length, reply; reply = _[--i]; ) {
// stop once the replies aren't new
if ( parseInt( reply.id.substring(2), 10 ) <= last_id ) { break; }
reply.classList.add('new');
fragment.appendChild( reply );
}
if ( fragment.childNodes.length > 0 ) {
// fire event with the new_posts to be modified, before they get
// appended to the the actual document, so any modifications can
// occur without reflow or repaint
window.dispatchEvent( new CustomEvent('beforeupdate', { detail: { fragment: fragment } } ) );
// add appending the new posts to the callback queue ( *after*
// the beforeupdate event
setTimeout( function () {
thread.appendChild( fragment );
}, 0 );
// alert plugins that don't do DOM manipulation that an update happened
window.dispatchEvent( new CustomEvent('update') );
// re-initialize vanilla 4chan features to extend them to the
// dynamically updated posts, using unsafe form of setTimeout
// to call methods in the regular page JS scope (not greasemonkey)
setTimeout( 'config.gs("4chan_enable_backlinking") && extra.enableBacklinking(); config.gs("4chan_enable_link_preview") && extra.enableLinkHoverPreview();', 0 );
// because built-in quick reply icons are added with a really stupid
// innerHTML modification which creates duplicate icons on older
// posts, I'll do the same manually.
if ( localStorage['4chan_enable_quickreply'] === 'true' ) {
Array.prototype.forEach.call( fragment.querySelectorAll('.postNum.desktop'), function (post) {
var id, img, a;
// exploit <span.postNum.desktop>'s structure to get the post
// number from <a[title="Quote this post"]>
id = post.children[1].textContent;
a = document.createElement('a');
a.href = 'javascript:void 0';
a.className = 'extButton';
img = document.createElement('img');
// get the right image from an existing button
img.src = document.querySelector('a.extButton img').src;
img.title = "Quick Reply";
// using onclick attribute to once again gain entrance to real scope
img.setAttribute( 'onclick', 'openQrWindow("qr_'+thread.id.substring(1)+'_'+id+'")' );
a.appendChild( img );
post.appendChild( a );
});
}
// clean up 'new' class after event listeners have run
setTimeout( function () {
// can't use getElementsByClassName since it's a live collection
for( var i = 0, _ = document.querySelectorAll( '.new' ), reply; reply = _[i++]; ) {
reply.classList.remove( 'new' );
}
}, 0 );
}
}
setTimeout( update, delay );
});
xhr.send();
}, delay );
}
// TODO visibility/scroll detection, like html5chan
// TODO hipster yellow fade on new posts
// TODO status/countdown
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment