Skip to content

In-world chat bubbles and typing indicator #1418

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
65 changes: 57 additions & 8 deletions scripts/communityScripts/armored-chat/armored_chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
var messageHistory = Settings.getValue("ArmoredChat-Messages", []) || [];
var maxLocalDistance = 20; // Maximum range for the local chat
var palData = AvatarManager.getPalData().data;
var isTyping = false;
var useChatBubbles = false;

Controller.keyPressEvent.connect(keyPressEvent);
Messages.subscribe("Chat"); // Floofchat
Expand Down Expand Up @@ -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);
Expand All @@ -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" || !useChatBubbles) {
Messages.sendLocalMessage(
"Floof-Notif",
JSON.stringify({
sender: message.displayName,
text: message.message,
})
);
}

// Save message to history
let savedMessage = message;
Expand Down Expand Up @@ -160,6 +166,17 @@
_sendMessage(event.message, event.channel);
break;
case "setting_change":
if (event.setting === "worldspace_chat_bubbles") {
useChatBubbles = event.value;
Messages.sendLocalMessage(
"ChatBubbles-Config",
JSON.stringify({
enabled: event.value,
})
);
break;
}

// Set the setting value, and save the config
settings[event.setting] = event.value; // Update local settings
_saveSettings(); // Save local settings
Expand All @@ -183,6 +200,29 @@
type: "clear_messages",
});
break;
case "start_typing":
if (!isTyping) {
Messages.sendMessage(
"ChatBubbles-Typing",
JSON.stringify({
action: "typing_start",
position: MyAvatar.position,
})
);
}
isTyping = true;
break;
case "end_typing":
if (isTyping) {
Messages.sendMessage(
"ChatBubbles-Typing",
JSON.stringify({
action: "typing_stop"
})
);
}
isTyping = false;
break;
}
break;
case "initialized":
Expand Down Expand Up @@ -260,6 +300,9 @@
function _loadSettings() {
settings = Settings.getValue("ArmoredChat-Config", settings);

const chatBubbleSettings = Settings.getValue("ChatBubbles-Config", { enabled: true });
if (chatBubbleSettings.enabled) { useChatBubbles = true; }

if (messageHistory) {
// Load message history
messageHistory.forEach((message) => {
Expand All @@ -271,7 +314,13 @@
}

// Send current settings to the app
_emitEvent({ type: "initial_settings", settings: settings });
_emitEvent({
type: "initial_settings",
settings: {
worldspace_chat_bubbles: useChatBubbles,
...settings
}
});
}
function _saveSettings() {
console.log("Saving config");
Expand Down
32 changes: 32 additions & 0 deletions scripts/communityScripts/armored-chat/armored_chat.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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: 'worldspace_chat_bubbles', value: checked})
}
}
}
}
}

Expand Down Expand Up @@ -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.worldspace_chat_bubbles) s_chat_bubbles.checked = true;
break;
}
}
Expand Down
214 changes: 214 additions & 0 deletions scripts/communityScripts/chatBubbles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//
// chatBubbles.js
//
// Created by Ada <[email protected]> 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";

// can't reuse the chat channel because ArmoredChat passes
// anything on "chat" into FloofChat-Notif and throws an error
const TYPING_NOTIFICATION_CHANNEL = "ChatBubbles-Typing";
const CONFIG_UPDATE_CHANNEL = "ChatBubbles-Config";

const BUBBLE_LIFETIME_SECS = 10;
const MAX_DISTANCE = 20;
const SELF_BUBBLES = true;

let settings = {
enabled: true,
};

let currentBubbles = {};
let typingIndicators = {};

function ChatBubbles_SpawnBubble(data, senderID) {
if (currentBubbles[senderID]) {
Entities.deleteEntity(currentBubbles[senderID].entity);
Script.clearTimeout(currentBubbles[senderID].timeout);
delete currentBubbles[senderID];
}

const scale = AvatarList.getAvatar(senderID).scale;

const bubbleEntity = Entities.addEntity({
type: "Text",
parentID: senderID,
text: data.message,
unlit: true,
lineHeight: 0.07,
dimensions: [1.3, 4, 0.01],
localPosition: [0, scale + 2.1, 0],
backgroundAlpha: 0,
textEffect: "outline fill",
textEffectColor: "#000",
textEffectThickness: 0.5,
canCastShadow: false,
billboardMode: "yaw",
alignment: "center",
verticalAlignment: "bottom",
}, "local");

currentBubbles[senderID] = {
entity: bubbleEntity,
timeout: Script.setTimeout(() => {
Entities.deleteEntity(bubbleEntity);
delete currentBubbles[senderID];
}, BUBBLE_LIFETIME_SECS * 1000),
};
}

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.2,
dimensions: [0.22, 0.1, 0.01],
localPosition: [0, scale, 0],
backgroundAlpha: 0.8,
canCastShadow: false,
billboardMode: "full",
alignment: "center",
verticalAlignment: "center",
topMargin: -0.08,
}, "local");

const indicatorInterval = Script.setInterval(() => ChatBubbles_IndicatorTick(senderID), 50);

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;
}
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 [_, bubble] of Object.entries(currentBubbles)) {
Entities.deleteEntity(bubble.entity);
Script.clearTimeout(bubble.timeout);
}

for (const [_, indicator] of Object.entries(typingIndicators)) {
Entities.deleteEntity(indicator.entity);
Script.clearInterval(indicator.interval);
}

currentBubbles = {};
typingIndicators = {};
}

function ChatBubbles_Delete(sessionID) {
const bubble = currentBubbles[sessionID];
const indicator = typingIndicators[sessionID];

if (bubble) {
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();
});
1 change: 1 addition & 0 deletions scripts/defaultScripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.js",
//"system/chat.js"
];

Expand Down