diff --git a/BondageClub/CSS/Styles.css b/BondageClub/CSS/Styles.css index 891e1bca3c..aff852be8d 100644 --- a/BondageClub/CSS/Styles.css +++ b/BondageClub/CSS/Styles.css @@ -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; +} \ No newline at end of file diff --git a/BondageClub/CSS/chat.css b/BondageClub/CSS/chat.css index c1c02d5815..e37c2557fe 100644 --- a/BondageClub/CSS/chat.css +++ b/BondageClub/CSS/chat.css @@ -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); } diff --git a/BondageClub/Screens/Online/ChatRoom/ChatRoom.js b/BondageClub/Screens/Online/ChatRoom/ChatRoom.js index a2e3a5f68f..0dcc1fd096 100644 --- a/BondageClub/Screens/Online/ChatRoom/ChatRoom.js +++ b/BondageClub/Screens/Online/ChatRoom/ChatRoom.js @@ -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: diff --git a/BondageClub/Screens/Online/ChatRoom/Text_ChatRoom.csv b/BondageClub/Screens/Online/ChatRoom/Text_ChatRoom.csv index 8b89733878..853521fef9 100644 --- a/BondageClub/Screens/Online/ChatRoom/Text_ChatRoom.csv +++ b/BondageClub/Screens/Online/ChatRoom/Text_ChatRoom.csv @@ -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 diff --git a/BondageClub/Scripts/Common.js b/BondageClub/Scripts/Common.js index 8ddb6f7e11..0f399ba462 100644 --- a/BondageClub/Scripts/Common.js +++ b/BondageClub/Scripts/Common.js @@ -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 diff --git a/BondageClub/Scripts/DictionaryBuilder.js b/BondageClub/Scripts/DictionaryBuilder.js index 9137b75312..5e3f19a197 100644 --- a/BondageClub/Scripts/DictionaryBuilder.js +++ b/BondageClub/Scripts/DictionaryBuilder.js @@ -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"; +} diff --git a/BondageClub/Scripts/Messages.d.ts b/BondageClub/Scripts/Messages.d.ts index 5e0146b514..2832b1cfcd 100644 --- a/BondageClub/Scripts/Messages.d.ts +++ b/BondageClub/Scripts/Messages.d.ts @@ -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[]; diff --git a/BondageClub/Scripts/Server.js b/BondageClub/Scripts/Server.js index 53d51f9937..7b38c66cf4 100644 --- a/BondageClub/Scripts/Server.js +++ b/BondageClub/Scripts/Server.js @@ -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()); } diff --git a/BondageClub/Scripts/Typedef.d.ts b/BondageClub/Scripts/Typedef.d.ts index 311a12d784..524a759954 100644 --- a/BondageClub/Scripts/Typedef.d.ts +++ b/BondageClub/Scripts/Typedef.d.ts @@ -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; + + } /**