Chaturbate Easy Tipping

Adds a new tipping popup and modifies the existing one

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Chaturbate Easy Tipping
// @namespace    madTipper
// @version      0.2
// @author       omgmikey
// @match        https://chaturbate.com/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// @run-at       document-idle
// @description Adds a new tipping popup and modifies the existing one
// ==/UserScript==
/* globals jQuery, $, initialRoomDossier */
/* eslint-disable dot-notation, no-multi-spaces */

var ID_PREFIX    = '#madTipper'
var CLASS_PREFIX = '.madTipper'
var CLASS_INPUT  = CLASS_PREFIX + '_input';

var HTML_IDS = {
    'BUTTON': 'button',
    'BUTTON_BG': 'button_bg',
    'POPUP': 'popup',
    'AMOUNT': 'amount',
    'COUNT': 'count',
    'INTERVAL': 'interval',
    'VARIANCE_LOWER': 'variance_lower',
    'VARIANCE_UPPER': 'variance_upper',

    'START': 'start',
    'STOP': 'stop',
    'TOTAL': 'total',
    'ETA': 'eta'
}

for (var key in HTML_IDS) {
    HTML_IDS[key] = ID_PREFIX + '_' + HTML_IDS[key];
}

var defaultSendTipButton = null;
var buttonAttach = null;
var popupAttach = null;

var tipsLeft = 0;
var tipFunctionTimeout = null;
var juration = loadJuration();

waitForKeyElements("#sendTipButton", initialize, true);

function initialize() {
    defaultSendTipButton = $('#sendTipButton');
    buttonAttach = defaultSendTipButton.parent();
    popupAttach = $('#SplitModeTipCallout').parent();

    createTipperButton();
    createTipperPopup();
    injectCSS();
    improveDefaultTipPopup();

    loadPreviousSettings();
    initializeButtonCallbacks();
    updateTipperButton();
};

function createTipperButton() {

    buttonAttach.append('<div id="madTipper_button_bg" class="sendTipButton"><a href="#" id="madTipper_button"></a></div>');
}

function updateTipperButton() {
    if (tipsLeft == 0) {
        $(HTML_IDS['BUTTON_BG']).html('MAD TIPPER').css({'width': '93px', 'max-width': '93px'});
    }
    else {
        $(HTML_IDS['BUTTON_BG']).html('MAD TIPPER (' + tipsLeft + ')').css({'width': '120px', 'max-width': '120px'});
    }
}

function createTipperPopup() {

    popupAttach.append(
        '<div class="overlay_popup" id="madTipper_popup">' +
            '<table width="100%" border="0" cellspacing="0" cellpadding="0">' +
                '<tbody>' +
                    '<tr>' +
                        '<td class="formborder">' +
                            '<div class="title">Mad Tipper</div>' +
                            '<div class="body">' +
                                '<form>' +
                                    '<label>Amount per tip:</label><br >' +
                                    '<input type="text" id="madTipper_amount" class="madTipper_input">' +
                                    '<br />' +

                                    '<label>Number of tips:</label><br >' +
                                    '<input type="text" id="madTipper_count" class="madTipper_input">' +
                                    '<br /><hr />' +

                                    '<label>Interval:</label><br >' +
                                    '<input type="text" id="madTipper_interval" class="madTipper_input">' +
                                    '<br />' +

                                    '<label>Interval variance lower (optional):</label><br >' +
                                    '<input type="text" id="madTipper_variance_lower" class="madTipper_input">' +
                                    '<br />' +

                                    '<label>Interval variance upper (optional):</label><br >' +
                                    '<input type="text" id="madTipper_variance_upper" class="madTipper_input">' +
                                    '<br /><hr />' +

                                    'Total tip:  ' + '<a id="madTipper_total"></a>' +
                                    '<br />' +
                                    'Estimated duration:  ' + '<a id="madTipper_eta"></a>' +
                                '</form>' +
                                '<hr />' +
                                '<button id="madTipper_start">Start</button>' +
                                '<button id="madTipper_stop" disabled="disabled">Stop</button>' +
                            '</div>' +
                        '</td>' +
                    '</tr>' +
                '</tbody>' +
            '</table>' +
        '</div>'
    );
}

function injectCSS() {

    $(HTML_IDS['BUTTON_BG']).attr('style', defaultSendTipButton.attr('style'));

    $(HTML_IDS['START']).attr('style', defaultSendTipButton.attr('style'));
    $(HTML_IDS['STOP']).attr('style', defaultSendTipButton.attr('style'));

    $(HTML_IDS['START']).addClass("sendTipButton");

    $(HTML_IDS['START']).prop('disabled', false);
    $(HTML_IDS['STOP']).prop('disabled', true);

    $(HTML_IDS['POPUP']).attr('style', $('#SplitModeTipCallout').attr('style'));
    $(HTML_IDS['POPUP']).css('left', '200px');

    $(CLASS_INPUT).css({
        'width': 'auto',
        'margin-bottom': '10px'
    });

    $(HTML_IDS['POPUP']).draggable();

    $(HTML_IDS['POPUP'] + ' .formborder').css({
        'height': '420px'
    });
}

function improveDefaultTipPopup() {

    $('#SplitModeTipCallout').draggable();

    $('#SplitModeTipCallout .tipMessageInput').parent()
    .append('<input type="checkbox" id="tip_keepopen"></input>')
    .append('<label for="tip_keepopen">Keep this window open after tipping</label>');

    var tipPopup = $('#SplitModeTipCallout');
    var keepOpenCheckbox = $('#tip_keepopen');
    var popupIsForcedOpen = false;

    var tipPopupForm = window.$('#SplitModeTipCallout form');
    tipPopupForm.submit(onFormSubmit);

    $('body').click(function(ev) {

        if ($('#SplitModeTipCallout .sendTip button[type="submit"]').is(ev.target)) {
            popupIsForcedOpen = false;
            return;
        }

        if (!popupIsForcedOpen || tipPopup.has(ev.target).length) {
            return;
        }

        if (tipPopup.is(':visible')) {
            tipPopup.hide();
        }

        popupIsForcedOpen = false;
    });

    function onFormSubmit() {

        if (!keepOpenCheckbox.is(':checked')) {
            return;
        }

        if (!tipPopup.is(':visible')) {
            tipPopup.show();
            popupIsForcedOpen = true;

            keepPopupOpen();
        }
    }

    function keepPopupOpen() {
        if (!tipPopup.is(':visible')) {
            defaultSendTipButton.trigger('click');
            tipPopup.show();
            return;
        }

        setTimeout(() => {
            keepPopupOpen();
        }, 100);
    }
}

function startTipping() {

    var err = verifyTipperFields();

    if (err) {
        alert(err);
        stopTipping();
        return;
    }

    saveCurrentSettings();

    $(HTML_IDS['START']).prop('disabled', true);
    $(HTML_IDS['STOP']).prop('disabled', false);
    $(CLASS_INPUT).prop('disabled', true);

    tipsLeft = getTipCount();

    /* we really want to send the first one immediately */
    sendTip();

    if (tipsLeft > 0) {
        chainQueueTips();
    }
}

function verifyTipperFields() {

    function isInt(value) {
        var regex = /^[0-9]+$/;
        return regex.test(String(value));
    }

    function isDuration(value) {
        try {
            juration.parse(value);
            return true;
        }
        catch(ex) {
            return false;
        }
    }

    function isDurationOrEmpty(value) {
        return value === '' || isDuration(value);
    }

    if (!isInt(getTipAmountRaw()) || getTipAmount() <= 0) {
        return 'Tip amount field should be a positive integer.';
    }

    if (!isInt(getTipCountRaw()) || getTipCount() <= 0) {
        return 'Tip count field should be a positive integer.';
    }

    if (!isDuration(getTipInterval())) {
        return 'Tip interval should contain a duration. E.g.: "2.5s", "1", "2min"';
    }

    if (!isDurationOrEmpty(getVarianceLowerRaw()) || !isDurationOrEmpty(getVarianceUpperRaw())) {
        return 'Variance fields should contain durations, or be left blank. E.g.: "", "2.5s"';
    }
}

function getSleepInterval() {

    var interval = juration.parse(getTipInterval());
    var lower_bound = interval - getVarianceLower();
    var upper_bound = interval + getVarianceUpper();

    return getRandomNumber(lower_bound, upper_bound) * 1000;
}

function getRandomNumber(min, max) {

    return Math.random() * (max - min) + min;
}

function chainQueueTips() {

    var sleepTime = getSleepInterval();

    tipFunctionTimeout = setTimeout(function() {
        sendTip(chainQueueTips);
    }, sleepTime);
}

function sendTip(queueNextTipFn) {

    var roomInfo = JSON.parse(initialRoomDossier);
    var queryUrl = '/tipping/send_tip/' + roomInfo.broadcaster_username + '/';

    var params = {
        'csrfmiddlewaretoken': $.cookie('csrftoken'),
        'tip_amount': getTipAmount(),
        'tip_room_type': roomInfo.room_status,
        'tip_type': 'public',
        'from_username': roomInfo.viewer_username,
        'source': 'theater',
        'video_mode': 'split',
        'message': '',
    };

    var formData = new FormData();

    for (const [k, v] of Object.entries(params)) {
        formData.append(k , v);
    }

    $.ajax({
        url: queryUrl,
        data: formData,
        method: 'POST',
        type: 'POST',
        cache: false,
        contentType: false,
        processData: false,
        success: function(response) {
            if (response.error) {
                alert(response.error);
                stopTipping();
            }
        }
    });

    updateTipsLeft();

    if (tipsLeft === 0) {
        stopTipping();
    }
    else if (queueNextTipFn) {
        queueNextTipFn();
    }
}

function updateTipsLeft() {

    tipsLeft--;
    updateTipperButton();
}

function stopTipping() {

    clearTimeout(tipFunctionTimeout);
    tipFunctionTimeout = null;

    tipsLeft = 0;
    updateTipperButton();

    $(HTML_IDS['STOP']).prop('disabled', true);
    $(HTML_IDS['START']).prop('disabled', false);
    $(CLASS_INPUT).prop('disabled', false);
}

function initializeButtonCallbacks() {

    var popup = $(HTML_IDS['POPUP']);
    var button = $(HTML_IDS['BUTTON_BG']);

    button.click(function(ev) {
        if (popup.is(':visible')) {
            popup.hide();
        }
        else {
            $(HTML_IDS['POPUP']).attr('style', $('#SplitModeTipCallout').attr('style'));
            popup.show();
        }
    });

    popup.click(function(ev) {
        ev.stopPropagation();
    });

    $(HTML_IDS['START']).click(function() {
        startTipping();
        $(HTML_IDS['POPUP']).hide();
    });

    $(HTML_IDS['STOP']).click(function() {
        stopTipping();
    });

    $('body').click(function(ev) {
        if (ev.target.id != button.prop('id')) {
            $(HTML_IDS['POPUP']).hide();
        }
    });

    $(CLASS_INPUT).change(function() {
        calculateAndSetTotalTip();
        calculateAndSetETA();
    });
}

function calculateAndSetTotalTip() {

    var value = $(HTML_IDS['AMOUNT']).val() * $(HTML_IDS['COUNT']).val();
    $(HTML_IDS['TOTAL']).html(value + ' tokens');
}

function calculateAndSetETA() {
    var interval = juration.parse($(HTML_IDS['INTERVAL']).val());

    /* we're not counting the first tip */
    var count = getTipCount() - 1;

    var variance_lower = getVarianceLower();
    var variance_upper = getVarianceUpper();

    var eta = (interval + variance_upper - variance_lower) * count;
    $(HTML_IDS['ETA']).html(juration.stringify(eta, {'format': 'long', 'units': 2}));
}

function getTipAmount() {

    return parseInt(getTipAmountRaw());
}

function getTipAmountRaw() {

    return $(HTML_IDS['AMOUNT']).val();
}

function getTipInterval() {

    return $(HTML_IDS['INTERVAL']).val();
}

function getTipCount() {

    return parseInt(getTipCountRaw());
}

function getTipCountRaw() {

    return $(HTML_IDS['COUNT']).val();
}

function getVarianceLower() {

    return parseVariance(getVarianceLowerRaw());
}

function getVarianceLowerRaw() {

    return $(HTML_IDS['VARIANCE_LOWER']).val();
}


function getVarianceUpper() {

    return parseVariance(getVarianceUpperRaw());
}

function getVarianceUpperRaw() {

    return $(HTML_IDS['VARIANCE_UPPER']).val();
}

function parseVariance(variance) {

    if (variance == '0') {
        variance = 0;
    }

    variance = variance || 0;

    if (variance != 0) {
        variance = juration.parse(variance);
    }

    return variance;
}

function saveCurrentSettings() {

    GM_setValue('amount', getTipAmount());
    GM_setValue('interval', getTipInterval());
    GM_setValue('count', getTipCount());
    GM_setValue('variance_lower', getVarianceLower());
    GM_setValue('variance_upper', getVarianceUpper());
}

function loadPreviousSettings() {

    var amount = GM_getValue('amount', 1);
    $(HTML_IDS['AMOUNT']).val(amount);

    var count = GM_getValue('count', 10);
    $(HTML_IDS['COUNT']).val(count);

    var interval = GM_getValue('interval', '1s');
    $(HTML_IDS['INTERVAL']).val(interval);

    var variance_lower = GM_getValue('variance_lower', '');
    $(HTML_IDS['VARIANCE_LOWER']).val(variance_lower);

    var variance_upper = GM_getValue('variance_upper', '');
    $(HTML_IDS['VARIANCE_UPPER']).val(variance_upper);

    calculateAndSetTotalTip();
    calculateAndSetETA();
}


// Script ends here
// Libs included because they're not on a popular cdn


/*
 * juration - a natural language duration parser
 * https://github.com/domchristie/juration
 *
 * Copyright 2011, Dom Christie
 * Licenced under the MIT licence
 *
 */

function loadJuration() {

  var UNITS = {
    seconds: {
      patterns: ['second', 'sec', 's'],
      value: 1,
      formats: {
        'chrono': '',
        'micro':  's',
        'short':  'sec',
        'long':   'second'
      }
    },
    minutes: {
      patterns: ['minute', 'min', 'm(?!s)'],
      value: 60,
      formats: {
        'chrono': ':',
        'micro':  'm',
        'short':  'min',
        'long':   'minute'
      }
    },
    hours: {
      patterns: ['hour', 'hr', 'h'],
      value: 3600,
      formats: {
        'chrono': ':',
        'micro':  'h',
        'short':  'hr',
        'long':   'hour'
      }
    },
    days: {
      patterns: ['day', 'dy', 'd'],
      value: 86400,
      formats: {
        'chrono': ':',
        'micro':  'd',
        'short':  'day',
        'long':   'day'
      }
    },
    weeks: {
      patterns: ['week', 'wk', 'w'],
      value: 604800,
      formats: {
        'chrono': ':',
        'micro':  'w',
        'short':  'wk',
        'long':   'week'
      }
    },
    months: {
      patterns: ['month', 'mon', 'mo', 'mth'],
      value: 2628000,
      formats: {
        'chrono': ':',
        'micro':  'm',
        'short':  'mth',
        'long':   'month'
      }
    },
    years: {
      patterns: ['year', 'yr', 'y'],
      value: 31536000,
      formats: {
        'chrono': ':',
        'micro':  'y',
        'short':  'yr',
        'long':   'year'
      }
    }
  };

  var stringify = function(seconds, options) {

    if(!_isNumeric(seconds)) {
      throw "juration.stringify(): Unable to stringify a non-numeric value";
    }

    if((typeof options === 'object' && options.format !== undefined) && (options.format !== 'micro' && options.format !== 'short' && options.format !== 'long' && options.format !== 'chrono')) {
      throw "juration.stringify(): format cannot be '" + options.format + "', and must be either 'micro', 'short', or 'long'";
    }

    var defaults = {
      format: 'short',
      units: undefined
    };

    var opts = _extend(defaults, options);

    var units = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'], values = [];
    var remaining = seconds;
    var activeUnits = 0;
    for(var i = 0, len = units.length;
        i < len && (opts.units == undefined || activeUnits < opts.units);
        i++) {
      var unit = UNITS[units[i]];
      values[i] = Math.floor(remaining / unit.value);
      if (values[i] > 0 || activeUnits > 0)
        activeUnits++;

      if(opts.format === 'micro' || opts.format === 'chrono') {
        values[i] += unit.formats[opts.format];
      }
      else {
        values[i] += ' ' + _pluralize(values[i], unit.formats[opts.format]);
      }
      remaining = remaining % unit.value;
    }
    var output = '';
    for(i = 0, len = values.length; i < len; i++) {
      if(values[i].charAt(0) !== "0" && opts.format != 'chrono') {
        output += values[i] + ' ';
      }
      else if (opts.format == 'chrono') {
        output += _padLeft(values[i]+'', '0', i==values.length-1 ? 2 : 3);
      }
    }
    return output.replace(/\s+$/, '').replace(/^(00:)+/g, '').replace(/^0/, '');
  };

  var parse = function(string) {

    // returns calculated values separated by spaces
    for(var unit in UNITS) {
      for(var i = 0, mLen = UNITS[unit].patterns.length; i < mLen; i++) {
        var regex = new RegExp("((?:\\d+\\.\\d+)|\\d+)\\s?(" + UNITS[unit].patterns[i] + "s?(?=\\s|\\d|\\b))", 'gi');
        string = string.replace(regex, function(str, p1, p2) {
          return " " + (p1 * UNITS[unit].value).toString() + " ";
        });
      }
    }

    var sum = 0,
        numbers = string
                    .replace(/(?!\.)\W+/g, ' ')                       // replaces non-word chars (excluding '.') with whitespace
                    .replace(/^\s+|\s+$|(?:and|plus|with)\s?/g, '')   // trim L/R whitespace, replace known join words with ''
                    .split(' ');

    for(var j = 0, nLen = numbers.length; j < nLen; j++) {
      if(numbers[j] && isFinite(numbers[j])) {
         sum += parseFloat(numbers[j]);
      } else if(!numbers[j]) {
        throw "juration.parse(): Unable to parse: a falsey value";
      } else {
        // throw an exception if it's not a valid word/unit
        throw "juration.parse(): Unable to parse: " + numbers[j].replace(/^\d+/g, '');
      }
    }
    return sum;
  };

  // _padLeft('5', '0', 2); // 05
  var _padLeft = function(s, c, n) {
      if (! s || ! c || s.length >= n) {
        return s;
      }

      var max = (n - s.length)/c.length;
      for (var i = 0; i < max; i++) {
        s = c + s;
      }

      return s;
  };

  var _pluralize = function(count, singular) {
    return count == 1 ? singular : singular + "s";
  };

  var _isNumeric = function(n) {
    return !isNaN(parseFloat(n)) && isFinite(n);
  };

  var _extend = function(obj, extObj) {
    for (var i in extObj) {
      if(extObj[i] !== undefined) {
        obj[i] = extObj[i];
      }
    }
    return obj;
  };

  var juration = {
    parse: parse,
    stringify: stringify,
    humanize: stringify
  };

  return juration;
};

/*--- waitForKeyElements():  A utility function, for Greasemonkey scripts,
    that detects and handles AJAXed content.

    Usage example:

        waitForKeyElements (
            "div.comments"
            , commentCallbackFunction
        );

        //--- Page-specific function to do what we want when the node is found.
        function commentCallbackFunction (jNode) {
            jNode.text ("This comment changed by waitForKeyElements().");
        }

    IMPORTANT: This function requires your script to have loaded jQuery.
*/
function waitForKeyElements (
    selectorTxt,    /* Required: The jQuery selector string that
                        specifies the desired element(s).
                    */
    actionFunction, /* Required: The code to run when elements are
                        found. It is passed a jNode to the matched
                        element.
                    */
    bWaitOnce,      /* Optional: If false, will continue to scan for
                        new elements even after the first match is
                        found.
                    */
    iframeSelector  /* Optional: If set, identifies the iframe to
                        search.
                    */
) {
    var targetNodes, btargetsFound;

    if (typeof iframeSelector == "undefined")
        targetNodes     = $(selectorTxt);
    else
        targetNodes     = $(iframeSelector).contents ()
                                           .find (selectorTxt);

    if (targetNodes  &&  targetNodes.length > 0) {
        btargetsFound   = true;
        /*--- Found target node(s).  Go through each and act if they
            are new.
        */
        targetNodes.each ( function () {
            var jThis        = $(this);
            var alreadyFound = jThis.data ('alreadyFound')  ||  false;

            if (!alreadyFound) {
                //--- Call the payload function.
                var cancelFound     = actionFunction (jThis);
                if (cancelFound)
                    btargetsFound   = false;
                else
                    jThis.data ('alreadyFound', true);
            }
        } );
    }
    else {
        btargetsFound   = false;
    }

    //--- Get the timer-control variable for this selector.
    var controlObj      = waitForKeyElements.controlObj  ||  {};
    var controlKey      = selectorTxt.replace (/[^\w]/g, "_");
    var timeControl     = controlObj [controlKey];

    //--- Now set or clear the timer as appropriate.
    if (btargetsFound  &&  bWaitOnce  &&  timeControl) {
        //--- The only condition where we need to clear the timer.
        clearInterval (timeControl);
        delete controlObj [controlKey]
    }
    else {
        //--- Set a timer, if needed.
        if ( ! timeControl) {
            timeControl = setInterval ( function () {
                    waitForKeyElements (    selectorTxt,
                                            actionFunction,
                                            bWaitOnce,
                                            iframeSelector
                                        );
                },
                300
            );
            controlObj [controlKey] = timeControl;
        }
    }
    waitForKeyElements.controlObj   = controlObj;
}