Merge branch 'feature/message-replies' into 'master'

Added the ability to reply to whispers, actions and chat messages

See merge request 
This commit is contained in:
BondageProjects 2025-03-28 01:14:28 +00:00
commit 31123d58e3
9 changed files with 323 additions and 49 deletions

View file

@ -342,3 +342,7 @@ select:invalid:not(:disabled):not(:read-only) {
scrollbar-color: rgb(149, 149, 149) rgb(0, 0, 0, 0.7);
}
}
.hidden {
display: none;
}

View file

@ -98,7 +98,25 @@
border-left: min(0.2vh, 0.1vw) inset black;
justify-self: center;
}
#chat-room-reply-indicator-text {
color: var(--base-font-color);
text-overflow: ellipsis;
text-wrap: nowrap;
overflow: hidden;
flex: 1;
line-height: 1.6em;
border: min(0.2vh, 0.1vw) solid black;
border-right: none;
user-select: none
}
#chat-room-reply-indicator:not(.hidden) {
background-color: var(--base-color, #eee);
position: absolute;
transform: translateY(-105%);
width: 100%;
display: flex;
height: 1.6em;
}
#InputChat {
grid-area: chat-input;
min-height: var(--button-size);
@ -110,7 +128,15 @@
border: unset;
outline: unset;
}
#chat-room-reply-indicator-close::before {
content: "❌";
}
#chat-room-reply-indicator-close {
background-color: var(--base-color, #eee);
border: min(0.2vh, 0.1vw) solid black;
cursor: pointer;
aspect-ratio: 1;
}
#InputChat:focus {
scrollbar-width: auto;
}
@ -237,7 +263,18 @@
background-color: var(--base-color);
}
.chat-room-message-reply::before {
content: "↱";
position: relative;
top: 4px
}
.chat-room-message-reply {
display: block;
color: gray;
width: 100%;
font-size: 0.75em;
text-align: left;
}
.ChatMessageName {
text-shadow: 0.05em 0.05em black;
color: var(--label-color);
@ -272,11 +309,15 @@
#TextAreaChatLog[data-colortheme="dark2"] {
background-color: #111;
color: #eee;
--base-color: #111;
--base-font-color: #eee;
}
#TextAreaChatLog[data-colortheme="dark"]~#chat-room-bot,
#TextAreaChatLog[data-colortheme="dark2"]~#chat-room-bot {
--button-color: #eee;
--base-color: #111;
--base-font-color: #eee;
background-color: #111;
border-color: rgba(0, 0, 0, 0.25);
color: #eee;
@ -284,6 +325,7 @@
#TextAreaChatLog[data-colortheme="dark"]~#chat-room-buttons-div,
#TextAreaChatLog[data-colortheme="dark2"]~#chat-room-buttons-div {
border-left: min(0.2vh, 0.1vw) inset rgba(0, 0, 0, 0.25);
}

View file

@ -1124,6 +1124,19 @@ function ChatRoomCreateElement() {
tag: "div",
attributes: { id: "chat-room-bot" },
children: [
{
tag: "div",
attributes: {id: "chat-room-reply-indicator"},
classList: ["hidden"],
children: [
{ tag: "span", attributes: { id: "chat-room-reply-indicator-text" }, children: [TextGet("ChatRoomReply")] },
ElementButton.Create(
"chat-room-reply-indicator-close",
ChatRoomMessageReplyStop,
{ noStyling: true },
),
],
},
{
tag: "textarea",
attributes: {
@ -1135,7 +1148,7 @@ function ChatRoomCreateElement() {
},
eventListeners: {
input: ChatRoomChatInputChangeHandler,
keyup: ChatRoomStatusUpdateTalk,
keyup: (key) => {ChatRoomStatusUpdateTalk(key); ChatRoomStopReplyOnEscape(key);},
},
},
{
@ -1972,6 +1985,15 @@ let ChatRoomStatusDeadKeys = [
"ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
];
/**
* Stops the reply when the escape key is pressed
* @param {KeyboardEvent} key
*/
function ChatRoomStopReplyOnEscape(key) {
if (key.key == "Escape") {
ChatRoomMessageReplyStop();
}
}
/**
* Sends the "Talk" status to other players if the player typed in the text box and there's a value in it
* @param {KeyboardEvent} Key
@ -3066,40 +3088,45 @@ function ChatRoomSendChat() {
* This function automatically formats and sends the message with all the information
* needed to reconstruct it on the receiver.
*
* @param {"Chat"|"Whisper"} type
* @param {"Chat"|"Whisper"|"Emote"} type
* @param {string} msg
* @param {string} [replyId]
* @return {ServerChatRoomMessage}
*/
function ChatRoomGenerateChatRoomChatMessage(type, msg) {
// Ensure the last OOC content range is closed with a )
const lastRange = SpeechGetOOCRanges(msg).pop();
if (
Player.ChatSettings.OOCAutoClose
&& lastRange !== undefined
&& msg.charAt(lastRange.start + lastRange.length - 1) !== ")"
&& lastRange.start + lastRange.length === msg.length
&& lastRange.length !== 1) {
msg += ")";
function ChatRoomGenerateChatRoomChatMessage(type, msg, replyId) {
/** @type {ChatMessageDictionary} */
const Dictionary = [];
if (type === "Chat" || type === "Whisper") {
// Ensure the last OOC content range is closed with a )
const lastRange = SpeechGetOOCRanges(msg).pop();
if (
Player.ChatSettings.OOCAutoClose
&& lastRange !== undefined
&& msg.charAt(lastRange.start + lastRange.length - 1) !== ")"
&& lastRange.start + lastRange.length === msg.length
&& lastRange.length !== 1) {
msg += ")";
}
// Chat messages are always garbled, unless the no speech restriction is lifted
let process = {
effects: [],
text: msg,
};
process = SpeechTransformProcess(Player, msg, SpeechTransformSenderEffects);
// We always garble, but if "no speech garbling" is enabled,
// we also send down the ungarbled message if it's different
const originalMsg = Player.RestrictionSettings.NoSpeechGarble && msg !== process.text ? msg : undefined;
Dictionary.push({ Effects: process.effects, Original: originalMsg });
}
// Chat messages are always garbled, unless the no speech restriction is lifted
let process = {
effects: [],
text: msg,
};
process = SpeechTransformProcess(Player, msg, SpeechTransformSenderEffects);
// We always garble, but if "no speech garbling" is enabled,
// we also send down the ungarbled message if it's different
const originalMsg = Player.RestrictionSettings.NoSpeechGarble && msg !== process.text ? msg : undefined;
/** @type {ChatMessageDictionary} */
let Dictionary = [
{ Effects: process.effects, Original: originalMsg },
];
return { Content: process.text, Type: type, Dictionary };
if (replyId) {
Dictionary.push({ ReplyId: replyId, Tag: "ReplyId" });
ChatRoomMessageReplyStop();
}
return { Content: msg, Type: type, Dictionary };
}
/**
@ -3111,8 +3138,8 @@ function ChatRoomSendChatMessage(msg) {
// Regular chat can be prevented with an owner presence rule, also validates for forbidden words
if (ChatRoomOwnerPresenceRule("BlockTalk", null)) return false;
if (!ChatRoomOwnerForbiddenWordCheck(msg)) return false;
const data = ChatRoomGenerateChatRoomChatMessage("Chat", msg);
const replyId = ChatRoomMessageGetReplyId();
const data = ChatRoomGenerateChatRoomChatMessage("Chat", msg, replyId);
ServerSend("ChatRoomChat", data);
ChatRoomStimulationMessage("Talk");
@ -3133,7 +3160,8 @@ function ChatRoomSendWhisper(targetNumber, msg) {
return "target-out-of-range";
}
const data = ChatRoomGenerateChatRoomChatMessage("Whisper", msg);
const replyId = ChatRoomMessageGetReplyId();
const data = ChatRoomGenerateChatRoomChatMessage("Whisper", msg, replyId);
data.Target = targetNumber;
ServerSend("ChatRoomChat", data);
@ -3176,7 +3204,10 @@ function ChatRoomSendEmote(msg) {
if (msg.startsWith(CommandsKey + "action ")) msg = msg.replace(CommandsKey + "action ", "*");
}
msg = msg.trim();
if (msg != "" && msg != "*") ServerSend("ChatRoomChat", { Content: msg, Type: "Emote" });
if (msg == "" || msg == "*") return;
const replyId = ChatRoomMessageGetReplyId();
const data = ChatRoomGenerateChatRoomChatMessage("Emote", msg, replyId);
ServerSend("ChatRoomChat", data);
}
/**
@ -3205,8 +3236,9 @@ function ChatRoomSendAttemptEmote(msg) {
msg += attemptSucceeded ? ": ✔" : ": ❌";
msg += " (" + chance + "%)";
ServerSend("ChatRoomChat", { Content: msg, Type: "Emote" });
const replyId = ChatRoomMessageGetReplyId();
const data = ChatRoomGenerateChatRoomChatMessage("Emote", msg, replyId);
ServerSend("ChatRoomChat", data);
return;
}
@ -3940,6 +3972,10 @@ function ChatRoomMessageDefaultMetadataExtractor(data, SenderCharacter) {
text = text.toLowerCase();
}
substitutions.push([entry.Tag, text]);
} else if (IsMsgIdDictionaryEntry(entry)) {
meta.MsgId = entry.MsgId;
} else if (IsReplyIdDictionaryEntry(entry)) {
meta.ReplyId = entry.ReplyId;
}
}
@ -4254,6 +4290,127 @@ function ChatRoomMessageNameClick() {
chatInput.value = `/whisper ${memberNumber} ${chatInput.value.replace(/\/whisper\s*\d+ ?/u, "")}`;
chatInput.focus();
}
// ----- Replies
/**
* Returns the HTML element for the message with the given ID
* @param {string} id
* @returns {HTMLElement | null}
*/
function ChatRoomMessageGetById(id) {
const chatLog = document.getElementById("TextAreaChatLog");
if (!chatLog) return null;
return chatLog.querySelector(`[msgid="${id}"]`)?.parentElement;
}
/**
* Returns the ID of the message that this message is a reply to
* @returns {string | null}
*/
function ChatRoomMessageGetReplyId() {
const chatInput = /** @type {null | HTMLTextAreaElement} */(document.getElementById("InputChat"));
if (!chatInput) return;
return chatInput.getAttribute("reply-id");
}
/**
* Returns the name of the character that this message is a reply to.
* We get the wrong name when replying to reply that's what this is for.
* @param {string} msgId
* @param {boolean} isWhisper
* @returns {string | null}
*/
function ChatRoomMessageGetReplyName(msgId, isWhisper=false) {
const message = ChatRoomMessageGetById(msgId);
if (message) {
if (isWhisper) {
const sender = Number(message.getAttribute("data-sender"));
return CharacterNickname(ChatRoomCharacter.find(C => C.MemberNumber === sender));
}
const names = message.querySelectorAll(".ChatMessageName");
const name = names[names.length - 1];
return name?.textContent;
}
return null;
}
/**
* @param {string} msgId
* @returns {string | null}
*/
function ChatRoomMessageGetReplyContent(msgId) {
const message = ChatRoomMessageGetById(msgId);
if (message) {
const contents = message.querySelectorAll(".chat-room-message-content");
const content = contents[contents.length - 1];
return content.textContent;
}
return null;
}
/**
* Figures out the type of the message with the given ID
* @param {string} msgId
* @returns {"Chat" | "Whisper"}
*/
function ChatRoomMessageGetType(msgId) {
if (!msgId) return "Chat";
if (ChatRoomMessageGetById(msgId)?.classList?.contains("ChatMessageWhisper")) return "Whisper";
return "Chat";
}
/**
* Closes the reply.
*/
function ChatRoomMessageReplyStop() {
const chatInput = /** @type {null | HTMLTextAreaElement} */(document.getElementById("InputChat"));
const replyIndicator = document.getElementById("chat-room-reply-indicator");
chatInput.removeAttribute("reply-id");
replyIndicator.classList.add("hidden");
}
/**
* Sets the reply to the message with the given ID
* @param {string} msgId
*/
function ChatRoomMessageSetReply(msgId) {
const chatInput = /** @type {null | HTMLTextAreaElement} */(document.getElementById("InputChat"));
chatInput.setAttribute("reply-id", msgId);
const replyMessage = ChatRoomMessageGetById(msgId);
const type = ChatRoomMessageGetType(msgId);
const isWhisper = type === "Whisper";
if (isWhisper) {
const receiver = Number(replyMessage.getAttribute("data-sender")) === Player.MemberNumber ? Number(replyMessage.getAttribute("data-target")) : Number(replyMessage.getAttribute("data-sender"));
chatInput.value = `/whisper ${receiver} ${chatInput.value.replace(/\/whisper\s*\d+ ?/u, "")}`;
}
const replyIndicator = document.getElementById("chat-room-reply-indicator");
const replyIndicatorText = document.getElementById("chat-room-reply-indicator-text");
const replyName = ChatRoomMessageGetReplyName(msgId, isWhisper);
replyIndicatorText.textContent = `${TextGet("ChatRoomReply")}: ${replyName && `${replyName}` || "a message"}`;
replyIndicator.classList.remove("hidden");
chatInput.focus();
}
/**
* Creates the HTML element for a reply message
* @param {string} msgId
* @param {string} displayMessage
* @returns {HTMLSpanElement | string}
*/
function ChatRoomMessageCreateReplyMessageElement(msgId, displayMessage) {
if (!msgId) {
return displayMessage;
}
return ElementCreate({
tag: "span",
classList: ["chat-room-message-content"],
attributes: { "tabindex": -1, "msgid": msgId },
children: [displayMessage],
eventListeners: {
click: (e) => {
ChatRoomMessageSetReply(msgId);
e.stopPropagation();
},
}
});
}
/**
* Update the Chat log with the recieved message
@ -4274,9 +4431,27 @@ function ChatRoomMessageDisplay(data, msg, SenderCharacter, metadata) {
const divChildren = [];
/** @type {undefined | string} */
let innerHTML = undefined;
let reply = undefined;
if (metadata.ReplyId) {
const replyMessage = ChatRoomMessageGetById(metadata.ReplyId);
const type = ChatRoomMessageGetType(metadata.ReplyId);
const isWhisper = type === "Whisper";
reply = ElementButton.Create(
null, () => {if (replyMessage) replyMessage.scrollIntoView();}, { noStyling: true },
{
button: {
classList: ["chat-room-message-reply", "truncated-text"],
attributes: { "tabindex": -1 },
style: { "--label-color": SenderCharacter.LabelColor },
children: replyMessage ? [ChatRoomMessageGetReplyName(metadata.ReplyId, isWhisper), ": ", ChatRoomMessageGetReplyContent(metadata.ReplyId)] : [TextGet("ChatRoomNoMessageFound")],
},
},
);
}
switch (data.Type) {
case "Chat":
divChildren.push(
reply,
ElementButton.Create(
null, ChatRoomMessageNameClick, { noStyling: true },
{
@ -4289,12 +4464,13 @@ function ChatRoomMessageDisplay(data, msg, SenderCharacter, metadata) {
},
),
": ",
displayMessage,
ChatRoomMessageCreateReplyMessageElement(metadata.MsgId,displayMessage)
);
break;
case "Whisper": {
const whisperTarget = SenderCharacter.IsPlayer() ? ChatRoomCharacter.find(c => c.MemberNumber == data.Target) : SenderCharacter;
divChildren.push(
reply,
ElementButton.Create(
null, ChatRoomMessageNameClick, { noStyling: true },
{ button: { classList: ["ReplyButton"], children: ["\u21a9\ufe0f"] } },
@ -4313,7 +4489,7 @@ function ChatRoomMessageDisplay(data, msg, SenderCharacter, metadata) {
},
),
": ",
displayMessage,
ChatRoomMessageCreateReplyMessageElement(metadata.MsgId,displayMessage)
);
if (!whisperTarget.IsPlayer()) {
@ -4327,7 +4503,7 @@ function ChatRoomMessageDisplay(data, msg, SenderCharacter, metadata) {
case "Action":
case "Activity":
divChildren.push(`(${displayMessage})`);
divChildren.push(reply,ChatRoomMessageCreateReplyMessageElement(metadata.MsgId,`(${displayMessage})`));
break;
case "ServerMessage":
@ -4340,7 +4516,7 @@ function ChatRoomMessageDisplay(data, msg, SenderCharacter, metadata) {
break;
case "Emote":
divChildren.push(`*${displayMessage}*`);
divChildren.push(reply,ChatRoomMessageCreateReplyMessageElement(metadata.MsgId,`*${displayMessage}*`));
break;
default:

View file

@ -34,6 +34,8 @@ ChatRoomStruggleLoosening,Loosening...
ChatRoomStruggleImpossible,Impossible!
ChatRoomStruggleLoosen,Loosen
ChatRoomStruggleGiveUp,Give up
ChatRoomReply,Replying to
ChatRoomNoMessageFound,Message could not be found.
CommandHelp,<strong>Help: KeyWord</strong>
CommandNoSuchCommand,command: no such command
CommandPrerequisiteFailed,command: prerequisite check failed

1 ActionGrabbedToServeDrinksIntro (Two maids grab you and escort you to their quarters. Another maid addresses you.) Your owner sent you here to work.
34 ChatRoomStruggleImpossible Impossible!
35 ChatRoomStruggleLoosen Loosen
36 ChatRoomStruggleGiveUp Give up
37 ChatRoomReply Replying to
38 ChatRoomNoMessageFound Message could not be found.
39 CommandHelp <strong>Help: KeyWord</strong>
40 CommandNoSuchCommand command: no such command
41 CommandPrerequisiteFailed command: prerequisite check failed

View file

@ -604,6 +604,13 @@ function CommonStringShuffle(string) {
return parts.join('');
}
/**
* Generate a unique ID
* @returns {string}
*/
function CommonGenerateUniqueID() {
return Math.random().toString(36).substring(2);
}
/**
* Converts an array to a string separated by commas (equivalent of .join(","))
* @param {readonly any[]} Arr - Array to convert to a joined string

View file

@ -439,3 +439,19 @@ function IsMessageEffectDictionaryEntry(entry) {
&& !!entry.Effects && CommonIsArray(entry.Effects)
&& (!entry.Original || typeof entry.Original === "string");
}
/**
* @param {ChatMessageDictionaryEntry} entry
* @returns {entry is ReplyIdDictionaryEntry}
*/
function IsReplyIdDictionaryEntry(entry) {
return CommonIsObject(entry) && typeof entry.ReplyId === "string";
}
/**
* @param {ChatMessageDictionaryEntry} entry
* @returns {entry is MsgIdDictionaryEntry}
*/
function IsMsgIdDictionaryEntry(entry) {
return CommonIsObject(entry) && typeof entry.MsgId === "string";
}

View file

@ -750,6 +750,20 @@ interface ActivityNameDictionaryEntry {
ActivityName: ActivityName;
}
/**
* A dictionary entry indicating the ID of a message being replied to.
*/
interface ReplyIdDictionaryEntry {
ReplyId: string;
Tag: "ReplyId";
}
/**
* A dictionary entry indicating the ID of a message.
*/
interface MsgIdDictionaryEntry {
MsgId: string;
Tag: "MsgId";
}
/**
* A dictionary entry with metadata about the chat message transmitted.
*
@ -777,7 +791,10 @@ type ChatMessageDictionaryEntry =
| ActivityCounterDictionaryEntry
| AssetGroupNameDictionaryEntry
| ActivityNameDictionaryEntry
| MessageEffectEntry;
| MessageEffectEntry
| MsgIdDictionaryEntry
| ReplyIdDictionaryEntry ;
type ChatMessageDictionary = ChatMessageDictionaryEntry[];

View file

@ -285,8 +285,6 @@ var ServerSendRateLimitInterval = 1200;
/**
* Queued messages waiting to be sent
*
* @typedef {{ Message: ClientEvent, args: ClientEventParams<ClientEvent>}} SendRateLimitQueueItem
*
* @type {SendRateLimitQueueItem[]}
*/
const ServerSendRateLimitQueue = [];
@ -302,8 +300,8 @@ let ServerSendRateLimitTimes = [];
*/
function ServerSend(Message, ...args) {
if (!ServerIsLoggedIn() && !["AccountCreate", "AccountLogin", "PasswordReset", "PasswordResetProcess"].includes(Message)) return; // We're not logged in
ServerSendRateLimitQueue.push({ Message, args });
const queueItem = /** @type {SendRateLimitQueueItem} */({ Message, args });
ServerSendRateLimitQueue.push(queueItem);
// Pump the queue manually to fight back against background tab throttling
ServerSendQueueProcess();
@ -323,6 +321,11 @@ function ServerSendQueueProcess() {
) {
const item = ServerSendRateLimitQueue.shift();
if (item) {
if (item.Message === "ChatRoomChat") {
const [data] = item.args;
data[0].Dictionary = data[0].Dictionary ?? [];
data[0].Dictionary.push({ Tag: "MsgId", MsgId: CommonGenerateUniqueID() });
}
ServerSocket.emit(item.Message, ...item.args);
ServerSendRateLimitTimes.push(Date.now());
}

View file

@ -8,7 +8,8 @@ declare function io(serv: string): SocketIO.Socket;
type ClientEvent = import("@socket.io/component-emitter").EventNames<ClientToServerEvents>;
type ClientEventParams<Ev extends ClientEvent> = import("@socket.io/component-emitter").EventParams<ClientToServerEvents, Ev>;
type _SendRateLimitQueueItem<T extends ClientEvent> = T extends ClientEvent ? { Message: T, args: ClientEventParams<T>} : never;
type SendRateLimitQueueItem = _SendRateLimitQueueItem<ClientEvent>;
interface String {
replaceAt(index: number, character: string): string;
}
@ -810,6 +811,12 @@ interface IChatRoomMessageMetadata {
ChatRoomName?: string;
/** The original, ungarbled message, if provided by the sender */
OriginalMsg?: string;
/** The ID of the message being replied to */
ReplyId?: string;
/** The ID of the message */
MsgId?: string;
}
/**