diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py
index ee60643059..01a6b76903 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
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
@@ -650,6 +651,26 @@ def msdp_send(session, *args, **kwargs):
session.msg(send=((), out))
+def is_typing_get_aliases(session, *args, **kwargs):
+ session.msg(is_typing={'type': 'aliases', 'payload': CmdSay.aliases})
+
+
+def is_typing_state(session, *args, **kwargs):
+ # audience = ObjectDB.objects.filter(db_typeclass_path="typeclasses.characters.Character",
+ # db_location=session.puppet.location).exclude(db_key=session.puppet.key)
+
+ audience = ObjectDB.objects.filter(db_typeclass_path="typeclasses.characters.Character",
+ db_location=session.puppet.location)
+
+ for puppet in audience:
+ for puppet_session in puppet.sessions.all():
+ puppet_session.msg(is_typing={'type': 'typing',
+ 'payload': {
+ 'name': session.puppet.name,
+ 'state': args[0]
+ }})
+
+
# client specific
diff --git a/evennia/web/static/webclient/css/webclient.css b/evennia/web/static/webclient/css/webclient.css
index 55135acc60..66433c4b9b 100644
--- a/evennia/web/static/webclient/css/webclient.css
+++ b/evennia/web/static/webclient/css/webclient.css
@@ -346,6 +346,33 @@ div {margin:0px;}
text-decoration: underline;
}
+#istyping {
+ position: fixed;
+ top: 25px;
+ right: 25px;
+ width: auto;
+ height: auto;
+ display: none;
+ 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..95ae44b584
--- /dev/null
+++ b/evennia/web/static/webclient/js/plugins/is_typing.js
@@ -0,0 +1,208 @@
+let is_typing = (function (){
+ let Evennia;
+ // 10 second timeout
+ const timeout = 10 * 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_state', [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 &&
+ 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() + 5 * 1000 > state.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)
+ }
+
+ if (state.cleanup_callback) {
+ clearInterval(state.cleanup_callback)
+ }
+ }
+
+ 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) {
+ clearInterval(state.cleanup_callback)
+ $('#istyping').hide();
+ }
+ }
+
+ const is_typing = function (args, kwargs) {
+ if ('type' in kwargs) {
+ switch (kwargs.type) {
+ case 'aliases':
+ setSayAliases(kwargs.payload)
+ 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 = setInterval(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
+
+ //
+ // 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.
+
+