//The 'V' is for 'VORE' but you can pretend it's for Vue.js if you really want. (function(){ var oldLog = console.log; console.log = function (message) { send_debug(message); oldLog.apply(console, arguments); }; var oldError = console.error; console.error = function (message) { send_debug(message); oldError.apply(console, arguments); } window.onerror = function (message, url, line, col, error) { var stacktrace = ""; if(error && error.stack) { stacktrace = error.stack; } send_debug(message+" ("+url+"@"+line+":"+col+") "+error+"|UA: "+navigator.userAgent+"|Stack: "+stacktrace); return true; } })(); //Options for vchat var vchat_opts = { pingThisOften: 10000, //ms pingDropsAllowed: 2, cookiePrefix: "vst-", //If you're another server, you can change this if you want. alwaysShow: ["vc_looc", "vc_system"] //Categories to always display on every tab }; var DARKMODE_COLORS = { buttonBgColor: "#40628a", buttonTextColor: "#FFFFFF", windowBgColor: "#272727", highlightColor: "#009900", tabTextColor: "#FFFFFF", tabBackgroundColor: "#272727" }; var LIGHTMODE_COLORS = { buttonBgColor: "none", buttonTextColor: "#000000", windowBgColor: "none", highlightColor: "#007700", tabTextColor: "#000000", tabBackgroundColor: "none" }; /*********** * * Setup Methods * ************/ var set_storage = set_cookie; var get_storage = get_cookie; var domparser = new DOMParser(); //Upgrade to LS if (storageAvailable('localStorage')) { set_storage = set_localstorage; get_storage = get_localstorage; } //State-tracking variables var vchat_state = { ready: false, //Userinfo as reported by byond byond_ip: null, byond_cid: null, byond_ckey: null, //Ping status lastPingAttempt: 0, lastPingReply: 0, missedPings: 0, latency: 0, reconnecting: false, //Last ID lastId: 0 } function start_vchat() { //Instantiate Vue.js start_vue(); //Inform byond we're done vchat_state.ready = true; push_Topic('done_loading'); //I'll do my own winsets doWinset("htmloutput", {"is-visible": true}); doWinset("oldoutput", {"is-visible": false}); doWinset("chatloadlabel", {"is-visible": false}); //Commence the pingening send_ping(); setInterval(send_ping, vchat_opts.pingThisOften); //For fun send_debug("VChat Loaded!"); //throw new Error("VChat Loaded!"); } //Loads vue for chat usage var vueapp; function start_vue() { vueapp = new Vue({ el: '#app', data: { messages: [], //List o messages from byond shown_messages: [], //Used on filtered tabs, but not "Main" because it has 0len categories list, which bypasses filtering for speed unshown_messages: 0, //How many messages in archive would be shown but aren't archived_messages: [], //Too old to show tabs: [ //Our tabs {name: "Main", categories: [], immutable: true, active: true} ], unread_messages: {}, //Message categories that haven't been looked at since we got one of them editing: false, //If we're in settings edit mode paused: false, //Autoscrolling latency: 0, //Not necessarily network latency, since the game server has to align the responses into ticks ext_styles: "", //Styles for chat downloaded files is_admin: false, //Settings inverted: false, //Dark mode crushing: 3, //Combine similar messages animated: false, //Small CSS animations for new messages fontsize: 0.9, //Font size nudging lineheight: 130, showingnum: 200, //How many messages to show //The table to map game css classes to our vchat categories type_table: [ { matches: ".say, .emote", becomes: "vc_localchat", pretty: "Local Chat", tooltip: "In-character local messages (say, emote, etc)", required: false, admin: false }, { matches: ".alert, .syndradio, .centradio, .airadio, .entradio, .comradio, .secradio, .engradio, .medradio, .sciradio, .supradio, .srvradio, .expradio, .radio, .deptradio, .newscaster", becomes: "vc_radio", pretty: "Radio Comms", tooltip: "All departments of radio messages", required: false, admin: false }, { matches: ".notice, .adminnotice, .info, .sinister, .cult", becomes: "vc_info", pretty: "Notices", tooltip: "Non-urgent messages from the game and items", required: false, admin: false }, { matches: ".critical, .danger, .userdanger, .warning, .italics", becomes: "vc_warnings", pretty: "Warnings", tooltip: "Urgent messages from the game and items", required: false, admin: false }, { matches: ".deadsay", becomes: "vc_deadchat", pretty: "Deadchat", tooltip: "All of deadchat", required: false, admin: false }, { matches: ".ooc:not(.looc)", becomes: "vc_globalooc", pretty: "Global OOC", tooltip: "The bluewall of global OOC messages", required: false, admin: false }, //VOREStation Add Start { matches: ".nif", becomes: "vc_nif", pretty: "NIF Messages", required: false, admin: false }, //VOREStation Add End { matches: ".pm", becomes: "vc_adminpm", pretty: "Admin PMs", tooltip: "Messages to/from admins ('adminhelps')", required: false, admin: false }, { matches: ".admin_channel", becomes: "vc_adminchat", pretty: "Admin Chat", tooltip: "ASAY messages", required: false, admin: true }, { matches: ".mod_channel", becomes: "vc_modchat", pretty: "Mod Chat", tooltip: "MSAY messages", required: false, admin: true }, { matches: ".event_channel", becomes: "vc_eventchat", pretty: "Event Chat", tooltip: "ESAY messages", required: false, admin: true }, { matches: ".ooc.looc, .ooc .looc", //Dumb game becomes: "vc_looc", pretty: "Local OOC", tooltip: "Local OOC messages, always enabled", required: true }, { matches: ".boldannounce", becomes: "vc_system", pretty: "System Messages", tooltip: "Messages from your client, always enabled", required: true } ], }, mounted: function() { //Load our settings this.load_settings(); var xhr = new XMLHttpRequest(); xhr.open('GET', 'ss13styles.css'); xhr.onreadystatechange = (function() { this.ext_styles = xhr.responseText; }).bind(this); xhr.send(); }, updated: function() { if(!this.editing && !this.paused) { window.scrollTo(0,document.getElementById("messagebox").scrollHeight); } }, watch: { //Save the inverted setting to LS inverted: function (newSetting) { set_storage("darkmode",newSetting); if(newSetting) { //Special treatment for which is outside Vue's scope and has custom css document.body.classList.add("inverted"); switch_ui_mode(DARKMODE_COLORS); } else { document.body.classList.remove("inverted"); switch_ui_mode(LIGHTMODE_COLORS); } }, crushing: function (newSetting) { set_storage("crushing",newSetting); }, animated: function (newSetting) { set_storage("animated",newSetting); }, fontsize: function (newSetting, oldSetting) { if(isNaN(newSetting)) { //Numbers only this.fontsize = oldSetting; return; } if(newSetting < 0.2) { this.fontsize = 0.2; } else if(newSetting > 5) { this.fontsize = 5; } set_storage("fontsize",newSetting); }, lineheight: function (newSetting, oldSetting) { if(!isFinite(newSetting)) { //Integers only this.lineheight = oldSetting; return; } if(newSetting < 100) { this.lineheight = 100; } else if(newSetting > 200) { this.lineheight = 200; } set_storage("lineheight",newSetting); }, showingnum: function (newSetting, oldSetting) { if(!isFinite(newSetting)) { //Integers only this.showingnum = oldSetting; return; } newSetting = Math.floor(newSetting); if(newSetting < 50) { this.showingnum = 50; } else if(newSetting > 2000) { this.showingnum = 2000; } set_storage("showingnum",this.showingnum); this.attempt_archive(); }, current_categories: function(newSetting, oldSetting) { if(newSetting.length) { this.apply_filter(newSetting); } } }, computed: { //Which tab is active? active_tab: function() { //Had to polyfill this stupid .find since IE doesn't have EC6 let tab = this.tabs.find( function(tab) { return tab.active; }); return tab; }, //What color does the latency pip get? ping_classes: function() { if(this.latency === 0) { return "grey"; } else if(this.latency < 0 ) {return "red"; } else if(this.latency <= 200) { return "green"; } else if(this.latency <= 400) { return "yellow"; } else { return "red"; } }, current_categories: function() { if(this.active_tab == this.tabs[0]) { return []; //Everything, no filtering, special case for speed. } else { return this.active_tab.categories.concat(vchat_opts.alwaysShow); } } }, methods: { //Load the chat settings load_settings: function() { this.inverted = get_storage("darkmode", false); this.crushing = get_storage("crushing", 3); this.animated = get_storage("animated", false); this.fontsize = get_storage("fontsize", 0.9); this.lineheight = get_storage("lineheight", 130); this.showingnum = get_storage("showingnum", 200); if(isNaN(this.crushing)){this.crushing = 3;} //This used to be a bool (03-02-2020) if(isNaN(this.fontsize)){this.fontsize = 0.9;} //This used to be a string (03-02-2020) }, //Change to another tab switchtab: function(tab) { if(tab == this.active_tab) return; this.active_tab.active = false; tab.active = true; tab.categories.forEach( function(cls) { this.unread_messages[cls] = 0; }, this); this.apply_filter(this.current_categories); }, //Toggle edit mode editmode: function() { this.editing = !this.editing; }, //Toggle autoscroll pause: function() { this.paused = !this.paused; }, //Create a new tab (stupid lack of classes in ES5...) newtab: function() { this.tabs.push({ name: "New Tab", categories: [], immutable: false, active: false }); this.switchtab(this.tabs[this.tabs.length - 1]); }, //Rename an existing tab renametab: function() { if(this.active_tab.immutable) { return; } var tabtorename = this.active_tab; var newname = window.prompt("Type the desired tab name:", tabtorename.name); if(newname === null || newname === "" || tabtorename === null) { return; } tabtorename.name = newname; }, //Delete the currently active tab deltab: function(tab) { if(!tab) { tab = this.active_tab; } if(tab.immutable) { return; } this.switchtab(this.tabs[0]); this.tabs.splice(this.tabs.indexOf(tab), 1); }, movetab: function(tab, shift) { if(!tab || tab.immutable) { return; } var at = this.tabs.indexOf(tab); var to = at + shift; this.tabs.splice(to, 0, this.tabs.splice(at, 1)[0]); }, tab_unread_count: function(tab) { var unreads = 0; var thisum = this.unread_messages; tab.categories.find( function(cls){ if(thisum[cls]) { unreads += thisum[cls]; } }); return unreads; }, tab_unread_categories: function(tab) { var unreads = false; var thisum = this.unread_messages; tab.categories.find( function(cls){ if(thisum[cls]) { unreads = true; return true; } }); return { red: unreads, grey: !unreads}; }, attempt_archive: function() { var wiggle = 20; //Wiggle room to prevent hysterisis effects. Slice off 20 at a time. //Pushing out old messages if(this.messages.length > this.showingnum) {//Time to slice off old messages var too_old = this.messages.splice(0,wiggle); //We do a few at a time to avoid doing it too often Array.prototype.push.apply(this.archived_messages, too_old); //ES6 adds spread operator. I'd use it if I could. }/* //Pulling back old messages } else if(this.messages.length < (this.showingnum - wiggle)) { //Sigh, repopulate old messages var too_new = this.archived_messages.splice(this.messages.length - (this.showingnum - wiggle)); Array.prototype.shift.apply(this.messages, too_new); } */ }, apply_filter: function(cat_array) { //Clean up the array this.shown_messages.splice(0); this.unshown_messages = 0; //For each message, try to find it's category in the categories we're showing this.messages.forEach( function(msg){ if(cat_array.indexOf(msg.category) > -1) { //Returns the position in the array, and -1 for not found this.shown_messages.push(msg); } }, this); //For each message, try to find it's category in the categories we're showing this.archived_messages.forEach( function(msg){ if(cat_array.indexOf(msg.category) > -1) { //Returns the position in the array, and -1 for not found this.unshown_messages++; } }, this); }, //Push a new message into our array add_message: function(message) { //IE doesn't support the 'class' syntactic sugar so we're left making our own object. let newmessage = { time: message.time, category: "error", content: message.message, repeats: 1 }; //Get a category newmessage.category = this.get_category(newmessage.content); //Try to crush it with one of the last few if(this.crushing) { let crushwith = this.messages.slice(-(this.crushing)); for (let i = crushwith.length - 1; i >= 0; i--) { let oldmessage = crushwith[i]; if(oldmessage.content == newmessage.content) { newmessage.repeats += oldmessage.repeats; this.messages.splice(this.messages.indexOf(oldmessage), 1); } } } //Unread indicator and insertion into current tab shown messages if sensible if(this.current_categories.length && (this.current_categories.indexOf(newmessage.category) < 0)) { //Not in the current categories if (isNaN(this.unread_messages[newmessage.category])) { this.unread_messages[newmessage.category] = 0; } this.unread_messages[newmessage.category] += 1; } else if(this.current_categories.length) { //Is in the current categories this.shown_messages.push(newmessage); } //Append to vue's messages newmessage.id = ++vchat_state.lastId; this.attempt_archive(); this.messages.push(newmessage); }, //Push an internally generated message into our array internal_message: function(message) { let newmessage = { time: this.messages.length ? this.messages.slice(-1).time+1 : 0, category: "vc_system", content: "[VChat Internal] " + message + "" }; newmessage.id = ++vchat_state.lastId; this.messages.push(newmessage); }, on_mouseup: function(event) { // Focus map window on mouseup so hotkeys work. Exception for if they highlighted text or clicked an input. let ele = event.target; let textSelected = ('getSelection' in window) && window.getSelection().isCollapsed === false; if (!textSelected && !(ele && (ele.tagName === 'INPUT' || ele.tagName === 'TEXTAREA'))) { focusMapWindow(); // Okay focusing map window appears to prevent click event from being fired. So lets do it ourselves. event.preventDefault(); event.target.click(); } }, click_message: function(event) { let ele = event.target; if(ele.tagName === "A") { event.stopPropagation(); event.preventDefault ? event.preventDefault() : (event.returnValue = false); //The second one is the weird IE method. var href = ele.getAttribute('href'); // Gets actual href without transformation into fully qualified URL if (href[0] == '?' || (href.length >= 8 && href.substring(0,8) == "byond://")) { window.location = href; //Internal byond link } else { //It's an external link window.location = "byond://?action=openLink&link="+encodeURIComponent(href); } } }, //Derive a vchat category based on css classes get_category: function(message) { if(!vchat_state.ready) { push_Topic('not_ready'); return; } let doc = domparser.parseFromString(message, 'text/html'); let evaluating = doc.querySelector('span'); let category = "nomatch"; //What we use if the classes aren't anything we know. if(!evaluating) return category; this.type_table.find( function(type) { if(evaluating.msMatchesSelector(type.matches)) { category = type.becomes; return true; } }); return category; }, save_chatlog: function() { var textToSave = ""; var messagesToSave = this.archived_messages.concat(this.messages); messagesToSave.forEach( function(message) { textToSave += message.content; if(message.repeats > 1) { textToSave += "(x"+message.repeats+")"; } textToSave += "
\n"; }); textToSave += ""; var hiddenElement = document.createElement('a'); hiddenElement.href = 'data:attachment/text,' + encodeURI(textToSave); hiddenElement.target = '_blank'; var filename = "chat_export.html"; //Unlikely to work unfortunately, not supported in any version of IE, only Edge if (hiddenElement.download !== undefined){ hiddenElement.download = filename; hiddenElement.click(); //Probably what will end up getting used } else { let blob = new Blob([textToSave], {type: 'text/html;charset=utf8;'}); saved = window.navigator.msSaveBlob(blob, filename); } } } }); } /*********** * * Actual Methods * ************/ //Send a 'ping' to byond and check to see if we got the last one back in a timely manner function send_ping() { vchat_state.latency = (Math.min(Math.max(vchat_state.lastPingReply - vchat_state.lastPingAttempt, -1), 999)); //If their last reply was in the previous ping window or earlier. if(vchat_state.latency < 0) { vchat_state.missedPings++; if((vchat_state.missedPings >= vchat_opts.pingDropsAllowed) && !vchat_state.reconnecting) { system_message("Your client has lost connection with the server. It will reconnect automatically if possible."); vchat_state.reconnecting = true; } } vueapp.latency = vchat_state.latency; push_Topic("keepalive_client"); vchat_state.lastPingAttempt = Date.now(); } //We accept double-url-encoded JSON strings because Byond is garbage and UTF-8 encoded url_encode() text has crazy garbage in it. function byondDecode(message) { //Byond encodes spaces as pluses?! This is 1998 I guess. message = message.replace(/\+/g, "%20"); try { message = decodeURIComponent(message); } catch (err) { message = unescape(message); } return JSON.parse(message); } //This is the function byond actually communicates with using byond's client << output() method. function putmessage(messages) { messages = byondDecode(messages); if (Array.isArray(messages)) { messages.forEach(function(message) { vueapp.add_message(message); }); } else if (typeof messages === 'object') { vueapp.add_message(messages); } } //Send an internal message generated in the javascript function system_message(message) { vueapp.internal_message(message); } //This is the other direction of communication, to push a Topic message back function push_Topic(topic_uri) { window.location = '?_src_=chat&proc=' + topic_uri; //Yes that's really how it works. } //Tells byond client to focus the main map window. function focusMapWindow() { window.location = 'byond://winset?mapwindow.map.focus=true'; } //Debug event function send_debug(message) { push_Topic("debug¶m[message]="+encodeURIComponent(message)); } //A side-channel to send events over that aren't just chat messages, if necessary. function get_event(event) { if(!vchat_state.ready) { push_Topic('not_ready'); return; } var parsed_event = {evttype: 'internal_error', event: event}; parsed_event = byondDecode(event); switch(parsed_event.evttype) { //We didn't parse it very well case 'internal_error': system_message("Event parse error: " + event); break; //They provided byond data. case 'byond_player': send_client_data(); vueapp.is_admin = (parsed_event.admin === 'true'); vchat_state.byond_ip = parsed_event.address; vchat_state.byond_cid = parsed_event.cid; vchat_state.byond_ckey = parsed_event.ckey; set_storage("ip",vchat_state.byond_ip); set_storage("cid",vchat_state.byond_cid); set_storage("ckey",vchat_state.byond_ckey); break; //Just a ping. case 'keepalive_server': vchat_state.lastPingReply = Date.now(); vchat_state.missedPings = 0; reconnecting = false; break; default: system_message("Didn't know what to do with event: " + event); } } //Send information retrieved from storage function send_client_data() { let client_data = { ip: get_storage("ip"), cid: get_storage("cid"), ckey: get_storage("ckey") }; push_Topic("ident¶m[clientdata]="+JSON.stringify(client_data)); } //Newer localstorage methods function set_localstorage(key, value) { let localstorage = window.localStorage; localstorage.setItem(vchat_opts.cookiePrefix+key,value); } function get_localstorage(key, deffo) { let localstorage = window.localStorage; let value = localstorage.getItem(vchat_opts.cookiePrefix+key); //localstorage only stores strings. if(value === "null" || value === null) { value = deffo; //Coerce bools back into their native forms } else if(value === "true") { value = true; } else if(value === "false") { value = false; //Coerce numbers back into numerical form } else if(!isNaN(value)) { value = +value; } return value; } //Older cookie methods function set_cookie(key, value) { let now = new Date(); now.setFullYear(now.getFullYear() + 1); let then = now.toUTCString(); document.cookie = vchat_opts.cookiePrefix+key+"="+value+";expires="+then+";path=/"; } function get_cookie(key, deffo) { var candidates = {cookie: null, localstorage: null, indexeddb: null}; let cookie_array = document.cookie.split(';'); let cookie_object = {}; cookie_array.forEach( function(element) { let clean = element.replace(vchat_opts.cookiePrefix,"").trim(); //Strip the prefix, trim whitespace let equals = clean.search("="); //Find the equals let left = decodeURIComponent(clean.substring(0,equals)); //From start to one char before equals let right = decodeURIComponent(clean.substring(equals+1)); //From one char after equals to end //cookies only stores strings. if(right == "null" || right === null) { right = deffo; } else if(right === "true") { right = true; } else if(right === "false") { right = false; } else if(!isNaN(right)) { right = +right; } cookie_object[left] = right; //Stick into object }); candidates.cookie = cookie_object[key]; //Return value of that key in our object (or undefined) } // Button Controls that need background-color and text-color set. var SKIN_BUTTONS = [ /* Rpane */ "rpane.textb", "rpane.infob", "rpane.wikib", "rpane.forumb", "rpane.rulesb", "rpane.github", "rpane.mapb", "rpane.changelog", /* Mainwindow */ "mainwindow.saybutton", "mainwindow.mebutton", "mainwindow.hotkey_toggle" ]; // Windows or controls that need background-color set. var SKIN_ELEMENTS = [ /* Mainwindow */ "mainwindow", "mainwindow.mainvsplit", "mainwindow.tooltip", /* Rpane */ "rpane", "rpane.rpanewindow", "rpane.mediapanel", ]; function switch_ui_mode(options) { doWinset(SKIN_BUTTONS.reduce(function(params, ctl) {params[ctl + ".background-color"] = options.buttonBgColor; return params;}, {})); doWinset(SKIN_BUTTONS.reduce(function(params, ctl) {params[ctl + ".text-color"] = options.buttonTextColor; return params;}, {})); doWinset(SKIN_ELEMENTS.reduce(function(params, ctl) {params[ctl + ".background-color"] = options.windowBgColor; return params;}, {})); doWinset("infowindow", { "background-color": options.tabBackgroundColor, "text-color": options.tabTextColor }); doWinset("infowindow.info", { "background-color": options.tabBackgroundColor, "text-color": options.tabTextColor, "highlight-color": options.highlightColor, "tab-text-color": options.tabTextColor, "tab-background-color": options.tabBackgroundColor }); } function doWinset(control_id, params) { if (typeof params === 'undefined') { params = control_id; // Handle single-argument use case. control_id = null; } var url = "byond://winset?"; if (control_id) { url += ("id=" + control_id + "&"); } url += Object.keys(params).map(function(ctl) { return ctl + "=" + encodeURIComponent(params[ctl]); }).join("&"); window.location = url; }