diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index c836a51dc1..368535228f 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -656,6 +656,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "ENCODING": validate_encoding, "MCCP": validate_bool, "NOGOAHEAD": validate_bool, + "NOPROMPTGOAHEAD": validate_bool, "MXP": validate_bool, "NOCOLOR": validate_bool, "NOPKEEPALIVE": validate_bool, @@ -672,7 +673,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "FORCEDENDLINE": validate_bool, "LOCALECHO": validate_bool, "TRUECOLOR": validate_bool, - "ISTYPING": validate_bool + "ISTYPING": validate_bool, } name = self.lhs.upper() @@ -815,8 +816,8 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): # the slices of the ANSI_PARSER lists to use for retrieving the # relevant color tags to display. Replace if using another schema. # This command can only show one set of markup. - slice_bright_fg = slice(7, 15) # from ANSI_PARSER.ansi_map - slice_dark_fg = slice(15, 23) # from ANSI_PARSER.ansi_map + slice_bright_fg = slice(13, 21) # from ANSI_PARSER.ansi_map + slice_dark_fg = slice(21, 29) # from ANSI_PARSER.ansi_map slice_dark_bg = slice(-8, None) # from ANSI_PARSER.ansi_map slice_bright_bg = slice(None, None) # from ANSI_PARSER.ansi_xterm256_bright_bg_map @@ -840,10 +841,10 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): ) return ftable - def make_hex_color_from_column(self, column_number): - r = 255 - column_number * 255 / 76 - g = column_number * 510 / 76 - b = column_number * 255 / 76 + def make_hex_color_from_column(self, column_number, count): + r = 255 - column_number * 255 / count + g = column_number * 510 / count + b = column_number * 255 / count if g > 255: g = 510 - g @@ -933,14 +934,25 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): elif self.args.startswith("t"): # show abbreviated truecolor sample (16.7 million colors in truecolor) - string = "" - for i in range(76): - string += f"|[{self.make_hex_color_from_column(i)} |n" + string = ( + "\n" + "True Colors (if this is not a smooth rainbow transition, your client might not " + "report that it can handle truecolor): \n" + ) + display_width = self.client_width() + num_colors = display_width * 1 + color_block = [ + f"|[{self.make_hex_color_from_column(i, num_colors)} " for i in range(num_colors) + ] + color_block = [ + "".join(color_block[iline : iline + display_width]) + for iline in range(0, num_colors, display_width) + ] + string += "\n".join(color_block) string += ( - "\n" - + "some of the truecolor colors (if not all hues show, your client might not report that it can" - " handle trucolor.):" + "\n|nfg: |#FF0000||#FF0000|n (|#F00||#F00|n) to |#0000FF||#0000FF|n (|#00F||#00F|n)" + "\n|nbg: |[#FF0000||[#FF0000|n (|[#F00||[#F00|n) to |n|[#0000FF||[#0000FF |n(|[#00F||[#00F|n)" ) self.msg(string) diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index aab89c690c..08c9ef9af5 100644 --- a/evennia/server/inputfuncs.py +++ b/evennia/server/inputfuncs.py @@ -25,12 +25,11 @@ from codecs import lookup as codecs_lookup from django.conf import settings -from evennia import DefaultCharacter +from evennia import ObjectDB, DefaultCharacter from evennia.commands.cmdhandler import cmdhandler from evennia.commands.default.general import CmdSay from evennia.utils.logger import log_err -from evennia.utils.utils import to_str, delay -from evennia.scripts.tickerhandler import TICKER_HANDLER +from evennia.utils.utils import to_str BrowserSessionStore = importlib.import_module(settings.SESSION_ENGINE).SessionStore @@ -45,8 +44,6 @@ _SA = object.__setattr__ _STRIP_INCOMING_MXP = settings.MXP_ENABLED and settings.MXP_OUTGOING_ONLY _STRIP_MXP = None -_IS_TYPING_PARTICIPANTS = {} - def _NA(o): return "N/A" @@ -145,12 +142,10 @@ def echo(session, *args, **kwargs): """ Echo test function """ - # txt = kwargs.get("txt") - # - # if _STRIP_INCOMING_MXP and txt: - # txt = strip_mxp(txt) + if _STRIP_INCOMING_MXP: + args = [_maybe_strip_incoming_mxp(str(arg)) for arg in args] - session.data_out(text="Echo returns: %s" % args) + session.data_out(text=f"Echo returns: {args}, {kwargs}") def default(session, cmdname, *args, **kwargs): @@ -187,7 +182,7 @@ _CLIENT_OPTIONS = ( "NOCOLOR", "NOGOAHEAD", "LOCALECHO", - "ISTYPING" + "ISTYPING", ) @@ -214,7 +209,7 @@ def client_options(session, *args, **kwargs): nocolor (bool): Strip color raw (bool): Turn off parsing localecho (bool): Turn on server-side echo (for clients not supporting it) - istyping (bool): Turn off OOB typing notifications (currently only used by the webclient) + istyping (bool): Toggle notifications for whether relevant players are typing """ old_flags = session.protocol_flags @@ -394,6 +389,8 @@ def repeat(session, *args, **kwargs): the above settings. """ + from evennia.scripts.tickerhandler import TICKER_HANDLER + name = kwargs.get("callback", "") interval = max(5, int(kwargs.get("interval", 60))) @@ -662,119 +659,6 @@ def msdp_send(session, *args, **kwargs): session.msg(send=((), out)) -def is_typing_send_update(): - """ - Send relevant updates to participants - - """ - participants = list(_IS_TYPING_PARTICIPANTS.keys()) - - for participant in participants: - if _IS_TYPING_PARTICIPANTS[participant]["session"].puppet is not None: - - payload = [] - # Get potentials - potentials = (DefaultCharacter.objects.filter_family(db_location=_IS_TYPING_PARTICIPANTS[participant]["session"].puppet.location) - .exclude(db_key=_IS_TYPING_PARTICIPANTS[participant]["session"].puppet.key)) - - for puppet in potentials: - - # We're only interested in sending updates if they're capable of receiving them - if str(puppet.sessid) in participants: - payload.append({ - "name": puppet.name, - "state": _IS_TYPING_PARTICIPANTS[str(puppet.sessid)]['state'], - }) - - _IS_TYPING_PARTICIPANTS[participant]['session'].msg(is_typing={ - 'type': 'typing', - 'payload': payload - }) - delay(5, is_typing_send_update) - else: - del _IS_TYPING_PARTICIPANTS[str(participant)] - - if len(_IS_TYPING_PARTICIPANTS.keys()) > 0: - delay(5, is_typing_send_update) - - -def is_typing_get_aliases(session, *args, **kwargs): - """ - Used in setting up clients. Fetch list of possible "talking" triggers - - Args: - session: - *args: - **kwargs: - - Returns: - - """ - options = session.protocol_flags - istyping = options.get("ISTYPING", True) - - if not istyping: - return - - # Add the participant to the list. - _IS_TYPING_PARTICIPANTS[str(session.sessid)] = { - "state": False, - "session": session, - } - - session.msg(is_typing={'type': 'aliases', 'payload': CmdSay.aliases}) - - if len(_IS_TYPING_PARTICIPANTS.keys()) == 1: - delay(5, is_typing_send_update) - - -def is_typing_update_participant(session, *args, **kwargs): - """ - Update a participant session's typing status - - Args: - session: - *args: First argument is a boolean indicating their typing state - **kwargs: - - Returns: - - """ - options = session.protocol_flags - istyping = options.get("ISTYPING", True) - - if not istyping: - is_typing_remove_participant(session) - return - - # If the session isn't found then server restarted - if _IS_TYPING_PARTICIPANTS.get(session.sessid) is None: - _IS_TYPING_PARTICIPANTS[str(session.sessid)] = { - "session": session, - "state": args[0], - } - - if len(_IS_TYPING_PARTICIPANTS.keys()) == 1: - delay(5, is_typing_send_update) - else: - _IS_TYPING_PARTICIPANTS[str(session.sessid)]['state'] = args[0] - -def is_typing_remove_participant(session, *args, **kwargs): - """ - Handle logging out/ending a session - - Args: - session: - *args: - **kwargs: - - Returns: - - """ - if _IS_TYPING_PARTICIPANTS.get(str(session.sessid)) is not None: - del _IS_TYPING_PARTICIPANTS[str(session.sessid)] - - # client specific diff --git a/evennia/server/is_typing.py b/evennia/server/is_typing.py new file mode 100644 index 0000000000..ca06c174df --- /dev/null +++ b/evennia/server/is_typing.py @@ -0,0 +1,71 @@ +""" +This module allows users based on a given condition (defaults to same location) to see +whether applicable users are typing or not. Currently, only the webclient is supported. +""" + +from evennia import DefaultCharacter +from evennia.commands.default.general import CmdSay + +# Notification timeout in milliseconds + <=100ms polling interval in the client. +_IS_TYPING_TIMEOUT = 1000 * 5 + + +def is_typing_setup(session, *args, **kwargs): + """ + This fetches any aliases for the "say" command and the + specified notification timeout in milliseconds. + + Args: + session: The player's current session. + """ + + options = session.protocol_flags + is_typing = options.get("ISTYPING", True) + + if not is_typing: + return + + session.msg( + is_typing={ + "type": "setup", + "payload": {"say_aliases": CmdSay.aliases, "talking_timeout": _IS_TYPING_TIMEOUT}, + } + ) + + +def is_typing_state(session, *args, **kwargs): + """ + Broadcasts a typing state update from the session's puppet + to all other characters meeting the configured conditions + (defaults to same location). + + Args: + session (Session): The player's current session. + **kwargs: + - state (bool): The typing state to broadcast. + """ + options = session.protocol_flags + is_typing = options.get("ISTYPING", True) + + if not is_typing: + return + + state = kwargs.get("state") + + audience = DefaultCharacter.objects.filter_family(db_location=session.puppet.location).exclude( + db_key=session.puppet.key + ) + + for puppet in audience: + + for puppet_session in puppet.sessions.all(): + puppet_session_options = puppet_session.protocol_flags + puppet_session_is_typing = puppet_session_options.get("ISTYPING", True) + + if puppet_session_is_typing: + puppet_session.msg( + is_typing={ + "type": "typing", + "payload": {"name": session.puppet.name, "state": state}, + } + ) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index ccd3bcccc2..79e11c8ebc 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -441,7 +441,11 @@ LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs") # Module holding handlers for managing incoming data from the client. These # will be loaded in order, meaning functions in later modules may overload # previous ones if having the same name. -INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"] +INPUT_FUNC_MODULES = [ + "evennia.server.inputfuncs", + "server.conf.inputfuncs", + "evennia.server.is_typing", +] # Modules that contain prototypes for use with the spawner mechanism. PROTOTYPE_MODULES = ["world.prototypes"] # Modules containining Prototype functions able to be embedded in prototype diff --git a/evennia/web/static/webclient/css/webclient.css b/evennia/web/static/webclient/css/webclient.css index 66433c4b9b..6ae9d929c6 100644 --- a/evennia/web/static/webclient/css/webclient.css +++ b/evennia/web/static/webclient/css/webclient.css @@ -356,6 +356,11 @@ div {margin:0px;} color: white; } +#istypingdivider { + color: white; + border-color: white; +} + .player-is-typing { animation: isTyping 2s ease 0s infinite normal forwards; } diff --git a/evennia/web/static/webclient/js/plugins/is_typing.js b/evennia/web/static/webclient/js/plugins/is_typing.js index 02bebc0176..44ac96131c 100644 --- a/evennia/web/static/webclient/js/plugins/is_typing.js +++ b/evennia/web/static/webclient/js/plugins/is_typing.js @@ -1,227 +1,264 @@ -let is_typing = (function () { - let Evennia; - const timeout = 2 * 1000; - const state = { - timeout: null, - callback: null, - is_typing: false, - typing_players: [], - cleanup_callback: null, - }; - - const sayCommands = ["say"]; - - const createDialog = function () { - const ele = [ - '
', - '
', - "
", - ].join("\n"); - - $("body").append(ele); - }; - - const playerElement = (name) => - `
${name} is typing...
`; - - const startedTyping = function () { - state.is_typing = true; - state.timeout = Date.now() + timeout; - state.callback = setTimeout(stoppedTyping, timeout); - - sendIsTyping(); - }; - - const stillTyping = function () { - state.timeout = Date.now() + timeout; - clearTimeout(state.callback); - state.callback = setTimeout(stoppedTyping, timeout); - - sendIsTyping(); - }; - - const stoppedTyping = function () { - state.is_typing = false; - clearTimeout(state.callback); - state.callback = null; - state.timeout = null; - - sendIsTyping(); - }; - - // Make our commands array regex safe - const escapeRegExp = function (text) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); - }; - - // Get the say command's aliases - const requestSayAliases = function () { - Evennia.msg("is_typing_get_aliases"); - }; - - const setSayAliases = function (aliases) { - aliases.forEach((alias) => sayCommands.push(escapeRegExp(alias))); - }; - - // Update server - const sendIsTyping = function () { - Evennia.msg("is_typing_update_participant", [state.is_typing]); - }; - - const onLoggedIn = function () { - requestSayAliases(); - }; - - // Listen for talk commands - const onKeydown = function (event) { - const regex = new RegExp( - `^\W*(${sayCommands.reduce((acc, cur) => acc + "|" + cur, "").substring(1)})`, - ); - const inputfield = $(".inputfield:focus"); - - // A 'say' command is being used. - if ( - Evennia.isConnected() && - inputfield.length === 1 && - event.key.length === 1 && - inputfield.val().match(regex) - ) { - if (event.which === 13) { - // Enter. Message sent. Reset. - stoppedTyping(); - } else if (!state.is_typing) { - // Speaking just started. Set is_talking and timeout. - startedTyping(); - } else if (Date.now() > state.timeout) { - // Expiration is nearing. Update timeout. - stillTyping(); - } - // Not talking anymore but state hasn't been updated yet. - } else if (state.is_typing) { - stoppedTyping(); - } - }; - - // Reset everything - const onConnectionClose = function () { - state.is_typing = false; - state.timeout = null; - state.typing_players = []; - - if (state.callback) { - clearTimeout(state.callback); +let is_typing = (function (){ + let Evennia; + let timeout = 0 + const state = { + timeout: null, + callback: null, + is_typing: false, + typing_players: [], + cleanup_callback: null, } - if (state.cleanup_callback) { - clearTimeout(state.cleanup_callback); + const sayCommands = ['say'] + + /** + * Create the containers that house our typing users + */ + const createDialog = function() { + const ele =[ + '
', + '
Who\'s typing?
', + '
', + '
', + '
' + ].join('\n') + + $('body').append(ele) } - Evennia.msg("is_typing_remove_participant"); - }; + const playerElement =(name)=> `
${name}
` - const cleanupTimedOutPlayers = function () { - const now = Date.now(); - const timedOut = []; + /** + * The user has just started typing--set our flag, start our timeout callback, and + * let the server know + */ + const startedTyping = function () { + state.is_typing = true; + state.timeout = Date.now() + timeout; + state.callback = setTimeout(stoppedTyping, timeout); - state.typing_players.forEach((player, index) => { - if (player.timeout < now) { - timedOut.push(index); - $(`#istyping-${player.name}`).remove(); - } - }); - - timedOut - .reverse() - .forEach((index) => state.typing_players.splice(index, 1)); - - if (state.typing_players.length === 0) { - clearTimeout(state.cleanup_callback); - $("#istyping").hide(); + sendIsTyping() } - }; - const is_typing = function (args, kwargs) { - if ("type" in kwargs) { - switch (kwargs.type) { - case "aliases": - setSayAliases(kwargs.payload); - break; + /** + * The user is *still* typing--update our timeout and let the server know + */ + const stillTyping = function () { + state.timeout = Date.now() + timeout + clearTimeout(state.callback) + state.callback = setTimeout(stoppedTyping, timeout) - case "typing": - const updated_players = kwargs.payload; - let player = null; + sendIsTyping() + } - updated_players.forEach((updated) => { - player = state.typing_players.filter( - (player) => player.name === updated.name, - ); + /** + * The user has stopped typing--clean things up and tell the server + */ + const stoppedTyping = function () { + state.is_typing = false; + clearTimeout(state.callback); + state.callback = null; + state.timeout = null; - // New talker - if (updated.state && player.length === 0) { - state.typing_players.push({ - name: updated.name, - timeout: Date.now() + timeout, - }); - $("#typingplayers").append(playerElement(updated.name)); + sendIsTyping() + } - // Existing talker is still going - } else if (updated.state && player.length > 0) { - if (Date.now() - 1000 >= player.timeout) { - stillTyping(); - } + /** + * Make our commands array regex safe + * + * @param {string} - The contents of the user's command input + */ + const escapeRegExp = function (text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + } - // They're done talking - } else { - state.typing_players = state.typing_players.filter( - (player) => player.name !== updated.name, - ); - $(`#istyping-${updated.name}`).remove(); + /** + * Fetch the "say" command's aliases and the notification timeout + */ + const setup = function() { + Evennia.msg('is_typing_setup'); + } + + /** + * Add the provided aliases to the things we listen for + * + * @param {string[]} aliases - Array of "say" commands + */ + const setSayAliases = function (aliases) { + aliases.forEach(alias=>{ + const cmd = escapeRegExp(alias); + + // Is it already present? + if (sayCommands.indexOf(cmd) === -1){ + sayCommands.push(escapeRegExp(alias)); // Nope! } - }); - - if (state.typing_players.length > 0 && !state.cleanup_callback) { - state.cleanup_callback = setTimeout(cleanupTimedOutPlayers, 100); - $("#istyping").show(); - } else if ( - state.typing_players.length === 0 && - state.cleanup_callback - ) { - clearInterval(state.cleanup_callback); - state.cleanup_callback = null; - $("#istyping").hide(); - } - break; - - default: - console.log("Default case"); - console.log(args); - console.log(kwargs); - } + }) } - }; - const getState = () => state; + /** + * Sends a typing indicator to the server. + * + * @param {bool} state - The typing state, e.g., "typing" or "idle". + */ + const sendIsTyping = function () { + Evennia.msg('is_typing_state', null, {"state": state.is_typing}) + } - // - // Mandatory plugin init function - const init = function () { - let options = window.options; - options["is_typing"] = true; - Evennia = window.Evennia; + const onLoggedIn = function () { + setup() + } - Evennia.emitter.on("is_typing", is_typing); + /** + * Sends a typing indicator to the server. + * + * @param {KeyboardEvent} event - The typing state, e.g., "typing" or "idle". + */ + const onKeydown = function (event) { + const regex = new RegExp(`^\W*(${sayCommands.reduce((acc, cur)=> acc + "|" + cur, "").substring(1)})`) + const inputfield = $(".inputfield:focus"); - createDialog(); - console.log("Is Typing plugin initialized"); - }; + // A 'say' command is being used. + if (Evennia.isConnected() && + inputfield.length === 1 && + event.key.length === 1 && + inputfield.val().match(regex)) { + // Enter. Message sent. Reset. + if (event.which === 13) { + stoppedTyping() - return { - init, - onLoggedIn, - onKeydown, - onConnectionClose, - getState, - }; -})(); + // Speaking just started. Set is_talking and timeout. + } else if (!state.is_typing) { + startedTyping(); -window.plugin_handler.add("is_typing", is_typing); + // Expiration is nearing. Update timeout. + } else if (Date.now() + timeout > state.timeout) { + stillTyping(); + + } + // Not talking anymore but state hasn't been updated yet. + } else if (state.is_typing) { + stoppedTyping(); + } + } + + /** + * Reset everything to defaults. + */ + const onConnectionClose = function () { + state.is_typing = false; + state.timeout = null; + state.typing_players = [] + + if (state.callback) { + clearTimeout(state.callback) + } + + if (state.cleanup_callback) { + clearTimeout(state.cleanup_callback) + } + } + + /** + * Remove any timed out players and hide the div if no one is talking + * + */ + const cleanupTimedOutPlayers = function () { + const now = Date.now(); + const timedOut = [] + + state.typing_players.forEach((player, index)=>{ + if (player.timeout < now) { + timedOut.push(index) + $(`#istyping-${player}`).remove() + } + }) + + timedOut.reverse().forEach(index=>state.typing_players.splice(index, 1)) + + if (state.typing_players.length === 0) { + clearTimeout(state.cleanup_callback) + $('#istyping').hide(); + } + } + + /** + * This handles inbound comms from the server + * + * @param {{ + * type: string - What type of response is it? + * payload - varies with type + * }} kwargs + */ + const is_typing = function (args, kwargs) { + if ('type' in kwargs) { + switch (kwargs.type) { + case 'setup': + const {say_aliases, talking_timeout } = kwargs.payload + timeout = talking_timeout + setSayAliases(say_aliases) + break; + + case 'typing': + const player = state.typing_players.filter(player=>player.name === kwargs.payload.name) + + // New talker + if (kwargs.payload.state && + player.length === 0) { + state.typing_players.push({name: kwargs.payload.name, timeout: Date.now() + timeout}) + $('#typingplayers').append(playerElement(kwargs.payload.name)) + + // Existing talker is still going + } else if (kwargs.payload.state && + player.length > 0) { + player[0].timeout = Date.now() + timeout; + + // They're done talking + } else { + state.typing_players = state.typing_players.filter(player=>player.name!== kwargs.payload.name) + $(`#istyping-${kwargs.payload.name}`).remove() + } + + if (state.typing_players.length > 0 && !state.cleanup_callback) { + state.cleanup_callback = setTimeout(cleanupTimedOutPlayers, 100); + $('#istyping').show(); + + } else if (state.typing_players.length === 0 && state.cleanup_callback) { + clearTimeout(state.cleanup_callback) + state.cleanup_callback = null; + $('#istyping').hide(); + } + break; + + default: + console.log("is_typing: Unknown case") + console.log(args) + console.log(kwargs) + } + } + } + + const getState = () => state + + // Mandatory plugin init function + const init = function () { + let options = window.options; + options["is_typing"] = true; + Evennia = window.Evennia; + + Evennia.emitter.on("is_typing", is_typing); + + createDialog(); + + console.log('Is Typing plugin initialized'); + } + + return { + init, + onLoggedIn, + onKeydown, + onConnectionClose, + getState + } +})() + +window.plugin_handler.add("is_typing", is_typing) \ No newline at end of file