//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 = { msBeforeDropped: 30000, //No ping for this long, and the server must be gone 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 vchatTabsVer: 1.0 //Version of vchat tabs save 'file' }; 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 lastPingReceived: 0, latency_sent: 0, //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 setInterval(check_ping, vchat_opts.msBeforeDropped); //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 reconnecting: false, //If we've lost our connection 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:not(.pm), .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:not(.pm), .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", tooltip: "Messages from the NIF itself and people inside", 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: { reconnecting: function(newSetting, oldSetting) { if(newSetting == true && oldSetting == false) { this.internal_message("Your client has lost connection to the server, or there is severe lag. Your client will reconnect if possible."); } else if (newSetting == false && oldSetting == true) { this.internal_message("Your client has reconnected to the server."); } }, //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) { return this.reconnecting ? "red" : "green"; //Standard } if (this.latency == "?") { return "grey"; } //Waiting for latency test reply else if(this.latency < 0 ) {return "red"; } else if(this.latency <= 200) { return "green"; } else if(this.latency <= 400) { return "yellow"; } else { return "grey"; } }, 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) this.load_tabs(); }, load_tabs: function() { var loadstring = get_storage("tabs") if(!loadstring) return; var loadfile = JSON.parse(loadstring); //Malformed somehow. if(!loadfile.version || !loadfile.tabs) { this.internal_message("There was a problem loading your tabs. Any new ones you make will be saved, however."); return; } //Version is old? Sorry. if(!loadfile.version == vchat_opts.vchatTabsVer) { this.internal_message("Your saved tabs are for an older version of VChat and must be recreated, sorry."); return; } this.tabs.push.apply(this.tabs, loadfile.tabs); }, save_tabs: function() { var savefile = { version: vchat_opts.vchatTabsVer, tabs: [] } //The tabs contain a bunch of vue stuff that gets funky when you try to serialize it with stringify, so we 'purify' it this.tabs.forEach(function(tab){ if(tab.immutable) return; var name = tab.name; var categories = []; tab.categories.forEach(function(category){categories.push(category);}); var cleantab = {name: name, categories: categories, immutable: false, active: false} savefile.tabs.push(cleantab); }); var savestring = JSON.stringify(savefile); set_storage("tabs", savestring); }, //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; this.save_tabs(); }, //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 fileprefix = "log"; var extension =".html"; var now = new Date(); var hours = String(now.getHours()); if(hours.length < 2) { hours = "0" + hours; } var minutes = String(now.getMinutes()); if(minutes.length < 2) { minutes = "0" + minutes; } var dayofmonth = String(now.getDate()); if(dayofmonth.length < 2) { dayofmonth = "0" + dayofmonth; } var month = String(now.getMonth()+1); //0-11 if(month.length < 2) { month = "0" + month; } var year = String(now.getFullYear()); var datesegment = " "+year+"-"+month+"-"+dayofmonth+" ("+hours+" "+minutes+")"; var filename = fileprefix+datesegment+extension; //Unlikely to work unfortunately, not supported in any version of IE, only Edge var hiddenElement = document.createElement('a'); if (hiddenElement.download !== undefined) { hiddenElement.href = 'data:attachment/text,' + encodeURI(textToSave); //Has a problem in byond 512 due to weird unicode handling hiddenElement.target = '_blank'; hiddenElement.download = filename; hiddenElement.click(); //Probably what will end up getting used } else { var blob = new Blob([textToSave], {type: 'text/html;charset=utf8;'}); saved = window.navigator.msSaveOrOpenBlob(blob, filename); } }, do_latency_test: function() { send_latency_check(); }, blur_this: function(event) { event.target.blur(); } } }); } /*********** * * Actual Methods * ************/ function check_ping() { var time_ago = Date.now() - vchat_state.lastPingReceived; if(time_ago > vchat_opts.msBeforeDropped) vueapp.reconnecting = true; } //Send a 'ping' to byond function send_latency_check() { if(vchat_state.latency_sent) return; vchat_state.latency_sent = Date.now(); vueapp.latency = "?"; push_Topic("ping"); setTimeout(function() { if(vchat_state.latency_ms == "?") { vchat_state.latency_ms = 999; } }, 1000); // 1 second to reply otherwise we mark it as bad setTimeout(function() { vchat_state.latency_sent = 0; vueapp.latency = 0; }, 5000); //5 seconds to display ping time overall } function get_latency_check() { if(!vchat_state.latency_sent) { return; //Too late } vueapp.latency = Date.now() - vchat_state.latency_sent; } //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': vchat_state.lastPingReceived = Date.now(); vueapp.reconnecting = false; break; //Response to a latency test. case 'pong': get_latency_check(); break; //The server doesn't know if we're loaded or not (we bail above if we're not, so we must be). case 'availability': push_Topic("done_loading"); 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; }