diff --git a/script.js b/script.js index 9c1c533c..467ac61e 100644 --- a/script.js +++ b/script.js @@ -190,7 +190,7 @@ FFZ.prototype._legacy_parse_donors = function(data) { this.log("Added donor badge to " + utils.number_commas(count) + " users."); } -},{"./constants":3,"./utils":23}],2:[function(require,module,exports){ +},{"./constants":3,"./utils":25}],2:[function(require,module,exports){ var FFZ = window.FrankerFaceZ; @@ -261,7 +261,10 @@ module.exports = { SVGPATH: SVGPATH, ZREKNARF: '' + SVGPATH + '', - CHAT_BUTTON: '' + SVGPATH + '' + CHAT_BUTTON: '' + SVGPATH + '', + + GEAR: '', + HEART: '' } },{}],4:[function(require,module,exports){ var FFZ = window.FrankerFaceZ; @@ -351,11 +354,72 @@ var FFZ = window.FrankerFaceZ, }; +// --------------------- +// Settings +// --------------------- + +FFZ.settings_info.capitalize = { + type: "boolean", + value: true, + + visible: function() { return ! this.has_bttv }, + + name: "Username Capitalization", + help: "Display names in chat with proper capitalization." + }; + + +FFZ.settings_info.keywords = { + type: "button", + value: [], + + visible: function() { return ! this.has_bttv }, + + name: "Highlight Keywords", + help: "Set additional keywords that will be highlighted in chat.", + + method: function() { + var old_val = this.settings.keywords.join(", "), + new_val = prompt("Highlight Keywords\n\nPlease enter a comma-separated list of words that you would like to be highlighted in chat.", old_val); + + if ( ! new_val ) + return; + + // Split them up. + new_val = new_val.trim().split(/\W*,\W*/); + + if ( new_val.length == 1 && (new_val[0] == "" || new_val[0] == "disable") ) + new_val = []; + + this.settings.set("keywords", new_val); + } + }; + + +FFZ.settings_info.chat_rows = { + type: "boolean", + value: false, + + visible: function() { return ! this.has_bttv }, + + name: "Chat Line Backgrounds", + help: "Display alternating background colors for lines in chat.", + + on_update: function(val) { + document.querySelector(".app-main").classList.toggle("ffz-chat-background", val); + } + }; + + // --------------------- // Initialization // --------------------- FFZ.prototype.setup_line = function() { + // Alternating Background + document.querySelector('.app-main').classList.toggle('ffz-chat-background', this.settings.chat_rows); + this._last_row = {}; + this.log("Hooking the Ember Line controller."); var Line = App.__container__.resolve('controller:line'), @@ -372,8 +436,7 @@ FFZ.prototype.setup_line = function() { return tokens; - }.property("model.message", "isModeratorOrHigher", "controllers.emoticons.emoticons.[]") - // TODO: Copy the new properties from the new Twitch! + }.property("model.message", "isModeratorOrHigher") }); @@ -385,16 +448,55 @@ FFZ.prototype.setup_line = function() { this._super(); var el = this.get('element'), - user = this.get('context.model.from'); + user = this.get('context.model.from'), + room = this.get('context.parentController.content.id'), + row_type = this.get('context.model.ffzAlternate'); - el.setAttribute('data-room', this.get('context.parentController.content.id')); + if ( row_type === undefined ) { + row_type = f._last_row[room] = f._last_row.hasOwnProperty(room) ? !f._last_row[room] : false; + this.set("context.model.ffzAlternate", row_type); + } + + el.classList.toggle('ffz-alternate', row_type); + el.setAttribute('data-room', room); el.setAttribute('data-sender', user); f.render_badge(this); - if ( localStorage.ffzCapitalize != 'false' ) + if ( f.settings.capitalize ) f.capitalize(this, user); + // Check for any mentions. + var mentioned = el.querySelector('span.mentioned'); + if ( mentioned ) { + el.classList.add("ffz-mentioned"); + + if ( ! document.hasFocus() && ! this.get('context.model.ffzNotified') && f.settings.highlight_notifications ) { + var cap_room = FFZ.get_capitalization(room), + cap_user = FFZ.get_capitalization(user), + room_name = cap_room, + msg = this.get("context.model.message"); + + if ( this.get("context.parentController.content.isGroupRoom") ) + room_name = this.get("context.parentController.content.tmiRoom.displayName"); + + if ( this.get("context.model.style") == "action" ) + msg = "* " + cap_user + " " + msg; + else + msg = cap_user + ": " + msg; + + f.show_notification( + msg, + "Twitch Chat Mention in " + room_name, + cap_room, + 60000, + window.focus.bind(window) + ); + } + } + + // Mark that we've checked this message for mentions. + this.set('context.model.ffzNotified', true); } }); @@ -402,12 +504,6 @@ FFZ.prototype.setup_line = function() { var user = this.get_user(); if ( user && user.name ) FFZ.capitalization[user.login] = [user.name, Date.now()]; - - // Load the mention words. - if ( localStorage.ffzMentionize ) - this.mention_words = JSON.parse(localStorage.ffzMentionize); - else - this.mention_words = []; } @@ -419,6 +515,10 @@ FFZ.capitalization = {}; FFZ._cap_fetching = 0; FFZ.get_capitalization = function(name, callback) { + // Use the BTTV code if it's present. + if ( window.BetterTTV ) + return BetterTTV.chat.helpers.lookupDisplayName(name); + name = name.toLowerCase(); if ( name == "jtv" || name == "twitchnotify" ) return name; @@ -459,9 +559,9 @@ FFZ.chat_commands.capitalization = function(room, args) { enabled = false; if ( enabled === undefined ) - return "Chat Name Capitalization is currently " + (localStorage.ffzCapitalize != "false" ? "enabled." : "disabled."); + return "Chat Name Capitalization is currently " + (this.settings.capitalize ? "enabled." : "disabled."); - localStorage.ffzCapitalize = enabled; + this.settings.set("capitalize", enabled); return "Chat Name Capitalization is now " + (enabled ? "enabled." : "disabled."); } @@ -474,55 +574,61 @@ FFZ.chat_commands.capitalization.help = "Usage: /ffz capitalization \nEn FFZ._regex_cache = {}; -FFZ.get_regex = function(word) { - return FFZ._regex_cache[word] = FFZ._regex_cache[word] || RegExp("\\b" + reg_escape(word) + "\\b", "i"); +FFZ._get_rex = function(word) { + return FFZ._regex_cache[word] = FFZ._regex_cache[word] || RegExp("\\b" + reg_escape(word) + "\\b", "ig"); +} + +FFZ._mentions_to_regex = function(list) { + return FFZ._regex_cache[list] = FFZ._regex_cache[list] || RegExp("\\b(?:" + _.chain(list).map(reg_escape).value().join("|") + ")\\b", "ig"); } FFZ.prototype._mentionize = function(controller, tokens) { - if ( ! this.mention_words ) + var mention_words = this.settings.keywords; + if ( ! mention_words ) return tokens; if ( typeof tokens == "string" ) tokens = [tokens]; - _.each(this.mention_words, function(word) { - var eo = {mentionedUser: word, own: false}; + var regex = FFZ._mentions_to_regex(mention_words); - tokens = _.compact(_.flatten(_.map(tokens, function(token) { - if ( _.isObject(token) ) - return token; + return _.chain(tokens).map(function(token) { + if ( !_.isString(token) ) + return token; + else if ( !token.match(regex) ) + return [token]; - var tbits = token.split(FFZ.get_regex(word)), bits = []; - tbits.forEach(function(val, ind) { - bits.push(val); - if ( ind !== tbits.length - 1 ) - bits.push(eo); - }); - return bits; - }))); - }); - - return tokens; + return _.zip( + _.map(token.split(regex), _.identity), + _.map(token.match(regex), function(e) { + return { + mentionedUser: e, + own: false + }; + }) + ); + }).flatten().compact().value(); } FFZ.chat_commands.mentionize = function(room, args) { if ( args && args.length ) { - this.mention_words = args.join(" ").trim().split(/\W*,\W*/); - if ( this.mention_words.length == 1 && this.mention_words[0] == "disable" ) - this.mention_words = []; + var mention_words = args.join(" ").trim().split(/\W*,\W*/); + if ( mention_words.length == 1 && mention_words[0] == "disable" ) + mention_words = []; - localStorage.ffzMentionize = JSON.stringify(this.mention_words); + this.settings.set("keywords", mention_words); } - if ( this.mention_words.length ) - return "The following words will be treated as mentions: " + this.mention_words.join(", "); + var mention_words = this.settings.keywords; + if ( mention_words.length ) + return "The following words will be highlighted: " + mention_words.join(", "); else - return "There are no words set that will be treated as mentions."; + return "There are no words set that will be highlighted."; } -FFZ.chat_commands.mentionize.help = "Usage: /ffz mentionize \nSet a list of words that will also be treated as mentions and be displayed specially in chat."; +FFZ.chat_commands.mentionize.help = "Usage: /ffz mentionize \nSet a list of words that will also be highlighted in chat."; // --------------------- @@ -815,7 +921,7 @@ FFZ.prototype._modify_room = function(room) { // filteredSuggestions property of the chat-input component would // be even better, but I was already hooking the room model. var suggestions = this._super(); - if ( localStorage.ffzCapitalize != 'false' ) + if ( this.settings.capitalize ) suggestions = _.map(suggestions, FFZ.get_capitalization); return suggestions; @@ -879,7 +985,7 @@ FFZ.prototype._legacy_load_room_css = function(room_id, callback, data) { output.css = data || null; return this._load_room_json(room_id, callback, output); } -},{"../constants":3,"../utils":23}],8:[function(require,module,exports){ +},{"../constants":3,"../utils":25}],8:[function(require,module,exports){ var FFZ = window.FrankerFaceZ; @@ -981,7 +1087,7 @@ FFZ.prototype._modify_viewers = function(controller) { chatters.sort(); while(chatters.length) { var viewer = chatters.shift(); - viewer = f.has_bttv ? BetterTTV.chat.helpers.lookupDisplayName(viewer) : FFZ.get_capitalization(viewer); + viewer = FFZ.get_capitalization(viewer); viewers.push({chatter: viewer}); } } @@ -1188,7 +1294,7 @@ FFZ.prototype._legacy_load_css = function(set_id, callback, data) { this._load_set_json(set_id, callback, output); } -},{"./constants":3,"./utils":23}],11:[function(require,module,exports){ +},{"./constants":3,"./utils":25}],11:[function(require,module,exports){ var FFZ = window.FrankerFaceZ, SENDER_REGEX = /(\sdata-sender="[^"]*"(?=>))/; @@ -1457,6 +1563,10 @@ FFZ.prototype.get_user = function() { // Import Everything! // ------------------- +//require('./templates'); + +require('./settings'); + require('./socket'); require('./emoticons'); require('./badges'); @@ -1478,11 +1588,13 @@ require('./ext/emote_menu'); require('./featurefriday'); require('./ui/styles'); +//require('./ui/dark'); require('./ui/notifications'); require('./ui/viewer_count'); require('./ui/menu_button'); require('./ui/menu'); +require('./ui/races'); require('./commands'); @@ -1521,13 +1633,12 @@ FFZ.prototype.setup_ember = function(delay) { this.users = {}; - // Cleanup localStorage - for(var key in localStorage) { - if ( key.substr(0,4) == "ffz_" ) - localStorage.removeItem(key); - } - // Initialize all the modules. + this.load_settings(); + + // Start this early, for quick loading. + //this.setup_dark(); + this.ws_create(); this.setup_emoticons(); this.setup_badges(); @@ -1542,8 +1653,10 @@ FFZ.prototype.setup_ember = function(delay) { //this.setup_teams(); + this.setup_notifications(); this.setup_css(); this.setup_menu(); + this.setup_races(); this.find_bttv(10); this.find_emote_menu(10); @@ -1555,7 +1668,7 @@ FFZ.prototype.setup_ember = function(delay) { this.log("Initialization complete in " + duration + "ms"); } -},{"./badges":1,"./commands":2,"./debug":4,"./ember/chatview":5,"./ember/line":6,"./ember/room":7,"./ember/router":8,"./ember/viewers":9,"./emoticons":10,"./ext/betterttv":11,"./ext/emote_menu":12,"./featurefriday":14,"./shims":15,"./socket":16,"./tracking":17,"./ui/menu":18,"./ui/menu_button":19,"./ui/notifications":20,"./ui/styles":21,"./ui/viewer_count":22}],14:[function(require,module,exports){ +},{"./badges":1,"./commands":2,"./debug":4,"./ember/chatview":5,"./ember/line":6,"./ember/room":7,"./ember/router":8,"./ember/viewers":9,"./emoticons":10,"./ext/betterttv":11,"./ext/emote_menu":12,"./featurefriday":14,"./settings":15,"./shims":16,"./socket":17,"./tracking":18,"./ui/menu":19,"./ui/menu_button":20,"./ui/notifications":21,"./ui/races":22,"./ui/styles":23,"./ui/viewer_count":24}],14:[function(require,module,exports){ var FFZ = window.FrankerFaceZ, constants = require('./constants'); @@ -1703,6 +1816,139 @@ FFZ.prototype._update_ff_name = function(name) { this.feature_friday.display_name = name; } },{"./constants":3}],15:[function(require,module,exports){ +var FFZ = window.FrankerFaceZ, + + + make_ls = function(key) { + return "ffz_setting_" + key; + }; + + +// -------------------- +// Initializer +// -------------------- + +FFZ.settings_info = {}; + +FFZ.prototype.load_settings = function() { + this.log("Loading settings."); + + // Build a settings object. + this.settings = {}; + + for(var key in FFZ.settings_info) { + var ls_key = make_ls(key), + info = FFZ.settings_info[key], + val = info.hasOwnProperty("value") ? info.value : undefined; + + if ( localStorage.hasOwnProperty(ls_key) ) { + try { + val = JSON.parse(localStorage.getItem(ls_key)); + } catch(err) { + this.log('Error loading value for "' + key + '": ' + err); + } + } + + this.settings[key] = val; + } + + // Helpers + this.settings.get = this._setting_get.bind(this); + this.settings.set = this._setting_set.bind(this); + this.settings.del = this._setting_del.bind(this); + + // Listen for Changes + window.addEventListener("storage", this._setting_update.bind(this)); +} + + +// -------------------- +// Tracking Updates +// -------------------- + +FFZ.prototype._setting_update = function(e) { + if ( ! e ) + e = window.event; + + this.log("Storage Event", e); + + if ( ! e.key || e.key.substr(0, 12) !== "ffz_setting_" ) + return; + + var ls_key = e.key, + key = ls_key.substr(12), + val = undefined, + info = FFZ.settings_info[key]; + + this.log("Updated Setting: " + key); + + try { + val = JSON.parse(e.newValue); + } catch(err) { + this.log('Error loading new value for "' + key + '": ' + err); + val = info.value || undefined; + } + + this.settings[key] = val; + if ( info.on_update ) + try { + info.on_update.bind(this)(val, false); + } catch(err) { + this.log('Error running updater for setting "' + key + '": ' + err); + } +} + + + +// -------------------- +// Settings Access +// -------------------- + +FFZ.prototype._setting_get = function(key) { + return this.settings[key]; +} + + +FFZ.prototype._setting_set = function(key, val) { + var ls_key = make_ls(key), + info = FFZ.settings_info[key], + jval = JSON.stringify(val); + + this.settings[key] = val; + localStorage.setItem(ls_key, jval); + + this.log('Changed Setting "' + key + '" to: ' + jval); + + if ( info.on_update ) + try { + info.on_update.bind(this)(val, true); + } catch(err) { + this.log('Error running updater for setting "' + key + '": ' + err); + } +} + + +FFZ.prototype._setting_del = function(key) { + var ls_key = make_ls(key), + info = FFZ.settings_info[key], + val = undefined; + + if ( localStorage.hasOwnProperty(ls_key) ) + localStorage.removeItem(ls_key); + + delete this.settings[key]; + + if ( info ) + val = this.settings[key] = info.hasOwnProperty("value") ? info.value : undefined; + + if ( info.on_update ) + try { + info.on_update.bind(this)(val, true); + } catch(err) { + this.log('Error running updater for setting "' + key + '": ' + err); + } +} +},{}],16:[function(require,module,exports){ Array.prototype.equals = function (array) { // if the other array is a falsy value, return if (!array) @@ -1728,13 +1974,14 @@ Array.prototype.equals = function (array) { } -},{}],16:[function(require,module,exports){ +},{}],17:[function(require,module,exports){ var FFZ = window.FrankerFaceZ; FFZ.prototype._ws_open = false; FFZ.prototype._ws_delay = 0; FFZ.ws_commands = {}; +FFZ.ws_on_close = []; // ---------------- @@ -1784,6 +2031,15 @@ FFZ.prototype.ws_create = function() { f.log("Socket closed."); f._ws_open = false; + // When the connection closes, run our callbacks. + for(var i=0; i < FFZ.ws_on_close.length; i++) { + try { + FFZ.ws_on_close[i].bind(f)(); + } catch(err) { + f.log("Error on Socket Close Callback: " + err); + } + } + // We never ever want to not have a socket. if ( f._ws_delay < 30000 ) f._ws_delay += 5000; @@ -1846,7 +2102,7 @@ FFZ.prototype.ws_send = function(func, data, callback, can_wait) { this._ws_sock.send(request + " " + func + data); return request; } -},{}],17:[function(require,module,exports){ +},{}],18:[function(require,module,exports){ var FFZ = window.FrankerFaceZ, constants = require('./constants'), PIWIK = ("https:" == document.location.protocol ? 'https:' : 'http:') + '//sir.stendec.me/ffz_piwik/'; @@ -1978,8 +2234,9 @@ FFZ.prototype.track_page = function() { this.track("trackPageView", document.title); } } -},{"./constants":3}],18:[function(require,module,exports){ -var FFZ = window.FrankerFaceZ; +},{"./constants":3}],19:[function(require,module,exports){ +var FFZ = window.FrankerFaceZ, + constants = require('../constants'); // -------------------- @@ -1999,11 +2256,16 @@ FFZ.prototype.setup_menu = function() { if ( ! parent.is(e.target) && parent.has(e.target).length === 0 ) { popup.remove(); delete f._popup; + f._popup_kill && f._popup_kill(); + delete f._popup_kill; } }); } +FFZ.menu_pages = {}; + + // -------------------- // Create Menu // -------------------- @@ -2013,64 +2275,244 @@ FFZ.prototype.build_ui_popup = function(view) { if ( popup ) { popup.parentElement.removeChild(popup); delete this._popup; + this._popup_kill && this._popup_kill(); + delete this._popup_kill; return; } // Start building the DOM. var container = document.createElement('div'), - inner = document.createElement('div'); + inner = document.createElement('div'), + menu = document.createElement('ul'), + + dark = (this.has_bttv ? BetterTTV.settings.get('darkenedMode') : false); container.className = 'emoticon-selector chat-menu ffz-ui-popup'; inner.className = 'emoticon-selector-box dropmenu'; container.appendChild(inner); - // TODO: Modularize for multiple menu pages! + container.classList.toggle('dark', dark); - // Get the current room. - var room_id = view.get('controller.currentRoom.id'), - room = this.rooms[room_id]; + // Render Menu + menu.className = 'menu clearfix'; + inner.appendChild(menu); - this.log("Menu for Room: " + room_id, room); - this.track('trackEvent', 'Menu', 'Open', room_id); + var el = document.createElement('li'); + el.className = 'title'; + el.innerHTML = "FrankerFaceZ"; + menu.appendChild(el); - // Add the header and ad button. - var btn = document.createElement('a'); - btn.className = 'button glyph-only ffz-button'; - btn.title = 'Advertise for FrankerFaceZ in chat!'; - btn.href = '#'; - btn.innerHTML = ''; + el.addEventListener("click", this._add_emote.bind(this, view, "To use custom emoticons in tons of channels, get FrankerFaceZ from http://www.frankerfacez.com")); - var hdr = document.createElement('div'); - hdr.className = 'list-header first'; - hdr.appendChild(btn); - hdr.appendChild(document.createTextNode('FrankerFaceZ')); - inner.appendChild(hdr); + var sub_container = document.createElement('div'); + sub_container.className = 'ffz-ui-menu-page'; + inner.appendChild(sub_container); - var c = this._emotes_for_sets(inner, view, room && room.menu_sets || []); + for(var key in FFZ.menu_pages) { + var page = FFZ.menu_pages[key]; + if ( !page || (page.hasOwnProperty("visible") && (!page.visible || (typeof page.visible == "function" && !page.visible.bind(this)()))) ) + continue; - if ( ! this._ws_exists ) { - btn.className = "button ffz-button primary"; - btn.innerHTML = "Server Error"; - btn.title = "FFZ Server Error"; - btn.addEventListener('click', alert.bind(window, "The FrankerFaceZ client was unable to create a WebSocket to communicate with the FrankerFaceZ server.\n\nThis is most likely due to your browser's configuration either disabling WebSockets entirely or limiting the number of simultaneous connections. Please ensure that WebSockets have not been disabled.")); + var el = document.createElement('li'), + link = document.createElement('a'); - } else { - if ( c === 0 ) - btn.addEventListener('click', this._add_emote.bind(this, view, "To use custom emoticons in tons of channels, get FrankerFaceZ from http://www.frankerfacez.com")); - else - btn.addEventListener('click', this._add_emote.bind(this, view, "To view this channel's emoticons, get FrankerFaceZ from http://www.frankerfacez.com")); + el.className = 'item'; + el.id = "ffz-menu-page-" + key; + link.title = page.name; + link.innerHTML = page.icon; + + link.addEventListener("click", this._ui_change_page.bind(this, view, menu, sub_container, key)); + + el.appendChild(link); + menu.appendChild(el); } - // Feature Friday! - this._feature_friday_ui(room_id, inner, view); + // Render Current Page + this._ui_change_page(view, menu, sub_container, this._last_page || "channel"); // Add the menu to the DOM. this._popup = container; - inner.style.maxHeight = Math.max(300, view.$().height() - 171) + "px"; + sub_container.style.maxHeight = Math.max(300, view.$().height() - 212) + "px"; view.$('.chat-interface').append(container); } +FFZ.prototype._ui_change_page = function(view, menu, container, page) { + this._last_page = page; + container.innerHTML = ""; + + var els = menu.querySelectorAll('li.active'); + for(var i=0; i < els.length; i++) + els[i].classList.remove('active'); + + var el = menu.querySelector('#ffz-menu-page-' + page); + if ( el ) + el.classList.add('active'); + else + this.log("No matching page: " + page); + + FFZ.menu_pages[page].render.bind(this)(view, container); +} + + +// -------------------- +// Settings Page +// -------------------- + +FFZ.menu_pages.settings = { + render: function(view, container) { + var menu = document.createElement('div'); + menu.className = 'chat-menu-content'; + + var settings = []; + for(var key in FFZ.settings_info) + settings.push([key, FFZ.settings_info[key]]); + + settings.sort(function(a,b) { + var ai = a[1], + bi = b[1], + + an = ai.name.toLowerCase(), + bn = bi.name.toLowerCase(); + + if ( an < bn ) return -1; + else if ( an > bn ) return 1; + return 0; + }); + + + for(var i=0; i < settings.length; i++) { + var key = settings[i][0], + info = settings[i][1], + el = document.createElement('p'), + val = this.settings.get(key); + + if ( info.visible !== undefined && info.visible !== null ) { + var visible = info.visible; + if ( typeof info.visible == "function" ) + visible = info.visible.bind(this)(); + + if ( ! visible ) + continue; + } + + el.className = 'clearfix'; + + if ( info.type == "boolean" ) { + var swit = document.createElement('a'), + label = document.createElement('span'); + + swit.className = 'switch'; + swit.classList.toggle('active', val); + swit.innerHTML = ""; + + label.className = 'switch-label'; + label.innerHTML = info.name; + + el.appendChild(swit); + el.appendChild(label); + + swit.addEventListener("click", this._ui_toggle_setting.bind(this, swit, key)); + + } else { + el.classList.add("option"); + var link = document.createElement('a'); + link.innerHTML = info.name; + link.href = "#"; + el.appendChild(link); + + link.addEventListener("click", info.method.bind(this)); + } + + if ( info.help ) { + var help = document.createElement('span'); + help.className = 'help'; + help.innerHTML = info.help; + el.appendChild(help); + } + + menu.appendChild(el); + } + + container.appendChild(menu); + }, + + name: "Settings", + icon: constants.GEAR + }; + + +FFZ.prototype._ui_toggle_setting = function(swit, key) { + var val = ! this.settings.get(key); + this.settings.set(key, val); + swit.classList.toggle('active', val); +} + + +// -------------------- +// Favorites Page +// -------------------- + +/*FFZ.menu_pages.favorites = { + render: function(view, container) { + + }, + + name: "Favorites", + icon: constants.HEART + };*/ + + +// -------------------- +// Channel Page +// -------------------- + +FFZ.menu_pages.channel = { + render: function(view, inner) { + // Get the current room. + var room_id = view.get('controller.currentRoom.id'), + room = this.rooms[room_id]; + + this.log("Menu for Room: " + room_id, room); + this.track('trackEvent', 'Menu', 'Open', room_id); + + // Add the header and ad button. + /*var btn = document.createElement('a'); + btn.className = 'button glyph-only ffz-button'; + btn.title = 'Advertise for FrankerFaceZ in chat!'; + btn.href = '#'; + btn.innerHTML = ''; + + var hdr = document.createElement('div'); + hdr.className = 'list-header first'; + hdr.appendChild(btn); + hdr.appendChild(document.createTextNode('FrankerFaceZ')); + inner.appendChild(hdr);*/ + + var c = this._emotes_for_sets(inner, view, room && room.menu_sets || []); + + /*if ( ! this._ws_exists ) { + btn.className = "button ffz-button primary"; + btn.innerHTML = "Server Error"; + btn.title = "FFZ Server Error"; + btn.addEventListener('click', alert.bind(window, "The FrankerFaceZ client was unable to create a WebSocket to communicate with the FrankerFaceZ server.\n\nThis is most likely due to your browser's configuration either disabling WebSockets entirely or limiting the number of simultaneous connections. Please ensure that WebSockets have not been disabled.")); + + } else { + if ( c === 0 ) + btn.addEventListener('click', this._add_emote.bind(this, view, "To use custom emoticons in tons of channels, get FrankerFaceZ from http://www.frankerfacez.com")); + else + btn.addEventListener('click', this._add_emote.bind(this, view, "To view this channel's emoticons, get FrankerFaceZ from http://www.frankerfacez.com")); + }*/ + + // Feature Friday! + this._feature_friday_ui(room_id, inner, view); + }, + + name: "Channel", + icon: constants.ZREKNARF + }; + + // -------------------- // Emotes for Sets // -------------------- @@ -2130,7 +2572,7 @@ FFZ.prototype._add_emote = function(view, emote) { room.set('messageToSend', current_text + (emote.name || emote)); } -},{}],19:[function(require,module,exports){ +},{"../constants":3}],20:[function(require,module,exports){ var FFZ = window.FrankerFaceZ, constants = require('../constants'); @@ -2181,10 +2623,147 @@ FFZ.prototype.update_ui_link = function(link) { link.classList.toggle('dark', dark); link.classList.toggle('blue', blue); } -},{"../constants":3}],20:[function(require,module,exports){ +},{"../constants":3}],21:[function(require,module,exports){ var FFZ = window.FrankerFaceZ; -FFZ.prototype.show_notification = function(message) { + +// --------------------- +// Initialization +// --------------------- + +FFZ.prototype.setup_notifications = function() { + this.log("Adding event handler for window focus."); + window.addEventListener("focus", this.clear_notifications.bind(this)); +} + + +// --------------------- +// Settings +// --------------------- + +FFZ.settings_info.highlight_notifications = { + type: "boolean", + value: false, + + visible: function() { return ! this.has_bttv }, + + name: "Highlight Notifications", + help: "Display notifications when a highlighted word appears in chat in an unfocused tab.", + + on_update: function(val, direct) { + // Check to see if we have notification permission. If this is + // enabled, at least. + if ( ! val || ! direct ) + return; + + if ( Notification.permission === "denied" ) { + this.log("Notifications have been denied by the user."); + this.settings.set("highlight_notifications", false); + return; + + } else if ( Notification.permission === "granted" ) + return; + + var f = this; + Notification.requestPermission(function(e) { + if ( e === "denied" ) { + f.log("Notifications have been denied by the user."); + f.settings.set("highlight_notifications", false); + } + }); + } + }; + + +// --------------------- +// Socket Commands +// --------------------- + +FFZ.ws_commands.message = function(message) { + this.show_mesage(message); +} + + +// --------------------- +// Notifications +// --------------------- + +FFZ._notifications = {}; +FFZ._last_notification = 0; + +FFZ.prototype.clear_notifications = function() { + for(var k in FFZ._notifications) { + var n = FFZ._notifications[k]; + if ( n ) + try { + n.close(); + } catch(err) { } + } + + FFZ._notifications = {}; + FFZ._last_notification = 0; +} + + +FFZ.prototype.show_notification = function(message, title, tag, timeout, on_click, on_close) { + var perm = Notification.permission; + if ( perm === "denied " ) + return false; + + if ( perm === "granted" ) { + title = title || "FrankerFaceZ"; + timeout = timeout || 10000; + + var options = { + lang: "en-US", + dir: "ltr", + body: message, + tag: tag || "FrankerFaceZ", + icon: "http://cdn.frankerfacez.com/icon32.png" + }; + + var f = this, + n = new Notification(title, options), + nid = FFZ._last_notification++; + + FFZ._notifications[nid] = n; + + n.addEventListener("click", function() { + delete FFZ._notifications[nid]; + if ( on_click ) + on_click.bind(f)(); + }); + + n.addEventListener("close", function() { + delete FFZ._notifications[nid]; + if ( on_close ) + on_close.bind(f)(); + }); + + if ( typeof timeout == "number" ) + n.addEventListener("show", function() { + setTimeout(function() { + delete FFZ._notifications[nid]; + n.close(); + }, timeout); + }); + + return; + } + + var f = this; + Notification.requestPermission(function(e) { + f.show_notification(message, title, tag); + }); +} + + + +// --------------------- +// Noty Notification +// --------------------- + +FFZ.prototype.show_message = function(message) { window.noty({ text: message, theme: "ffzTheme", @@ -2192,12 +2771,310 @@ FFZ.prototype.show_notification = function(message) { closeWith: ["button"] }).show(); } +},{}],22:[function(require,module,exports){ +var FFZ = window.FrankerFaceZ, + utils = require('../utils'); -FFZ.ws_commands.message = function(message) { - this.show_notification(message); +// --------------- +// Initialization +// --------------- + +FFZ.prototype.setup_races = function() { + this.log("Initializing race support."); + this.srl_races = {}; } -},{}],21:[function(require,module,exports){ + + +// --------------- +// Settings +// --------------- + +FFZ.settings_info.srl_races = { + type: "boolean", + value: true, + name: "SRL Race Information", + help: 'Display information about SpeedRunsLive races under channels.', + on_update: function(val) { + this.rebuild_race_ui(); + } + }; + + +// --------------- +// Socket Handler +// --------------- + +FFZ.ws_on_close.push(function() { + var controller = App.__container__.lookup('controller:channel'), + current_id = controller.get('id'), + need_update = false; + + for(var chan in this.srl_races) { + delete this.srl_races[chan]; + if ( chan == current_id ) + need_update = true; + } + + if ( need_update ) + this.rebuild_race_ui(); +}); + +FFZ.ws_commands.srl_race = function(data) { + var controller = App.__container__.lookup('controller:channel'), + current_id = controller.get('id'), + need_update = false; + + for(var i=0; i < data[0].length; i++) { + var channel_id = data[0][i]; + this.srl_races[channel_id] = data[1]; + if ( channel_id == current_id ) + need_update = true; + } + + if ( data[1] ) { + var race = data[1], + tte = race.twitch_entrants = {}; + + for(var ent in race.entrants) { + if ( ! race.entrants.hasOwnProperty(ent) ) continue; + if ( race.entrants[ent].channel ) + tte[race.entrants[ent].channel] = ent; + race.entrants[ent].name = ent; + } + } + + if ( need_update ) + this.rebuild_race_ui(); +} + + +// --------------- +// Race UI +// --------------- + +FFZ.prototype.rebuild_race_ui = function() { + var controller = App.__container__.lookup('controller:channel'), + channel_id = controller.get('id'), + race = this.srl_races[channel_id], + enable_ui = this.settings.srl_races, + + actions = document.querySelector('.stats-and-actions .channel-actions'), + race_container = actions.querySelector('#ffz-ui-race'); + + if ( ! race || ! enable_ui ) { + if ( race_container ) + race_container.parentElement.removeChild(race_container); + if ( this._popup && this._popup.id == "ffz-race-popup" ) { + delete this._popup; + this._popup_kill && this._popup_kill(); + delete this._popup_kill; + } + return; + } + + if ( race_container ) + return this._update_race(true); + + race_container = document.createElement('span'); + race_container.setAttribute('data-channel', channel_id); + race_container.id = 'ffz-ui-race'; + + var btn = document.createElement('span'); + btn.className = 'button drop action'; + btn.title = "SpeedRunsLive Race"; + btn.innerHTML = '",d.className="switch-label",d.innerHTML=r.name,l.appendChild(u),l.appendChild(d),u.addEventListener("click",this._ui_toggle_setting.bind(this,u,o))}else{l.classList.add("option");var p=document.createElement("a");p.innerHTML=r.name,p.href="#",l.appendChild(p),p.addEventListener("click",r.method.bind(this))}if(r.help){var _=document.createElement("span");_.className="help",_.innerHTML=r.help,l.appendChild(_)}i.appendChild(l)}t.appendChild(i)},name:"Settings",icon:i.GEAR},n.prototype._ui_toggle_setting=function(e,t){var n=!this.settings.get(t);this.settings.set(t,n),e.classList.toggle("active",n)},n.menu_pages.channel={render:function(e,t){var n=e.get("controller.currentRoom.id"),i=this.rooms[n];this.log("Menu for Room: "+n,i),this.track("trackEvent","Menu","Open",n);this._emotes_for_sets(t,e,i&&i.menu_sets||[]);this._feature_friday_ui(n,t,e)},name:"Channel",icon:i.ZREKNARF},n.prototype._emotes_for_sets=function(e,t,n,i,s){if(null!=i){var o=document.createElement("div");o.className="list-header",o.appendChild(document.createTextNode(i)),s&&o.appendChild(s),e.appendChild(o)}var a=document.createElement("div"),r=0;a.className="emoticon-grid";for(var l=0;l0){s=!0;break}}e.classList.toggle("no-emotes",!s),e.classList.toggle("live",r),e.classList.toggle("dark",o),e.classList.toggle("blue",a)}}},{"../constants":3}],21:[function(){var t=e.FrankerFaceZ;t.prototype.setup_notifications=function(){this.log("Adding event handler for window focus."),e.addEventListener("focus",this.clear_notifications.bind(this))},t.settings_info.highlight_notifications={type:"boolean",value:!1,visible:function(){return!this.has_bttv},name:"Highlight Notifications",help:"Display notifications when a highlighted word appears in chat in an unfocused tab.",on_update:function(e,t){if(e&&t){if("denied"===Notification.permission)return this.log("Notifications have been denied by the user."),void this.settings.set("highlight_notifications",!1);if("granted"!==Notification.permission){var n=this;Notification.requestPermission(function(e){"denied"===e&&(n.log("Notifications have been denied by the user."),n.settings.set("highlight_notifications",!1))})}}}},t.ws_commands.message=function(e){this.show_mesage(e)},t._notifications={},t._last_notification=0,t.prototype.clear_notifications=function(){for(var e in t._notifications){var n=t._notifications[e];if(n)try{n.close()}catch(i){}}t._notifications={},t._last_notification=0},t.prototype.show_notification=function(e,n,i,s,o,a){var r=Notification.permission;if("denied "===r)return!1;if("granted"===r){n=n||"FrankerFaceZ",s=s||1e4;var l={lang:"en-US",dir:"ltr",body:e,tag:i||"FrankerFaceZ",icon:"http://cdn.frankerfacez.com/icon32.png"},c=this,h=new Notification(n,l),u=t._last_notification++;return t._notifications[u]=h,h.addEventListener("click",function(){delete t._notifications[u],o&&o.bind(c)()}),h.addEventListener("close",function(){delete t._notifications[u],a&&a.bind(c)()}),void("number"==typeof s&&h.addEventListener("show",function(){setTimeout(function(){delete t._notifications[u],h.close()},s)}))}var c=this;Notification.requestPermission(function(){c.show_notification(e,n,i)})},t.prototype.show_message=function(t){e.noty({text:t,theme:"ffzTheme",layout:"bottomCenter",closeWith:["button"]}).show()}},{}],22:[function(t){var n=e.FrankerFaceZ,i=t("../utils");n.prototype.setup_races=function(){this.log("Initializing race support."),this.srl_races={}},n.settings_info.srl_races={type:"boolean",value:!0,name:"SRL Race Information",help:'Display information about SpeedRunsLive races under channels.',on_update:function(){this.rebuild_race_ui()}},n.ws_on_close.push(function(){var e=App.__container__.lookup("controller:channel"),t=e.get("id"),n=!1;for(var i in this.srl_races)delete this.srl_races[i],i==t&&(n=!0);n&&this.rebuild_race_ui()}),n.ws_commands.srl_race=function(e){for(var t=App.__container__.lookup("controller:channel"),n=t.get("id"),i=!1,s=0;s=300?"right":"left")+" share dropmenu",this._popup_kill=this._race_kill.bind(this),this._popup=e;var l="http://kadgar.net/live",c=!1;for(var h in a.entrants){var u=a.entrants[h].state;a.entrants.hasOwnProperty(h)&&a.entrants[h].channel&&("racing"==u||"entered"==u)&&(l+="/"+a.entrants[h].channel,c=!0)}var d=document.querySelector(".app-main.theatre")?document.body.clientHeight-300:t.parentElement.offsetTop-175,p=App.__container__.lookup("controller:channel"),_=p?p.get("display_name"):n.get_capitalization(o),f=encodeURIComponent("I'm watching "+_+" race "+a.goal+" in "+a.game+" on SpeedRunsLive!");r='
',r+='
',r+="
#Entrant Time
",r+='
',r+='',r+='

SRL',c&&(r+='   Multitwitch'),r+="

",e.innerHTML=r,t.appendChild(e),this._update_race(!0)}}},n.prototype._update_race=function(e){this._race_timer&&e&&(clearTimeout(this._race_timer),delete this._race_timer);var t=document.querySelector("#ffz-ui-race");if(t){var n=t.getAttribute("data-channel"),s=this.srl_races[n];if(!s)return t.parentElement.removeChild(t),this._popup_kill&&this._popup_kill(),void(this._popup&&(delete this._popup,delete this._popup_kill));var o=s.twitch_entrants[n],a=s.entrants[o],r=t.querySelector("#ffz-race-popup"),l=Date.now()/1e3,c=Math.floor(l-s.time);if(t.querySelector(".logo").innerHTML=i.placement(a),r){var h=r.querySelector("tbody"),u=r.querySelector(".heading span"),d=r.querySelector(".heading div");h.innerHTML="";var p=[],_=!0;for(var f in s.entrants)s.entrants.hasOwnProperty(f)&&("racing"==s.entrants[f].state&&(_=!1),p.push(s.entrants[f]));p.sort(function(e,t){var n=e.place||9999,i=t.place||9999,s=e.time||c,o=t.time||c;return("forfeit"==e.state||"dq"==e.state)&&(n=1e4),("forfeit"==t.state||"dq"==t.state)&&(i=1e4),i>n?-1:n>i?1:e.namet.name?1:o>s?-1:s>o?1:void 0});for(var m=0;m'+f.display_name+"",v=f.channel?'':"",b=f.hitbox?'':"",y=c?i.time_to_string(f.time||c):"",w=i.place_string(f.place),k=f.comment?i.sanitize(f.comment):"";h.innerHTML+="'+w+""+g+""+v+b+''+("forfeit"==f.state?"Forfeit":y)+""}if(this._race_game!=s.game||this._race_goal!=s.goal){this._race_game=s.game,this._race_goal=s.goal;var T=i.sanitize(s.game),z=i.sanitize(s.goal);d.innerHTML='

'+T+"

Goal: "+z}c?_?u.innerHTML="Done":(u.innerHTML=i.time_to_string(c),this._race_timer=setTimeout(this._update_race.bind(this),1e3)):u.innerHTML="Entry Open"}}}},{"../utils":25}],23:[function(t){var n=e.FrankerFaceZ,i=t("../constants");n.prototype.setup_css=function(){this.log("Injecting main FrankerFaceZ CSS.");var e=this._main_style=document.createElement("link");e.id="ffz-ui-css",e.setAttribute("rel","stylesheet"),e.setAttribute("href",i.SERVER+"script/style.css"),document.head.appendChild(e),jQuery.noty.themes.ffzTheme={name:"ffzTheme",style:function(){this.$bar.removeClass().addClass("noty_bar").addClass("ffz-noty").addClass(this.options.type)},callback:{onShow:function(){},onClose:function(){}}}}},{"../constants":3}],24:[function(t){var n=e.FrankerFaceZ,i=t("../constants"),s=t("../utils");n.ws_commands.viewers=function(e){var t=e[0],n=e[1],o=App.__container__.lookup("controller:channel"),a=o&&o.get&&o.get("id");if(a===t){var r=document.querySelector(".channel-stats .ffz.stat"),l=i.ZREKNARF+" "+s.number_commas(n);if(r)r.innerHTML=l;else{var c=document.querySelector(".channel-stats");if(!c)return;r=document.createElement("span"),r.className="ffz stat",r.title="Viewers with FrankerFaceZ",r.innerHTML=l,c.appendChild(r),jQuery(r).tipsy()}}}},{"../constants":3,"../utils":25}],25:[function(t,n){var i=(e.FrankerFaceZ,t("./constants"),{}),s=document.createElement("span"),o=function(e){return 1==e?"1st":2==e?"2nd":3==e?"3rd":null==e?"---":e+"th"};n.exports={update_css:function(e,t,n){var i=e.innerHTML,s="/*BEGIN "+t+"*/",o="/*END "+t+"*/",a=i.indexOf(s),r=i.indexOf(o),l=-1!==a&&-1!==r&&r>a;(l||n)&&(l&&(i=i.substr(0,a)+i.substr(r+o.length)),n&&(i+=s+n+o),e.innerHTML=i)},number_commas:function(e){var t=e.toString().split(".");return t[0]=t[0].replace(/\B(?=(\d{3})+(?!\d))/g,","),t.join(".")},place_string:o,placement:function(e){return"forfeit"==e.state?"Forfeit":"dq"==e.state?"DQed":e.place?o(e.place):""},sanitize:function(e){var t=i[e];return t||(s.textContent=e,t=i[e]=s.innerHTML,s.innerHTML=""),t},time_to_string:function(e){var t=e%60,n=Math.floor(e/60),i=Math.floor(n/60);return n%=60,(10>i?"0":"")+i+":"+(10>n?"0":"")+n+":"+(10>t?"0":"")+t}}},{"./constants":3}]},{},[13]),e.ffz=new FrankerFaceZ}(window); \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index cb656f26..295da7bf 100644 --- a/src/constants.js +++ b/src/constants.js @@ -7,5 +7,8 @@ module.exports = { SVGPATH: SVGPATH, ZREKNARF: '' + SVGPATH + '', - CHAT_BUTTON: '' + SVGPATH + '' + CHAT_BUTTON: '' + SVGPATH + '', + + GEAR: '', + HEART: '' } \ No newline at end of file diff --git a/src/ember/line.js b/src/ember/line.js index 7427f75e..cebffb9d 100644 --- a/src/ember/line.js +++ b/src/ember/line.js @@ -5,11 +5,72 @@ var FFZ = window.FrankerFaceZ, }; +// --------------------- +// Settings +// --------------------- + +FFZ.settings_info.capitalize = { + type: "boolean", + value: true, + + visible: function() { return ! this.has_bttv }, + + name: "Username Capitalization", + help: "Display names in chat with proper capitalization." + }; + + +FFZ.settings_info.keywords = { + type: "button", + value: [], + + visible: function() { return ! this.has_bttv }, + + name: "Highlight Keywords", + help: "Set additional keywords that will be highlighted in chat.", + + method: function() { + var old_val = this.settings.keywords.join(", "), + new_val = prompt("Highlight Keywords\n\nPlease enter a comma-separated list of words that you would like to be highlighted in chat.", old_val); + + if ( ! new_val ) + return; + + // Split them up. + new_val = new_val.trim().split(/\W*,\W*/); + + if ( new_val.length == 1 && (new_val[0] == "" || new_val[0] == "disable") ) + new_val = []; + + this.settings.set("keywords", new_val); + } + }; + + +FFZ.settings_info.chat_rows = { + type: "boolean", + value: false, + + visible: function() { return ! this.has_bttv }, + + name: "Chat Line Backgrounds", + help: "Display alternating background colors for lines in chat.", + + on_update: function(val) { + document.querySelector(".app-main").classList.toggle("ffz-chat-background", val); + } + }; + + // --------------------- // Initialization // --------------------- FFZ.prototype.setup_line = function() { + // Alternating Background + document.querySelector('.app-main').classList.toggle('ffz-chat-background', this.settings.chat_rows); + this._last_row = {}; + this.log("Hooking the Ember Line controller."); var Line = App.__container__.resolve('controller:line'), @@ -26,8 +87,7 @@ FFZ.prototype.setup_line = function() { return tokens; - }.property("model.message", "isModeratorOrHigher", "controllers.emoticons.emoticons.[]") - // TODO: Copy the new properties from the new Twitch! + }.property("model.message", "isModeratorOrHigher") }); @@ -39,16 +99,55 @@ FFZ.prototype.setup_line = function() { this._super(); var el = this.get('element'), - user = this.get('context.model.from'); + user = this.get('context.model.from'), + room = this.get('context.parentController.content.id'), + row_type = this.get('context.model.ffzAlternate'); - el.setAttribute('data-room', this.get('context.parentController.content.id')); + if ( row_type === undefined ) { + row_type = f._last_row[room] = f._last_row.hasOwnProperty(room) ? !f._last_row[room] : false; + this.set("context.model.ffzAlternate", row_type); + } + + el.classList.toggle('ffz-alternate', row_type); + el.setAttribute('data-room', room); el.setAttribute('data-sender', user); f.render_badge(this); - if ( localStorage.ffzCapitalize != 'false' ) + if ( f.settings.capitalize ) f.capitalize(this, user); + // Check for any mentions. + var mentioned = el.querySelector('span.mentioned'); + if ( mentioned ) { + el.classList.add("ffz-mentioned"); + + if ( ! document.hasFocus() && ! this.get('context.model.ffzNotified') && f.settings.highlight_notifications ) { + var cap_room = FFZ.get_capitalization(room), + cap_user = FFZ.get_capitalization(user), + room_name = cap_room, + msg = this.get("context.model.message"); + + if ( this.get("context.parentController.content.isGroupRoom") ) + room_name = this.get("context.parentController.content.tmiRoom.displayName"); + + if ( this.get("context.model.style") == "action" ) + msg = "* " + cap_user + " " + msg; + else + msg = cap_user + ": " + msg; + + f.show_notification( + msg, + "Twitch Chat Mention in " + room_name, + cap_room, + 60000, + window.focus.bind(window) + ); + } + } + + // Mark that we've checked this message for mentions. + this.set('context.model.ffzNotified', true); } }); @@ -56,12 +155,6 @@ FFZ.prototype.setup_line = function() { var user = this.get_user(); if ( user && user.name ) FFZ.capitalization[user.login] = [user.name, Date.now()]; - - // Load the mention words. - if ( localStorage.ffzMentionize ) - this.mention_words = JSON.parse(localStorage.ffzMentionize); - else - this.mention_words = []; } @@ -73,6 +166,10 @@ FFZ.capitalization = {}; FFZ._cap_fetching = 0; FFZ.get_capitalization = function(name, callback) { + // Use the BTTV code if it's present. + if ( window.BetterTTV ) + return BetterTTV.chat.helpers.lookupDisplayName(name); + name = name.toLowerCase(); if ( name == "jtv" || name == "twitchnotify" ) return name; @@ -113,9 +210,9 @@ FFZ.chat_commands.capitalization = function(room, args) { enabled = false; if ( enabled === undefined ) - return "Chat Name Capitalization is currently " + (localStorage.ffzCapitalize != "false" ? "enabled." : "disabled."); + return "Chat Name Capitalization is currently " + (this.settings.capitalize ? "enabled." : "disabled."); - localStorage.ffzCapitalize = enabled; + this.settings.set("capitalize", enabled); return "Chat Name Capitalization is now " + (enabled ? "enabled." : "disabled."); } @@ -128,55 +225,61 @@ FFZ.chat_commands.capitalization.help = "Usage: /ffz capitalization \nEn FFZ._regex_cache = {}; -FFZ.get_regex = function(word) { - return FFZ._regex_cache[word] = FFZ._regex_cache[word] || RegExp("\\b" + reg_escape(word) + "\\b", "i"); +FFZ._get_rex = function(word) { + return FFZ._regex_cache[word] = FFZ._regex_cache[word] || RegExp("\\b" + reg_escape(word) + "\\b", "ig"); +} + +FFZ._mentions_to_regex = function(list) { + return FFZ._regex_cache[list] = FFZ._regex_cache[list] || RegExp("\\b(?:" + _.chain(list).map(reg_escape).value().join("|") + ")\\b", "ig"); } FFZ.prototype._mentionize = function(controller, tokens) { - if ( ! this.mention_words ) + var mention_words = this.settings.keywords; + if ( ! mention_words ) return tokens; if ( typeof tokens == "string" ) tokens = [tokens]; - _.each(this.mention_words, function(word) { - var eo = {mentionedUser: word, own: false}; + var regex = FFZ._mentions_to_regex(mention_words); - tokens = _.compact(_.flatten(_.map(tokens, function(token) { - if ( _.isObject(token) ) - return token; + return _.chain(tokens).map(function(token) { + if ( !_.isString(token) ) + return token; + else if ( !token.match(regex) ) + return [token]; - var tbits = token.split(FFZ.get_regex(word)), bits = []; - tbits.forEach(function(val, ind) { - bits.push(val); - if ( ind !== tbits.length - 1 ) - bits.push(eo); - }); - return bits; - }))); - }); - - return tokens; + return _.zip( + _.map(token.split(regex), _.identity), + _.map(token.match(regex), function(e) { + return { + mentionedUser: e, + own: false + }; + }) + ); + }).flatten().compact().value(); } FFZ.chat_commands.mentionize = function(room, args) { if ( args && args.length ) { - this.mention_words = args.join(" ").trim().split(/\W*,\W*/); - if ( this.mention_words.length == 1 && this.mention_words[0] == "disable" ) - this.mention_words = []; + var mention_words = args.join(" ").trim().split(/\W*,\W*/); + if ( mention_words.length == 1 && mention_words[0] == "disable" ) + mention_words = []; - localStorage.ffzMentionize = JSON.stringify(this.mention_words); + this.settings.set("keywords", mention_words); } - if ( this.mention_words.length ) - return "The following words will be treated as mentions: " + this.mention_words.join(", "); + var mention_words = this.settings.keywords; + if ( mention_words.length ) + return "The following words will be highlighted: " + mention_words.join(", "); else - return "There are no words set that will be treated as mentions."; + return "There are no words set that will be highlighted."; } -FFZ.chat_commands.mentionize.help = "Usage: /ffz mentionize \nSet a list of words that will also be treated as mentions and be displayed specially in chat."; +FFZ.chat_commands.mentionize.help = "Usage: /ffz mentionize \nSet a list of words that will also be highlighted in chat."; // --------------------- diff --git a/src/ember/room.js b/src/ember/room.js index a9ee5868..227e8326 100644 --- a/src/ember/room.js +++ b/src/ember/room.js @@ -228,7 +228,7 @@ FFZ.prototype._modify_room = function(room) { // filteredSuggestions property of the chat-input component would // be even better, but I was already hooking the room model. var suggestions = this._super(); - if ( localStorage.ffzCapitalize != 'false' ) + if ( this.settings.capitalize ) suggestions = _.map(suggestions, FFZ.get_capitalization); return suggestions; diff --git a/src/ember/viewers.js b/src/ember/viewers.js index eafce418..0f5c2ee3 100644 --- a/src/ember/viewers.js +++ b/src/ember/viewers.js @@ -81,7 +81,7 @@ FFZ.prototype._modify_viewers = function(controller) { chatters.sort(); while(chatters.length) { var viewer = chatters.shift(); - viewer = f.has_bttv ? BetterTTV.chat.helpers.lookupDisplayName(viewer) : FFZ.get_capitalization(viewer); + viewer = FFZ.get_capitalization(viewer); viewers.push({chatter: viewer}); } } diff --git a/src/main.js b/src/main.js index be3e1826..2a722ada 100644 --- a/src/main.js +++ b/src/main.js @@ -61,6 +61,10 @@ FFZ.prototype.get_user = function() { // Import Everything! // ------------------- +//require('./templates'); + +require('./settings'); + require('./socket'); require('./emoticons'); require('./badges'); @@ -82,11 +86,13 @@ require('./ext/emote_menu'); require('./featurefriday'); require('./ui/styles'); +//require('./ui/dark'); require('./ui/notifications'); require('./ui/viewer_count'); require('./ui/menu_button'); require('./ui/menu'); +require('./ui/races'); require('./commands'); @@ -125,13 +131,12 @@ FFZ.prototype.setup_ember = function(delay) { this.users = {}; - // Cleanup localStorage - for(var key in localStorage) { - if ( key.substr(0,4) == "ffz_" ) - localStorage.removeItem(key); - } - // Initialize all the modules. + this.load_settings(); + + // Start this early, for quick loading. + //this.setup_dark(); + this.ws_create(); this.setup_emoticons(); this.setup_badges(); @@ -146,8 +151,10 @@ FFZ.prototype.setup_ember = function(delay) { //this.setup_teams(); + this.setup_notifications(); this.setup_css(); this.setup_menu(); + this.setup_races(); this.find_bttv(10); this.find_emote_menu(10); diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 00000000..286684d4 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,132 @@ +var FFZ = window.FrankerFaceZ, + + + make_ls = function(key) { + return "ffz_setting_" + key; + }; + + +// -------------------- +// Initializer +// -------------------- + +FFZ.settings_info = {}; + +FFZ.prototype.load_settings = function() { + this.log("Loading settings."); + + // Build a settings object. + this.settings = {}; + + for(var key in FFZ.settings_info) { + var ls_key = make_ls(key), + info = FFZ.settings_info[key], + val = info.hasOwnProperty("value") ? info.value : undefined; + + if ( localStorage.hasOwnProperty(ls_key) ) { + try { + val = JSON.parse(localStorage.getItem(ls_key)); + } catch(err) { + this.log('Error loading value for "' + key + '": ' + err); + } + } + + this.settings[key] = val; + } + + // Helpers + this.settings.get = this._setting_get.bind(this); + this.settings.set = this._setting_set.bind(this); + this.settings.del = this._setting_del.bind(this); + + // Listen for Changes + window.addEventListener("storage", this._setting_update.bind(this)); +} + + +// -------------------- +// Tracking Updates +// -------------------- + +FFZ.prototype._setting_update = function(e) { + if ( ! e ) + e = window.event; + + this.log("Storage Event", e); + + if ( ! e.key || e.key.substr(0, 12) !== "ffz_setting_" ) + return; + + var ls_key = e.key, + key = ls_key.substr(12), + val = undefined, + info = FFZ.settings_info[key]; + + this.log("Updated Setting: " + key); + + try { + val = JSON.parse(e.newValue); + } catch(err) { + this.log('Error loading new value for "' + key + '": ' + err); + val = info.value || undefined; + } + + this.settings[key] = val; + if ( info.on_update ) + try { + info.on_update.bind(this)(val, false); + } catch(err) { + this.log('Error running updater for setting "' + key + '": ' + err); + } +} + + + +// -------------------- +// Settings Access +// -------------------- + +FFZ.prototype._setting_get = function(key) { + return this.settings[key]; +} + + +FFZ.prototype._setting_set = function(key, val) { + var ls_key = make_ls(key), + info = FFZ.settings_info[key], + jval = JSON.stringify(val); + + this.settings[key] = val; + localStorage.setItem(ls_key, jval); + + this.log('Changed Setting "' + key + '" to: ' + jval); + + if ( info.on_update ) + try { + info.on_update.bind(this)(val, true); + } catch(err) { + this.log('Error running updater for setting "' + key + '": ' + err); + } +} + + +FFZ.prototype._setting_del = function(key) { + var ls_key = make_ls(key), + info = FFZ.settings_info[key], + val = undefined; + + if ( localStorage.hasOwnProperty(ls_key) ) + localStorage.removeItem(ls_key); + + delete this.settings[key]; + + if ( info ) + val = this.settings[key] = info.hasOwnProperty("value") ? info.value : undefined; + + if ( info.on_update ) + try { + info.on_update.bind(this)(val, true); + } catch(err) { + this.log('Error running updater for setting "' + key + '": ' + err); + } +} \ No newline at end of file diff --git a/src/socket.js b/src/socket.js index 35e8785d..406c99fd 100644 --- a/src/socket.js +++ b/src/socket.js @@ -4,6 +4,7 @@ FFZ.prototype._ws_open = false; FFZ.prototype._ws_delay = 0; FFZ.ws_commands = {}; +FFZ.ws_on_close = []; // ---------------- @@ -53,6 +54,15 @@ FFZ.prototype.ws_create = function() { f.log("Socket closed."); f._ws_open = false; + // When the connection closes, run our callbacks. + for(var i=0; i < FFZ.ws_on_close.length; i++) { + try { + FFZ.ws_on_close[i].bind(f)(); + } catch(err) { + f.log("Error on Socket Close Callback: " + err); + } + } + // We never ever want to not have a socket. if ( f._ws_delay < 30000 ) f._ws_delay += 5000; diff --git a/src/ui/dark.js b/src/ui/dark.js new file mode 100644 index 00000000..3b23365e --- /dev/null +++ b/src/ui/dark.js @@ -0,0 +1,49 @@ +var FFZ = window.FrankerFaceZ, + constants = require("../constants"); + + +// --------------------- +// Settings +// --------------------- + +FFZ.settings_info.dark_twitch = { + type: "boolean", + value: false, + + visible: function() { return ! this.has_bttv }, + + name: "Dark Twitch", + help: "View the entire site with a dark theme.", + + on_update: function(val) { + document.querySelector(".app-main").classList.toggle("ffz-dark", val); + if ( val ) + this._load_dark_css(); + } + }; + + +// --------------------- +// Initialization +// --------------------- + +FFZ.prototype.setup_dark = function() { + document.querySelector(".app-main").classList.toggle("ffz-dark", this.settings.dark_twitch); + if ( this.settings.dark_twitch ) + this._load_dark_css(); +} + + +FFZ.prototype._load_dark_css = function() { + if ( this._dark_style ) + return; + + this.log("Injecting FrankerFaceZ Dark Twitch CSS."); + + var s = this._dark_style = document.createElement('link'); + + s.id = "ffz-dark-css"; + s.setAttribute('rel', 'stylesheet'); + s.setAttribute('href', constants.SERVER + "script/dark.css"); + document.head.appendChild(s); +} \ No newline at end of file diff --git a/src/ui/menu.js b/src/ui/menu.js index fa8dbce8..64cf276c 100644 --- a/src/ui/menu.js +++ b/src/ui/menu.js @@ -1,4 +1,5 @@ -var FFZ = window.FrankerFaceZ; +var FFZ = window.FrankerFaceZ, + constants = require('../constants'); // -------------------- @@ -18,11 +19,16 @@ FFZ.prototype.setup_menu = function() { if ( ! parent.is(e.target) && parent.has(e.target).length === 0 ) { popup.remove(); delete f._popup; + f._popup_kill && f._popup_kill(); + delete f._popup_kill; } }); } +FFZ.menu_pages = {}; + + // -------------------- // Create Menu // -------------------- @@ -32,64 +38,244 @@ FFZ.prototype.build_ui_popup = function(view) { if ( popup ) { popup.parentElement.removeChild(popup); delete this._popup; + this._popup_kill && this._popup_kill(); + delete this._popup_kill; return; } // Start building the DOM. var container = document.createElement('div'), - inner = document.createElement('div'); + inner = document.createElement('div'), + menu = document.createElement('ul'), + + dark = (this.has_bttv ? BetterTTV.settings.get('darkenedMode') : false); container.className = 'emoticon-selector chat-menu ffz-ui-popup'; inner.className = 'emoticon-selector-box dropmenu'; container.appendChild(inner); - // TODO: Modularize for multiple menu pages! + container.classList.toggle('dark', dark); - // Get the current room. - var room_id = view.get('controller.currentRoom.id'), - room = this.rooms[room_id]; + // Render Menu + menu.className = 'menu clearfix'; + inner.appendChild(menu); - this.log("Menu for Room: " + room_id, room); - this.track('trackEvent', 'Menu', 'Open', room_id); + var el = document.createElement('li'); + el.className = 'title'; + el.innerHTML = "FrankerFaceZ"; + menu.appendChild(el); - // Add the header and ad button. - var btn = document.createElement('a'); - btn.className = 'button glyph-only ffz-button'; - btn.title = 'Advertise for FrankerFaceZ in chat!'; - btn.href = '#'; - btn.innerHTML = ''; + el.addEventListener("click", this._add_emote.bind(this, view, "To use custom emoticons in tons of channels, get FrankerFaceZ from http://www.frankerfacez.com")); - var hdr = document.createElement('div'); - hdr.className = 'list-header first'; - hdr.appendChild(btn); - hdr.appendChild(document.createTextNode('FrankerFaceZ')); - inner.appendChild(hdr); + var sub_container = document.createElement('div'); + sub_container.className = 'ffz-ui-menu-page'; + inner.appendChild(sub_container); - var c = this._emotes_for_sets(inner, view, room && room.menu_sets || []); + for(var key in FFZ.menu_pages) { + var page = FFZ.menu_pages[key]; + if ( !page || (page.hasOwnProperty("visible") && (!page.visible || (typeof page.visible == "function" && !page.visible.bind(this)()))) ) + continue; - if ( ! this._ws_exists ) { - btn.className = "button ffz-button primary"; - btn.innerHTML = "Server Error"; - btn.title = "FFZ Server Error"; - btn.addEventListener('click', alert.bind(window, "The FrankerFaceZ client was unable to create a WebSocket to communicate with the FrankerFaceZ server.\n\nThis is most likely due to your browser's configuration either disabling WebSockets entirely or limiting the number of simultaneous connections. Please ensure that WebSockets have not been disabled.")); + var el = document.createElement('li'), + link = document.createElement('a'); - } else { - if ( c === 0 ) - btn.addEventListener('click', this._add_emote.bind(this, view, "To use custom emoticons in tons of channels, get FrankerFaceZ from http://www.frankerfacez.com")); - else - btn.addEventListener('click', this._add_emote.bind(this, view, "To view this channel's emoticons, get FrankerFaceZ from http://www.frankerfacez.com")); + el.className = 'item'; + el.id = "ffz-menu-page-" + key; + link.title = page.name; + link.innerHTML = page.icon; + + link.addEventListener("click", this._ui_change_page.bind(this, view, menu, sub_container, key)); + + el.appendChild(link); + menu.appendChild(el); } - // Feature Friday! - this._feature_friday_ui(room_id, inner, view); + // Render Current Page + this._ui_change_page(view, menu, sub_container, this._last_page || "channel"); // Add the menu to the DOM. this._popup = container; - inner.style.maxHeight = Math.max(300, view.$().height() - 171) + "px"; + sub_container.style.maxHeight = Math.max(300, view.$().height() - 212) + "px"; view.$('.chat-interface').append(container); } +FFZ.prototype._ui_change_page = function(view, menu, container, page) { + this._last_page = page; + container.innerHTML = ""; + + var els = menu.querySelectorAll('li.active'); + for(var i=0; i < els.length; i++) + els[i].classList.remove('active'); + + var el = menu.querySelector('#ffz-menu-page-' + page); + if ( el ) + el.classList.add('active'); + else + this.log("No matching page: " + page); + + FFZ.menu_pages[page].render.bind(this)(view, container); +} + + +// -------------------- +// Settings Page +// -------------------- + +FFZ.menu_pages.settings = { + render: function(view, container) { + var menu = document.createElement('div'); + menu.className = 'chat-menu-content'; + + var settings = []; + for(var key in FFZ.settings_info) + settings.push([key, FFZ.settings_info[key]]); + + settings.sort(function(a,b) { + var ai = a[1], + bi = b[1], + + an = ai.name.toLowerCase(), + bn = bi.name.toLowerCase(); + + if ( an < bn ) return -1; + else if ( an > bn ) return 1; + return 0; + }); + + + for(var i=0; i < settings.length; i++) { + var key = settings[i][0], + info = settings[i][1], + el = document.createElement('p'), + val = this.settings.get(key); + + if ( info.visible !== undefined && info.visible !== null ) { + var visible = info.visible; + if ( typeof info.visible == "function" ) + visible = info.visible.bind(this)(); + + if ( ! visible ) + continue; + } + + el.className = 'clearfix'; + + if ( info.type == "boolean" ) { + var swit = document.createElement('a'), + label = document.createElement('span'); + + swit.className = 'switch'; + swit.classList.toggle('active', val); + swit.innerHTML = ""; + + label.className = 'switch-label'; + label.innerHTML = info.name; + + el.appendChild(swit); + el.appendChild(label); + + swit.addEventListener("click", this._ui_toggle_setting.bind(this, swit, key)); + + } else { + el.classList.add("option"); + var link = document.createElement('a'); + link.innerHTML = info.name; + link.href = "#"; + el.appendChild(link); + + link.addEventListener("click", info.method.bind(this)); + } + + if ( info.help ) { + var help = document.createElement('span'); + help.className = 'help'; + help.innerHTML = info.help; + el.appendChild(help); + } + + menu.appendChild(el); + } + + container.appendChild(menu); + }, + + name: "Settings", + icon: constants.GEAR + }; + + +FFZ.prototype._ui_toggle_setting = function(swit, key) { + var val = ! this.settings.get(key); + this.settings.set(key, val); + swit.classList.toggle('active', val); +} + + +// -------------------- +// Favorites Page +// -------------------- + +/*FFZ.menu_pages.favorites = { + render: function(view, container) { + + }, + + name: "Favorites", + icon: constants.HEART + };*/ + + +// -------------------- +// Channel Page +// -------------------- + +FFZ.menu_pages.channel = { + render: function(view, inner) { + // Get the current room. + var room_id = view.get('controller.currentRoom.id'), + room = this.rooms[room_id]; + + this.log("Menu for Room: " + room_id, room); + this.track('trackEvent', 'Menu', 'Open', room_id); + + // Add the header and ad button. + /*var btn = document.createElement('a'); + btn.className = 'button glyph-only ffz-button'; + btn.title = 'Advertise for FrankerFaceZ in chat!'; + btn.href = '#'; + btn.innerHTML = ''; + + var hdr = document.createElement('div'); + hdr.className = 'list-header first'; + hdr.appendChild(btn); + hdr.appendChild(document.createTextNode('FrankerFaceZ')); + inner.appendChild(hdr);*/ + + var c = this._emotes_for_sets(inner, view, room && room.menu_sets || []); + + /*if ( ! this._ws_exists ) { + btn.className = "button ffz-button primary"; + btn.innerHTML = "Server Error"; + btn.title = "FFZ Server Error"; + btn.addEventListener('click', alert.bind(window, "The FrankerFaceZ client was unable to create a WebSocket to communicate with the FrankerFaceZ server.\n\nThis is most likely due to your browser's configuration either disabling WebSockets entirely or limiting the number of simultaneous connections. Please ensure that WebSockets have not been disabled.")); + + } else { + if ( c === 0 ) + btn.addEventListener('click', this._add_emote.bind(this, view, "To use custom emoticons in tons of channels, get FrankerFaceZ from http://www.frankerfacez.com")); + else + btn.addEventListener('click', this._add_emote.bind(this, view, "To view this channel's emoticons, get FrankerFaceZ from http://www.frankerfacez.com")); + }*/ + + // Feature Friday! + this._feature_friday_ui(room_id, inner, view); + }, + + name: "Channel", + icon: constants.ZREKNARF + }; + + // -------------------- // Emotes for Sets // -------------------- diff --git a/src/ui/notifications.js b/src/ui/notifications.js index fba726b0..4e8e9c42 100644 --- a/src/ui/notifications.js +++ b/src/ui/notifications.js @@ -1,15 +1,147 @@ var FFZ = window.FrankerFaceZ; -FFZ.prototype.show_notification = function(message) { + +// --------------------- +// Initialization +// --------------------- + +FFZ.prototype.setup_notifications = function() { + this.log("Adding event handler for window focus."); + window.addEventListener("focus", this.clear_notifications.bind(this)); +} + + +// --------------------- +// Settings +// --------------------- + +FFZ.settings_info.highlight_notifications = { + type: "boolean", + value: false, + + visible: function() { return ! this.has_bttv }, + + name: "Highlight Notifications", + help: "Display notifications when a highlighted word appears in chat in an unfocused tab.", + + on_update: function(val, direct) { + // Check to see if we have notification permission. If this is + // enabled, at least. + if ( ! val || ! direct ) + return; + + if ( Notification.permission === "denied" ) { + this.log("Notifications have been denied by the user."); + this.settings.set("highlight_notifications", false); + return; + + } else if ( Notification.permission === "granted" ) + return; + + var f = this; + Notification.requestPermission(function(e) { + if ( e === "denied" ) { + f.log("Notifications have been denied by the user."); + f.settings.set("highlight_notifications", false); + } + }); + } + }; + + +// --------------------- +// Socket Commands +// --------------------- + +FFZ.ws_commands.message = function(message) { + this.show_mesage(message); +} + + +// --------------------- +// Notifications +// --------------------- + +FFZ._notifications = {}; +FFZ._last_notification = 0; + +FFZ.prototype.clear_notifications = function() { + for(var k in FFZ._notifications) { + var n = FFZ._notifications[k]; + if ( n ) + try { + n.close(); + } catch(err) { } + } + + FFZ._notifications = {}; + FFZ._last_notification = 0; +} + + +FFZ.prototype.show_notification = function(message, title, tag, timeout, on_click, on_close) { + var perm = Notification.permission; + if ( perm === "denied " ) + return false; + + if ( perm === "granted" ) { + title = title || "FrankerFaceZ"; + timeout = timeout || 10000; + + var options = { + lang: "en-US", + dir: "ltr", + body: message, + tag: tag || "FrankerFaceZ", + icon: "http://cdn.frankerfacez.com/icon32.png" + }; + + var f = this, + n = new Notification(title, options), + nid = FFZ._last_notification++; + + FFZ._notifications[nid] = n; + + n.addEventListener("click", function() { + delete FFZ._notifications[nid]; + if ( on_click ) + on_click.bind(f)(); + }); + + n.addEventListener("close", function() { + delete FFZ._notifications[nid]; + if ( on_close ) + on_close.bind(f)(); + }); + + if ( typeof timeout == "number" ) + n.addEventListener("show", function() { + setTimeout(function() { + delete FFZ._notifications[nid]; + n.close(); + }, timeout); + }); + + return; + } + + var f = this; + Notification.requestPermission(function(e) { + f.show_notification(message, title, tag); + }); +} + + + +// --------------------- +// Noty Notification +// --------------------- + +FFZ.prototype.show_message = function(message) { window.noty({ text: message, theme: "ffzTheme", layout: "bottomCenter", closeWith: ["button"] }).show(); -} - - -FFZ.ws_commands.message = function(message) { - this.show_notification(message); } \ No newline at end of file diff --git a/src/ui/races.js b/src/ui/races.js new file mode 100644 index 00000000..d44a06ea --- /dev/null +++ b/src/ui/races.js @@ -0,0 +1,302 @@ +var FFZ = window.FrankerFaceZ, + utils = require('../utils'); + + +// --------------- +// Initialization +// --------------- + +FFZ.prototype.setup_races = function() { + this.log("Initializing race support."); + this.srl_races = {}; +} + + +// --------------- +// Settings +// --------------- + +FFZ.settings_info.srl_races = { + type: "boolean", + value: true, + name: "SRL Race Information", + help: 'Display information about SpeedRunsLive races under channels.', + on_update: function(val) { + this.rebuild_race_ui(); + } + }; + + +// --------------- +// Socket Handler +// --------------- + +FFZ.ws_on_close.push(function() { + var controller = App.__container__.lookup('controller:channel'), + current_id = controller.get('id'), + need_update = false; + + for(var chan in this.srl_races) { + delete this.srl_races[chan]; + if ( chan == current_id ) + need_update = true; + } + + if ( need_update ) + this.rebuild_race_ui(); +}); + +FFZ.ws_commands.srl_race = function(data) { + var controller = App.__container__.lookup('controller:channel'), + current_id = controller.get('id'), + need_update = false; + + for(var i=0; i < data[0].length; i++) { + var channel_id = data[0][i]; + this.srl_races[channel_id] = data[1]; + if ( channel_id == current_id ) + need_update = true; + } + + if ( data[1] ) { + var race = data[1], + tte = race.twitch_entrants = {}; + + for(var ent in race.entrants) { + if ( ! race.entrants.hasOwnProperty(ent) ) continue; + if ( race.entrants[ent].channel ) + tte[race.entrants[ent].channel] = ent; + race.entrants[ent].name = ent; + } + } + + if ( need_update ) + this.rebuild_race_ui(); +} + + +// --------------- +// Race UI +// --------------- + +FFZ.prototype.rebuild_race_ui = function() { + var controller = App.__container__.lookup('controller:channel'), + channel_id = controller.get('id'), + race = this.srl_races[channel_id], + enable_ui = this.settings.srl_races, + + actions = document.querySelector('.stats-and-actions .channel-actions'), + race_container = actions.querySelector('#ffz-ui-race'); + + if ( ! race || ! enable_ui ) { + if ( race_container ) + race_container.parentElement.removeChild(race_container); + if ( this._popup && this._popup.id == "ffz-race-popup" ) { + delete this._popup; + this._popup_kill && this._popup_kill(); + delete this._popup_kill; + } + return; + } + + if ( race_container ) + return this._update_race(true); + + race_container = document.createElement('span'); + race_container.setAttribute('data-channel', channel_id); + race_container.id = 'ffz-ui-race'; + + var btn = document.createElement('span'); + btn.className = 'button drop action'; + btn.title = "SpeedRunsLive Race"; + btn.innerHTML = '