This commit is contained in:
Michael Faith 2026-02-20 18:07:02 -08:00 committed by GitHub
commit e2266bd21c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 382 additions and 2 deletions

View file

@ -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()

View file

@ -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",

View file

@ -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},
}
)

View file

@ -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)

View file

@ -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

View file

@ -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 */

View file

@ -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 =[
'<div id="istyping" class="content">',
'<h5>Who\'s typing?</h4>',
'<hr id="istypingdivider" />',
'<div id="typingplayers"></div>',
'</div>'
].join('\n')
$('body').append(ele)
}
const playerElement =(name)=> `<div id="istyping-${name}" class="player-is-typing">${name}</div>`
/**
* 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)

View file

@ -113,6 +113,8 @@ JQuery available.
<!--
<script src={% static "webclient/js/plugins/dual_input.js" %} language="javascript" type="text/javascript"></script>
-->
<script src={% static "webclient/js/plugins/is_typing.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/default_in.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/default_out.js" %} language="javascript" type="text/javascript"></script>
<script src={% static "webclient/js/plugins/multimedia.js" %} language="javascript" type="text/javascript"></script>