diff --git a/scripts/communityScripts/armored-chat/armored_chat.js b/scripts/communityScripts/armored-chat/armored_chat.js index ae46f4d8f3b..7e60f275014 100644 --- a/scripts/communityScripts/armored-chat/armored_chat.js +++ b/scripts/communityScripts/armored-chat/armored_chat.js @@ -14,7 +14,8 @@ var settings = { external_window: false, maximum_messages: 200, - join_notification: true + join_notification: true, + use_chat_bubbles: true, }; // Global vars @@ -26,6 +27,7 @@ var messageHistory = Settings.getValue("ArmoredChat-Messages", []) || []; var maxLocalDistance = 20; // Maximum range for the local chat var palData = AvatarManager.getPalData().data; + var isTyping = false; Controller.keyPressEvent.connect(keyPressEvent); Messages.subscribe("Chat"); // Floofchat @@ -99,6 +101,8 @@ if (channel !== "chat") return; message = JSON.parse(message); + if (message.action !== "send_chat_message") return; + // Get the message data const currentTimestamp = _getTimestamp(); const timeArray = _formatTimestamp(currentTimestamp); @@ -124,13 +128,15 @@ _emitEvent({ type: "show_message", ...message }); // Show new message on screen - Messages.sendLocalMessage( - "Floof-Notif", - JSON.stringify({ - sender: message.displayName, - text: message.message, - }) - ); + if (message.channel !== "local" || !settings.use_chat_bubbles) { + Messages.sendLocalMessage( + "Floof-Notif", + JSON.stringify({ + sender: message.displayName, + text: message.message, + }) + ); + } // Save message to history let savedMessage = message; @@ -160,6 +166,7 @@ _sendMessage(event.message, event.channel); break; case "setting_change": + // Set the setting value, and save the config settings[event.setting] = event.value; // Update local settings _saveSettings(); // Save local settings @@ -171,6 +178,14 @@ ? Desktop.PresentationMode.NATIVE : Desktop.PresentationMode.VIRTUAL; break; + case "use_chat_bubbles": + Messages.sendLocalMessage( + "ChatBubbles-Config", + JSON.stringify({ + enabled: event.value, + }) + ); + break; } break; @@ -183,6 +198,29 @@ type: "clear_messages", }); break; + case "start_typing": + if (!isTyping) { + Messages.sendMessage( + "Chat-Typing", + JSON.stringify({ + action: "typing_start", + position: MyAvatar.position, + }) + ); + } + isTyping = true; + break; + case "end_typing": + if (isTyping) { + Messages.sendMessage( + "Chat-Typing", + JSON.stringify({ + action: "typing_stop" + }) + ); + } + isTyping = false; + break; } break; case "initialized": diff --git a/scripts/communityScripts/armored-chat/armored_chat.qml b/scripts/communityScripts/armored-chat/armored_chat.qml index aedf687d773..5725dd7d104 100644 --- a/scripts/communityScripts/armored-chat/armored_chat.qml +++ b/scripts/communityScripts/armored-chat/armored_chat.qml @@ -232,6 +232,13 @@ Rectangle { text = "" } } + onTextChanged: { + if (text === "") { + toScript({type: "action", action: "end_typing"}); + } else { + toScript({type: "action", action: "start_typing"}); + } + } onFocusChanged: { if (!HMD.active) return; if (focus) return ApplicationInterface.showVRKeyboardForHudUI(true); @@ -380,6 +387,30 @@ Rectangle { } } } + + // Chat bubbles + Rectangle { + width: parent.width + height: 40 + color: "transparent" + + Text{ + text: "In-world chat bubbles" + color: "white" + font.pointSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + + CheckBox{ + id: s_chat_bubbles + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + onCheckedChanged: { + toScript({type: 'setting_change', setting: 'use_chat_bubbles', value: checked}) + } + } + } } } @@ -610,6 +641,7 @@ Rectangle { if (message.settings.external_window) s_external_window.checked = true; if (message.settings.maximum_messages) s_maximum_messages.value = message.settings.maximum_messages; if (message.settings.join_notification) s_join_notification.checked = true; + if (message.settings.use_chat_bubbles) s_chat_bubbles.checked = true; break; } } diff --git a/scripts/communityScripts/armored-chat/armored_chat_quick_message.qml b/scripts/communityScripts/armored-chat/armored_chat_quick_message.qml index b14fd0a8117..7470af55dce 100644 --- a/scripts/communityScripts/armored-chat/armored_chat_quick_message.qml +++ b/scripts/communityScripts/armored-chat/armored_chat_quick_message.qml @@ -43,6 +43,14 @@ Item { Keys.onLeftPressed: { moveLeft(); } Keys.onRightPressed: { moveRight(); } + onTextChanged: { + if (text === "") { + toScript({type: "action", action: "end_typing"}); + } else { + toScript({type: "action", action: "start_typing"}); + } + } + function moveLeft(){ if (cursorPosition > 0){ cursorPosition-- @@ -119,4 +127,4 @@ Item { function toScript(packet){ sendToScript(packet) } -} \ No newline at end of file +} diff --git a/scripts/communityScripts/chatBubbles/assets/notify.wav b/scripts/communityScripts/chatBubbles/assets/notify.wav new file mode 100644 index 00000000000..f416c805743 Binary files /dev/null and b/scripts/communityScripts/chatBubbles/assets/notify.wav differ diff --git a/scripts/communityScripts/chatBubbles/chatBubbles.js b/scripts/communityScripts/chatBubbles/chatBubbles.js new file mode 100644 index 00000000000..1404d974243 --- /dev/null +++ b/scripts/communityScripts/chatBubbles/chatBubbles.js @@ -0,0 +1,380 @@ +// +// chatBubbles.js +// +// Created by Ada on 2025-04-19 +// Copyright 2025 Overte e.V. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +"use strict"; + +const CHAT_CHANNEL = "chat"; + +const TYPING_NOTIFICATION_CHANNEL = "Chat-Typing"; +const CONFIG_UPDATE_CHANNEL = "ChatBubbles-Config"; + +const BUBBLE_LIFETIME_SECS = 10; +const BUBBLE_FADE_TIME = 1; +const BUBBLE_ANIM_FPS = 15; +const BUBBLE_LINE_HEIGHT = 0.07; +const BUBBLE_WIDTH = 1.3; +const BUBBLE_WIDTH_MAX_CHARS = 24; // roughly 18 ems per meter +const MAX_DISTANCE = 20; +const SELF_BUBBLES = false; + +const NOTIFY_SOUND = SoundCache.getSound(Script.resolvePath("./assets/notify.wav")); + +let settings = { + enabled: true, +}; + +let currentBubbles = {}; +let typingIndicators = {}; + +// NOTE: naive wrapping algorithm that doesn't account +// for languages with non-latin scripts, though our SDF +// fonts and text renderer don't support them anyway +function ChatBubbles_WrapText(text, maxChars = BUBBLE_WIDTH_MAX_CHARS) { + // split on spaces, periods, commas, slashes, hyphens, colons, and semicolons, + // collapsing whitespace down to one space + let tokens = text.replace(/\s+/g, " ").split(/([ \.,\/\-:;])/); + let lineWidth = 0; + let lineChunk = []; + let linesAccum = []; + + for (const token of tokens) { + // the split regex sometimes produces empty space tokens too, so skip those + if (token.length < 1) { continue; } + + // this token would go over the limit, + // push the line we have and start a new one + if (lineWidth + token.length > maxChars && lineWidth !== 0) { + linesAccum.push(lineChunk.join("")); + lineChunk = []; + lineWidth = 0; + } + + // it's *still* too long for an empty line, + // so break it apart into smaller chunks + if (lineWidth + token.length > maxChars) { + // split by codepoints so we don't get orphaned UTF16 surrogates + let chars = [...token]; + let i = 0; + + while (i < chars.length) { + const token = chars.slice(i, i + maxChars).join(""); + + i += maxChars; + + // this token would go over the limit, + // push the line we have and start a new one + if (lineWidth + token.length > maxChars && lineWidth !== 0) { + linesAccum.push(lineChunk.join("")); + lineChunk = []; + lineWidth = 0; + lineChunk.push(token); + } else { + // this token will fit, so add it to the current line + lineChunk.push(token); + lineWidth += token.length; + } + } + } else { + // this token will fit, so add it to the current line + lineChunk.push(token); + lineWidth += token.length; + } + } + + // push the trailing line + linesAccum.push(lineChunk.join("")); + + return [linesAccum.join("\n"), linesAccum.length]; +} + +function ChatBubbles_SpawnBubble(data, senderID) { + // this user doesn't have a bubble stack, so add one + if (!currentBubbles[senderID]) { + currentBubbles[senderID] = {}; + } + + const scale = AvatarList.getAvatar(senderID).scale; + + let link; + let linkIsImage = false; + + // only handles cases where the whole message is just a URL, + // text with a URL in the middle is ignored + const maybeURL = data.message.trim(); + + if ( + (maybeURL.startsWith("https://") || maybeURL.startsWith("http://")) && + !/\s+/g.test(maybeURL) && + /[A-Za-z0-9-._~:/?#\[\]@!$&'()*+,;%=]+/g.test(maybeURL) + ) { + link = maybeURL; + + const chunkBeforeQuery = maybeURL.split("?", 2)[0]; + + if ( + chunkBeforeQuery.endsWith(".jpg") || + chunkBeforeQuery.endsWith(".png") || + chunkBeforeQuery.endsWith(".gif") || + chunkBeforeQuery.endsWith(".svg") || + chunkBeforeQuery.endsWith(".webp") + ) { + linkIsImage = true; + } + } + + const [text, lineCount] = ChatBubbles_WrapText(data.message); + let height = lineCount * BUBBLE_LINE_HEIGHT; + + let bubbleEntity; + if (link !== undefined && linkIsImage) { + height = BUBBLE_WIDTH / 3; + bubbleEntity = Entities.addEntity({ + type: "Image", + parentID: senderID, + imageURL: link, + emissive: true, + keepAspectRatio: true, + ignorePickIntersection: true, + dimensions: [BUBBLE_WIDTH, height, 0.01], + localPosition: [0, scale + (height / 2) + 0.1, 0], + canCastShadow: false, + billboardMode: "yaw", + grab: {grabbable: false}, + }, "local"); + } else { + bubbleEntity = Entities.addEntity({ + type: "Text", + parentID: senderID, + text: text, + unlit: true, + ignorePickIntersection: (link === undefined), + lineHeight: BUBBLE_LINE_HEIGHT, + dimensions: [BUBBLE_WIDTH, height + 0.04, 0.01], + localPosition: [0, scale + (height / 2) + 0.1, 0], + backgroundAlpha: 0.5, + textColor: (link === undefined) ? [255, 255, 255] : [128, 240, 255], + textEffect: "outline fill", + textEffectColor: "#000", + textEffectThickness: 0.4, + canCastShadow: false, + billboardMode: "yaw", + alignment: "center", + verticalAlignment: "center", + grab: {grabbable: false}, + script: (link === undefined && !linkIsImage) ? undefined : +`(function() { + this.mousePressOnEntity = function(entity, event) { + if (event.isPrimaryButton) { + const url = ${JSON.stringify(link)}; + Window.openWebBrowser(url); + } + }; +})` + }, "local"); + } + + for (const bubble of Object.values(currentBubbles[senderID])) { + let { localPosition } = Entities.getEntityProperties(bubble.entity, "localPosition"); + localPosition = Vec3.sum(localPosition, [0, height + 0.05, 0]); + Entities.editEntity(bubble.entity, { localPosition: localPosition }); + } + + let bubbleIndex = Uuid.generate(); + + let bubble = { + entity: bubbleEntity, + timeout: Script.setTimeout(() => { + let fade = 1.0; + + const fadeInterval = Script.setInterval(() => { + if (linkIsImage) { + Entities.editEntity(bubble.entity, { alpha: fade }); + } else { + Entities.editEntity(bubble.entity, { textAlpha: fade, backgroundAlpha: fade * 0.5 }); + } + fade -= (1 / BUBBLE_ANIM_FPS) / BUBBLE_FADE_TIME; + }, 1000 / BUBBLE_ANIM_FPS); + + bubble.timeout = Script.setTimeout(() => { + Script.clearInterval(fadeInterval); + Entities.deleteEntity(bubble.entity); + delete currentBubbles[senderID][bubbleIndex]; + }, BUBBLE_FADE_TIME * 1000); + }, BUBBLE_LIFETIME_SECS * 1000), + }; + + currentBubbles[senderID][bubbleIndex] = bubble; + + Audio.playSound(NOTIFY_SOUND, { + position: data.position, + volume: 0.25, + localOnly: true, + }); +} + +function ChatBubbles_IndicatorTick(senderID) { + const data = typingIndicators[senderID]; + + const lowColor = [128, 192, 192]; + const hiColor = [255, 255, 255]; + + let colorFade = 0.5 + (Math.cos(data.age / 5) * 0.5); + + Entities.editEntity(data.entity, {textColor: Vec3.mix(lowColor, hiColor, colorFade)}); + + data.age += 1; +} + +function ChatBubbles_ShowTypingIndicator(senderID) { + if (typingIndicators[senderID]) { return; } + + const scale = AvatarList.getAvatar(senderID).scale; + + const indicatorEntity = Entities.addEntity({ + type: "Text", + parentID: senderID, + text: "•••", + unlit: true, + lineHeight: 0.15, + dimensions: [0.18, 0.08, 0.01], + localPosition: [0, scale, 0], + backgroundAlpha: 0.8, + canCastShadow: false, + billboardMode: "full", + alignment: "center", + verticalAlignment: "center", + textEffect: "outline fill", + textEffectColor: "#000", + textEffectThickness: 0.3, + topMargin: -0.06, + grab: {grabbable: false}, + }, "local"); + + const indicatorInterval = Script.setInterval(() => ChatBubbles_IndicatorTick(senderID), 1000 / BUBBLE_ANIM_FPS); + + typingIndicators[senderID] = { + entity: indicatorEntity, + interval: indicatorInterval, + age: 0, + }; +} + +function ChatBubbles_HideTypingIndicator(senderID) { + const data = typingIndicators[senderID]; + + if (!data) { return; } + + Entities.deleteEntity(data.entity); + Script.clearInterval(data.interval); + delete typingIndicators[senderID]; +} + +function ChatBubbles_RecvMsg(channel, msg, senderID, localOnly) { + // IPC between ArmoredChat's config window and this script + if (channel === CONFIG_UPDATE_CHANNEL && localOnly) { + let data; + try { + data = JSON.parse(msg); + } catch (e) { + console.error(e); + return; + } + + for (const [key, value] of Object.entries(data)) { + settings[key] = value; + } + + Settings.setValue("ChatBubbles-Config", settings); + return; + } + + // not any other message we're interested in + if (channel !== CHAT_CHANNEL && channel !== TYPING_NOTIFICATION_CHANNEL) { return; } + + // don't spawn bubbles for MyAvatar if the setting is disabled + if (!SELF_BUBBLES && (senderID === MyAvatar.sessionUUID || !MyAvatar.sessionUUID)) { return; } + + let data; + try { + data = JSON.parse(msg); + } catch (e) { + console.error(e); + return; + } + + if (channel === TYPING_NOTIFICATION_CHANNEL) { + if (data.action === "typing_start") { + // don't spawn a bubble if they're too far away + if (Vec3.distance(MyAvatar.position, data.position) > MAX_DISTANCE) { return; } + ChatBubbles_ShowTypingIndicator(senderID); + } else if (data.action === "typing_stop") { + ChatBubbles_HideTypingIndicator(senderID); + } + } else if (data.action === "send_chat_message" && settings.enabled) { + // don't spawn a bubble if they're too far away + if (data.channel !== "local") { return; } + if (Vec3.distance(MyAvatar.position, data.position) > MAX_DISTANCE) { return; } + ChatBubbles_SpawnBubble(data, senderID); + } +} + +function ChatBubbles_DeleteAll() { + for (const [_, bubbleList] of Object.entries(currentBubbles)) { + for (const [id, bubble] of Object.entries(bubbleList)) { + Entities.deleteEntity(bubble.entity); + Script.clearTimeout(bubble.timeout); + delete bubbleList[id]; + } + } + + for (const [_, indicator] of Object.entries(typingIndicators)) { + Entities.deleteEntity(indicator.entity); + Script.clearInterval(indicator.interval); + } + + currentBubbles = {}; + typingIndicators = {}; +} + +function ChatBubbles_Delete(sessionID) { + const bubbleList = currentBubbles[sessionID]; + const indicator = typingIndicators[sessionID]; + + if (bubbleList) { + for (const [_, bubble] of Object.entries(bubbleList)) { + Entities.deleteEntity(bubble.entity); + Script.clearTimeout(bubble.timeout); + } + delete currentBubbles[sessionID]; + } + + if (indicator) { + Entities.deleteEntity(indicator.entity); + Script.clearInterval(indicator.interval); + delete typingIndicators[sessionID]; + } +} + +// delete any chat bubbles or typing indicators if we get disconnected +Window.domainChanged.connect(_domainURL => ChatBubbles_DeleteAll()); +Window.domainConnectionRefused.connect((_msg, _code, _info) => ChatBubbles_DeleteAll()); + +// delete the chat bubbles and typing indicators of someone who disconnects +AvatarList.avatarRemovedEvent.connect(sessionID => ChatBubbles_Delete(sessionID)); +AvatarList.avatarSessionChangedEvent.connect((_, oldSessionID) => ChatBubbles_Delete(oldSessionID)); + +settings = Settings.getValue("ChatBubbles-Config", settings); +Messages.messageReceived.connect(ChatBubbles_RecvMsg); +Messages.subscribe(TYPING_NOTIFICATION_CHANNEL); + +Script.scriptEnding.connect(() => { + Settings.setValue("ChatBubbles-Config", settings); + Messages.messageReceived.disconnect(ChatBubbles_RecvMsg); + Messages.unsubscribe(TYPING_NOTIFICATION_CHANNEL); + ChatBubbles_DeleteAll(); +}); diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js index 9559b8d570d..fb573525859 100644 --- a/scripts/defaultScripts.js +++ b/scripts/defaultScripts.js @@ -47,6 +47,7 @@ var DEFAULT_SCRIPTS_SEPARATE = [ "simplifiedUI/ui/simplifiedNametag/simplifiedNametag.js", {"stable": "system/more/app-more.js", "beta": "https://more.overte.org/more/app-more.js"}, "communityScripts/armored-chat/armored_chat.js", + "communityScripts/chatBubbles/chatBubbles.js", //"system/chat.js" ];