/**
* @file Duplicate username list
* @author Andrew Sayers
* @summary Select a list of accounts you believe to be duplicates for the same user
*/
/**
* @summary Select a list of accounts you believe to be duplicates for the same user
* @constructor
* @extends Widget
* @param {Object} args duplicate account arguments
* @example
*
* var dupe_list = new DuplicateAccountList({
*
* required: [{ username: ..., user_id: ..., email: ..., notes: ... }, ...], // Accounts that must be included in the list
* default : [{ username: ..., user_id: ..., email: ..., notes: ... }, ...], // Accounts that will be suggested by default
*
* show_heatmap: false, // if true, the "required" and "default" lists must include info and mod_info
*
* // other data needed for the list to function:
* bb : bb, // BulletinBoard object
* v : v, // Variables object
* callback : function(users) { ... }, // called with the current data
* container : dupes_appended_to_this,
* loading_html: 'loading...',
*
* });
*/
function DuplicateAccountList( args ) {
// we need to handle the callback specially:
var callback = args.callback;
delete args.callback;
Widget.call( this, args, 'duplicate_account_list' );
if ( !DuplicateAccountList.css_added ) {
DuplicateAccountList.css_added = true;
$("head").append(
"<style type='text/css'>" +
args.v.parse( BabelExt.resources.get('res/widgets/duplicate_account_list.css'), args.bb.css_keys() ) +
"</style>"
);
}
var dupes = this;
this.bb = args.bb;
this.v = args.v;
this.user_info = {};
this.show_heatmap = args.show_heatmap;
this.loading_html = args.loading_html;
this.li_template = this.element.find('li').detach();
if ( args.required && args.required.length ) {
this.account_highlighter = new AccountHighlighter({ v: args.v, source_username: args.required[0].username.replace( /\s/g, '\u00A0' ), source_address: args.required[0].email });
} else {
this.account_highlighter = new AccountHighlighter({ v: args.v, source_username: '', source_address: '@' });
}
if ( this.show_heatmap ) this.element.addClass('heatmap');
// make sure there's a <datalist> for us:
var list_id = this.element.find('.user').attr('list');
if ( $('#'+list_id).length == 0 ) {
$('<datalist id="' + list_id + '"><option></option></datalist>').appendTo(document.body);
}
this.val(args);
var primary_user_id = args.required ? args.required[0].user_id : 0, old_list_length = -1;
var user_table = this.element.children('table');
form_keys( args.bb, user_table, function(keys, list) {
var callback_needed = false, callback_list = [];
if ( !user_table.find('[name="primary-username"]:checked').length )
user_table.find('[name="primary-username"]').first().prop( 'checked', true );
var new_primary_user_id = user_table.find('[name="primary-username"]:checked').closest('tr').data('dupe-account-current');
if ( primary_user_id != new_primary_user_id ) {
primary_user_id = new_primary_user_id;
callback_needed = true;
}
list.forEach(function(item) {
var row = $(item.element).closest('tr');
if ( item.value ) {
row.removeClass('dupe-unknown');
callback_needed |= dupes._set_row( row, $.trim(item.text), item.value );
callback_list.push( row.data('dupe-account-val') );
callback_list[callback_list.length-1].is_primary = callback_list[callback_list.length-1].user_id == primary_user_id;
} else {
row. addClass('dupe-unknown');
}
});
if ( old_list_length != list.length ) {
old_list_length = list.length;
callback_needed = true;
}
if ( callback_needed && callback ) {
callback(
$(user_table).find('.required').map(function() {
var ret = $(this).data('dupe-account-val');
ret.is_primary = ret.user_id == primary_user_id;
return ret;
}).get().concat( callback_list )
);
}
return false; // prevent form submission
});
// INITIALISE THE POPUP
var popup = this.element.find('.timeline-popup');
function hide_popup() {
if ( !$(this).closest('.timeline-popup').length ) {
popup.hide();
$(document.body).off( 'click', hide_popup );
}
};
popup.find('.close').click(function(event) {
popup.hide();
$(document.body).off( 'click', hide_popup );
event.preventDefault();
});
popup.on( 'click', 'a', function(event) {
this.href.replace( /#event-[0-9]*/, function(event) {
$(event).data('collapse').val(false);
});
});
this.element.find('.timeline').on( 'click', 'a.mod-team-events', function(event) {
var $this = $(this);
popup.find('.header span:first-child').text($this.attr('title'));
popup.find('.body').children().detach();
popup.find('.body').append($this.data('popup-body'));
var position = $this.position();
position.top += $this.outerHeight();
popup.css(position).show();
setTimeout(function() { $(document.body).click(hide_popup) }, 0);
event.preventDefault();
});
}
DuplicateAccountList.prototype = Object.create(Widget, {
bb : { writable: true, configurable: false },
v : { writable: true, configurable: false },
user_info : { writable: true, configurable: false },
account_highlighter: { writable: true, configurable: false },
loading_html : { writable: true, configurable: false },
li_template : { writable: true, configurable: false },
});
DuplicateAccountList.prototype.constructor = DuplicateAccountList;
// Set the elements in a <tr>, return true if the element has changed value:
DuplicateAccountList.prototype._set_row = function( row, username, user_id, first_user ) {
row.find('.member').attr( 'href', this.bb.url_for.user_show({ user_id: user_id }) );
if ( row.data('dupe-account-current') == user_id ) return false;
if ( this.user_info.hasOwnProperty(user_id) ) {
var user = this.user_info[user_id];
if ( this.show_heatmap ) {
row.find('.heatmap').html( this.loading_html );
var dupes = this;
$.when( user.post_promise, user.note_promise ).then(function(posts, notes) { dupes._heatmap( row.find('.heatmap'), user.info, posts, notes ) });
}
row
.data('dupe-account-current', user_id)
.removeClass( 'dupe-loading' );
row.find('.email-user a,.at a,.email-domain a' ).attr( 'href', 'mailto:' + user.email );
row.find( '.notes' ).html( user.notes ).find('time').timeago();
if ( first_user ) {
var email = user.email.split('@');
row.find('.member' ) .text( username.replace( /\s/g, '\u00A0' ) );
row.find('.email-user a' ) .text( email[0] );
row.find('.email-domain a').first().text( email[1] );
} else {
this.account_highlighter.highlight_to_element(
username.replace( /\s/g, '\u00A0' ),
user.email,
row.find('.member'),
row.find('.email-user a' ),
row.find('.email-domain a').first()
);
}
} else {
var dupes = this;
row.addClass( 'dupe-loading' );
var post_promise = this.show_heatmap && this.bb.user_posts(user_id, true);
var note_promise = this.show_heatmap && this.bb.user_notes(user_id);
$.when(
this.bb.user_info(user_id),
this.bb.user_moderation_info(user_id)
).then(function( user_info, user_moderation_info ) {
dupes.user_info[user_id] = {
email: user_moderation_info.email,
notes: user_info.infraction_summary + ' ' + user_moderation_info.summary,
info: user_info,
moderation_info: user_moderation_info,
post_promise: post_promise,
note_promise: note_promise,
};
dupes._set_row( row, username, user_id );
});
}
row.data( 'dupe-account-val', { username: username, user_id: user_id } );
return true;
}
/**
* @summary Get/set widget's values
* @param {Object} value new value
* @return {Object} (new) value
* @example
*
* ns.val({
* required: [{ username: ..., user_id: ... }, ...], // Accounts that must be included in the list
* default : [{ username: ..., user_id: ... }, ...], // Accounts that will be suggested by default
* });
*
* @description if "show_heatmap" is true, the "required" and "default" lists must include info and moderation_info
*
*/
DuplicateAccountList.prototype.val = function( value ) {
var dupes = this;
if ( value ) {
var multi = this.element.find('.multi:last-child');
var primary_defined = false;
var bb = this.show_heatmap ? this.bb : { user_posts: function() {} };
function update_row(user) {
var row = multi.clone()
.insertBefore(multi)
.data( 'user', user );
row.find('.user').data( 'value', user.user_id );
dupes.user_info[user.user_id] = {
email: user.email,
notes: user.notes,
info: user.info,
moderation_info: user.moderation_info,
post_promise: bb.user_posts(user.user_id, true),
note_promise: bb.user_notes(user.user_id),
};
dupes._set_row(row, user.username, user.user_id, value.required && value.required[0] == user);
if ( user.is_banned && !primary_defined ) {
row.find('[name="primary-username"]').prop( 'checked', true );
primary_defined = true;
}
return row;
}
this.element.find('tbody > tr:not(:last-child)').remove();
( value.required || [] ).forEach(function(user) { update_row(user).addClass('required').find('.user').replaceWith($('<span>').text(user.username.replace( /\s/g, '\u00A0' ))) });
( value.default || [] ).forEach(function(user) { update_row(user) .find('.user') .val (user.username.replace( /\s/g, '\u00A0' )) });
if ( !primary_defined ) this.element.find('[name="primary-username"]').first().prop( 'checked', true );
}
return this.element.find('tr').map(function() { return $(this).data('dupe-account-val') }).get();
}
/**
* Add a user to the list
*/
DuplicateAccountList.prototype._heatmap = function(container, info, post_list, notes) {
var day_names = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ];
var heatmap = [];
// heatmap consists only of posts:
var max = 1;
post_list.forEach(function(post) {
var date = post.date;
var day = heatmap[date.getUTCDay()];
if ( !day ) day = heatmap[date.getUTCDay()] = [];
if ( day[date.getUTCHours()] ) {
max = Math.max( max, ++day[date.getUTCHours()] );
} else {
day[date.getUTCHours()] = 1;
}
});
// build the heatmap from the posts:
var map = '';
for ( var d=0; d!=7; ++d ) {
var day = heatmap[d];
var day_name = day_names[d];
if ( day ) {
map += '<div>'
for ( var h=0; h!=24; ++h ) {
var time_name = h + ':00-' + (h+1) + ':00';
if ( day[h] ) {
var intensity = 255 - Math.ceil( 255 * day[h] / max );
map += '<div class="posts" style="background: <intensity=' + intensity + '>" title="' + day[h] + ' post(s) at ' + time_name + ' on a ' + day_name + '"></div>';
} else {
map += '<div class="no-posts" title="has never posted at ' + time_name + ' on a ' + day_name + '"></div>'
}
}
map += '</div>';
} else {
map += '<div title="(has never posted on a ' + day_name + ')"></div>'
}
}
// build the event list for the user
var username = info.username, posts = {}, infractions = [];
var events = [
{ type: 'join' , username: username, user_id: info.user_id, date: info. join_date },
{ type: 'activity', username: username, user_id: info.user_id, date: info.activity_date }
];
if ( post_list )
post_list.forEach(function(post) {
posts[post.id] = post;
events.push({ type: 'post', username: username, target: post, date: post.date } );
});
if ( info.infractions )
info.infractions.forEach(function(infraction) {
events.push({ type: 'infraction', username: username, target: infraction, date: infraction.start_date });
});
if ( notes )
notes.forEach(function(note) {
events.push({ type: 'note', username: username, target: note, date: note.date_object });
});
var widget = this;
$('<a class="user-heatmap enabled" href="#show-hide-user"></a>')
.appendTo(container.empty())
.data( 'events', events )
.click(function(event) {
var $this = $(this);
if ( $this.hasClass('enabled') ) {
$this.removeClass('enabled').html(map.replace( /<intensity=([0-9]*)>/g, 'rgb($1,$1,$1)' ));
} else {
$this. addClass('enabled').html(map.replace( /<intensity=([0-9]*)>/g, 'rgb($1,$1,255)' ));
}
widget._heatmap_update();
event.preventDefault();
})
.click();
}
DuplicateAccountList.prototype._heatmap_update = function() {
var v = this.v, bb = this.bb;
var events = [].concat.apply( [], this.element.find('.heatmap a.enabled').map(function() { return $(this).data('events') }).get() );
events.sort(function(a,b) { return b.date < a.date });
var timeline = [];
function event_info(event) {
switch ( event.type ) {
case 'infraction':
var content = '<span class="infraction">infraction</span>: ' + event.target.points + ' point(s) for ' + event.target.reason + '</div>';;
if ( event.target.notes ) {
console.log( 'infraction with notes', event.target.notes);
};
return {
header : content,
body : content,
summary : event.target.reason + ' (' + event.target.points + ')',
username: event.target.username,
user_id : event.target.user_id
};
case 'post':
return {
header : event.target.thread_title,
body : event.target.message,
summary : bb.post_summary(event.target),
username: event.target.username,
user_id : event.target.user_id
};
case 'note':
return {
header : '<span class="note">Note</span>: ' + event.target.title,
summary : bb.post_summary(event.target),
body : event.target.message,
username: event.target.username,
user_id : event.target.user_id
};
case 'join':
return {
header : '<span class="join">joined</span>',
body : 'First recorded activity for ' + event.username,
summary : 'joined',
username: event.username,
user_id : event.user_id
};
case 'activity':
return {
header : '<span class="active">last activity</span>',
body : 'Most recent recorded activity for ' + event.username,
summary : 'Most recent activity',
username: event.username,
user_id : event.user_id
};
default:
return;
}
}
function display_hour(hour_events, year, month, day, hour) {
// Generate HTML to display all events that occurred this hour
var event_types = {}, popup_body = $('<ul>');
hour_events.forEach(function(event) {
if ( !event_types[event.type] ) event_types[event.type] = [];
event_types[event.type].push(event);
if ( event.type == 'nearby' ) {
var event = events[event.event], info = event_info(event);
if ( info )
$('<li class="' + event.type + '"><span></span><a></a></li>').appendTo(popup_body)
.children('a')
.attr( 'href', location.toString().replace( /#.*/, '' ) + '#event-' + event.index )
.text( event.username + ': ' + info.summary );
}
});
// if there is an infracted post at this time, act as if there is an infraction too:
if ( !event_types.infraction && ( event_types.post || [] ).filter(function(post) { return post.infraction }).length )
event_types.infraction = [];
var ret = $('<a class="mod-team-events" href="#events-' + event_types.nearby.map(function(event) { return event.event }).join() + '"></a>');
ret
.attr( 'title', h+':00-'+(h+1)+':00, ' + (d+1) + ' ' + month_names[m] + ' ' + (2000+y) )
.data( 'popup-body', popup_body )
;
if ( event_types.infraction ) ret.addClass('infraction');
else if ( event_types.post ) ret.addClass('post');
else if ( event_types.note ) ret.addClass('note');
else if ( event_types.join ) ret.addClass('join');
else if ( event_types.activity ) ret.addClass('active');
return ret;
}
events.forEach(function(event, index) {
var date = event.date;
event.index = index;
var min_offset = 0, max_offset=1;
for ( var day_offset=min_offset; day_offset!=max_offset; ++day_offset )
for ( var hour_offset=min_offset; hour_offset!=max_offset; ++hour_offset ) {
var odate = new Date( date );
odate.setDate ( odate.getDate () + day_offset );
odate.setHours( odate.getHours() + hour_offset );
var node = timeline;
[ odate.getUTCFullYear() - 2000, odate.getUTCMonth(), odate.getUTCDate()-1, odate.getUTCHours() ].forEach(function(index) {
if ( !node[index] ) node[index] = [];
node = node[index];
});
node.push({ type: 'nearby', event: index });
if ( !day_offset && !hour_offset ) node.push(event);
}
});
var month_names = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
var cell_width = 4;
var calendar = (
this.element.find('.timeline')
.data( 'events', events )
.html( '<div class="timeline-container"><div class="user-timeline"></div>' )
.find('.user-timeline')
);
var width = cell_width, started = false, now = new Date();
for ( var y=0; y!=timeline.length; ++y ) {
var year = timeline[y];
if ( !year && !started ) continue;
started = true;
var year_element = $('<div></div>').appendTo(calendar);
for ( var m=0; m!=12; ++m ) {
var date = new Date(2000+y, m+1, 0);
if ( date > now ) break;
var day_count = date.getDate();
var month_element = $('<div><span>' + month_names[m] + " '" + y + '</span><div class="month-marker"></div></div>').appendTo(year_element);
if ( year ) {
var month = year[m];
if ( month )
for ( var d=0; d!=day_count; ++d ) {
var day = month[d];
var day_element = $('<div></div>').appendTo(month_element);
if ( day )
for ( var h=0; h!=24; ++h ) {
var hour = day[h];
if ( hour )
day_element.append(display_hour(hour, y, m, d, h));
else
day_element.append('<div></div>');
}
}
}
}
}
var li_template = this.li_template, lis = [];
events.forEach(function(event) {
var info = event_info(event);
if ( !info ) return;
var li = li_template.clone().attr( 'id', 'event-' + event.index );
li.find('.postdate').addClass('mod-friend-event-' + event.type);
li.data( 'collapse', new Collapse({
v : v,
bb: bb,
insertBefore: li.find('.postdate'),
collapsed: true,
callback: function() {
li.toggleClass('mod-friend-collapsed')
}
}));
li.find('time').attr( 'datetime', event.date ).text( event.date.toLocaleFormat().replace( /:00 /, ' ' ) );
li.find('.username,.postuseravatar').attr( 'href', bb.url_for.user_show({ user_id: info.user_id }) );
li.find('.username strong').text(info.username);
li.find('.postuseravatar img').attr( 'src', bb.url_for.user_avatar({ user_id: info.user_id }) );
li.find('.summary').text( event.username + ': ' + info.summary );
li.find('h2').html(info.header);
li.find('blockquote').html(info.body);
lis.unshift(li);
});
this.element.find('#posts').empty().append(lis);
}