diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 46cfc8415b..368535228f 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -673,6 +673,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "FORCEDENDLINE": validate_bool, "LOCALECHO": validate_bool, "TRUECOLOR": validate_bool, + "ISTYPING": validate_bool, } name = self.lhs.upper() diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index 79b240d30f..701eceab7a 100644 --- a/evennia/server/inputfuncs.py +++ b/evennia/server/inputfuncs.py @@ -25,8 +25,9 @@ from codecs import lookup as codecs_lookup from django.conf import settings -from evennia.accounts.models import AccountDB +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 @@ -181,6 +182,7 @@ _CLIENT_OPTIONS = ( "NOCOLOR", "NOGOAHEAD", "LOCALECHO", + "ISTYPING", ) @@ -207,6 +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): Toggle notifications for whether relevant players are typing """ old_flags = session.protocol_flags @@ -270,6 +273,8 @@ def client_options(session, *args, **kwargs): flags["NOGOAHEAD"] = validate_bool(value) elif key == "localecho": flags["LOCALECHO"] = validate_bool(value) + elif key == "istyping": + flags["ISTYPING"] = validate_bool(value) elif key in ( "Char 1", "Char.Skills 1", 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/server/portal/webclient.py b/evennia/server/portal/webclient.py index 1f35d473ee..e2380bcce4 100644 --- a/evennia/server/portal/webclient.py +++ b/evennia/server/portal/webclient.py @@ -138,6 +138,7 @@ class WebSocketClient(WebSocketServerProtocol, _BASE_SESSION_CLASS): self.protocol_flags["TRUECOLOR"] = True self.protocol_flags["XTERM256"] = True self.protocol_flags["ANSI"] = True + self.protocol_flags["ISTYPING"] = True # watch for dead links self.transport.setTcpKeepAlive(1) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 0371b4bd07..ad423c8685 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -460,7 +460,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 55135acc60..6ae9d929c6 100644 --- a/evennia/web/static/webclient/css/webclient.css +++ b/evennia/web/static/webclient/css/webclient.css @@ -346,6 +346,38 @@ div {margin:0px;} text-decoration: underline; } +#istyping { + position: fixed; + top: 25px; + right: 25px; + width: auto; + height: auto; + display: none; + color: white; +} + +#istypingdivider { + color: white; + border-color: white; +} + +.player-is-typing { + animation: isTyping 2s ease 0s infinite normal forwards; +} +@keyframes isTyping { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.2; + } + + 100% { + opacity: 1; + } +} + /* XTERM256 colors */ diff --git a/evennia/web/static/webclient/js/plugins/is_typing.js b/evennia/web/static/webclient/js/plugins/is_typing.js new file mode 100644 index 0000000000..44ac96131c --- /dev/null +++ b/evennia/web/static/webclient/js/plugins/is_typing.js @@ -0,0 +1,264 @@ +let is_typing = (function (){ + let Evennia; + let timeout = 0 + const state = { + timeout: null, + callback: null, + is_typing: false, + typing_players: [], + cleanup_callback: null, + } + + const sayCommands = ['say'] + + /** + * Create the containers that house our typing users + */ + const createDialog = function() { + const ele =[ + '
', + '
Who\'s typing?
', + '
', + '
', + '
' + ].join('\n') + + $('body').append(ele) + } + + const playerElement =(name)=> `
${name}
` + + /** + * 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); + + sendIsTyping() + } + + /** + * 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) + + sendIsTyping() + } + + /** + * 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; + + sendIsTyping() + } + + /** + * 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, '\\$&'); + } + + /** + * 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! + } + }) + } + + /** + * 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}) + } + + const onLoggedIn = function () { + setup() + } + + /** + * 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"); + + // 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() + + // Speaking just started. Set is_talking and timeout. + } else if (!state.is_typing) { + startedTyping(); + + // 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 diff --git a/evennia/web/templates/webclient/base.html b/evennia/web/templates/webclient/base.html index a76c45f801..7cf5a4be23 100644 --- a/evennia/web/templates/webclient/base.html +++ b/evennia/web/templates/webclient/base.html @@ -113,6 +113,8 @@ JQuery available. + +