//// chatBubbles.js//// Created by Ada <ada@thingvellir.net> 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//// SPDX-License-Identifier: Apache-2.0//"use strict"; const CHAT_CHANNEL = "chat"; const TYPING_NOTIFICATION_CHANNEL = "Chat-Typing";const CONFIG_UPDATE_CHANNEL = "ChatBubbles-Config"; const BUBBLE_MIN_LIFETIME_SECS = 10;const BUBBLE_MAX_LIFETIME_SECS = 60; // failsafe to prevent really long perma-messagesconst BUBBLE_LINE_LIFETIME_SECS = 3.5;const BUBBLE_IMAGE_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 = 36;const BUBBLE_BG_ALPHA = 0.6;const MAX_DISTANCE = 20;const TYPING_NOTIF_HEAD_OFFSET_HEIGHT = 0.48;const MSG_HEAD_OFFSET_HEIGHT = 0.6;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 anywayfunction 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\n]+/g, " ").match(/([^ \.,\/\-:;\n]*[ \.,\/\-:;\n]*)/g); if (!tokens) return []; // Just in case the text is empty, a space or some other non bubblable message. let lineWidth = 0; let lineChunk = []; let linesAccum = []; for (let token of tokens) { // the split regex sometimes produces empty space tokens too, so skip those if (token.length < 1) { continue; } const newLine = token.indexOf('\n') == 0; // This token is either a new line or // this token would go over the limit, // push the line we have and start a new one // (Skip blank new lines) if ((newLine || (lineWidth + token.length > maxChars)) && lineWidth !== 0) { linesAccum.push(lineChunk.join("")); lineChunk = []; lineWidth = 0; } // trim first new line character if (newLine) { token = token.slice(1); } // Look through this token for new line characters, // and split to a new line on each. let index = 0; while (index > -1) { index = token.indexOf('\n'); if (index > -1) { lineChunk.push(token.slice(0, index)); linesAccum.push(lineChunk.join("")); lineChunk = [] lineWidth = 0; token = token.slice(index+1); } } // 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_ResizeBubble(bubbleEntity, text, scale, height) { const size = Entities.textSize(bubbleEntity, text); if (size.width === 0 || size.height === 0) { // Text size cannot be calculated immediately after entity creation; // We'll keep trying until textSize does not report 0. Script.setTimeout(() => {ChatBubbles_ResizeBubble(bubbleEntity, text, scale, height)}, 100); return; } else if (size.height <= height*0.999 || size.height >= height*1.001 || size.height == null || Number.isNaN(size.height)) { // Text size returns unexpected values during entity // creation. When entity sizes are too large, too small // or invalid we ignore them. // See https://github.com/overte-org/overte/issues/1897. Script.setTimeout(() => {ChatBubbles_ResizeBubble(bubbleEntity, text, scale, height)}, 100); return; } Entities.editEntity(bubbleEntity, { visible: true, dimensions: [size.width + (0.06 * scale), size.height + (0.04 * scale), 0.01], });} function ChatBubbles_SpawnBubble(data, senderID) { // this user doesn't have a bubble stack, so add one if (!currentBubbles[senderID]) { currentBubbles[senderID] = {}; } const avatar = AvatarList.getAvatar(senderID); const scale = avatar.scale; const headIndex = avatar.getJointIndex("Head"); const headPos = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex); 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; let lifetime; if (link !== undefined && linkIsImage) { height = BUBBLE_WIDTH / 3; height *= scale; lifetime = BUBBLE_IMAGE_LIFETIME_SECS; bubbleEntity = Entities.addEntity({ type: "Image", parentID: senderID, imageURL: link, emissive: true, keepAspectRatio: true, ignorePickIntersection: true, dimensions: Vec3.multiply(scale, [BUBBLE_WIDTH, height, 0.01]), localPosition: [0, MSG_HEAD_OFFSET_HEIGHT + headPos.y + (height / 2), 0], canCastShadow: false, billboardMode: "yaw", grab: {grabbable: false}, renderLayer: "front", fadeInMode: "disabled", fadeOutMode: "disabled", }, "local"); } else { height *= scale; lifetime = BUBBLE_LINE_LIFETIME_SECS * lineCount; bubbleEntity = Entities.addEntity({ type: "Text", parentID: senderID, visible: false, text: text, unlit: true, ignorePickIntersection: (link === undefined), lineHeight: BUBBLE_LINE_HEIGHT * scale, dimensions: Vec3.multiply(scale, [BUBBLE_WIDTH, height + 0.04, 0.01]), localPosition: [0, MSG_HEAD_OFFSET_HEIGHT + headPos.y + (height / 2), 0], backgroundAlpha: BUBBLE_BG_ALPHA, 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", topMargin: -0.004 * scale, // center isn't exactly centered? grab: {grabbable: false}, renderLayer: "front", fadeInMode: "disabled", fadeOutMode: "disabled", script: (link === undefined && !linkIsImage) ? undefined :`(function() { this.mousePressOnEntity = function(entity, event) { if (event.isPrimaryButton) { const url = ${JSON.stringify(link)}; Window.openWebBrowser(url); } };})` }, "local"); Script.setTimeout(() => { ChatBubbles_ResizeBubble(bubbleEntity, text, scale, height); }, 100); // this wait time is annoyingly inconsistent, // so it needs a decent chunk of time to work properly on all messages } for (const bubble of Object.values(currentBubbles[senderID])) { let { localPosition } = Entities.getEntityProperties(bubble.entity, "localPosition"); localPosition = Vec3.sum(localPosition, [0, height + (0.05 * scale), 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 * BUBBLE_BG_ALPHA }); } 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); }, Math.min(BUBBLE_MAX_LIFETIME_SECS, Math.max(BUBBLE_MIN_LIFETIME_SECS, lifetime)) * 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 avatar = AvatarList.getAvatar(senderID); const scale = avatar.scale; const headIndex = avatar.getJointIndex("Head"); const headPos = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex); const indicatorEntity = Entities.addEntity({ type: "Text", parentID: senderID, text: "•••", unlit: true, lineHeight: 0.15 * scale, localDimensions: Vec3.multiply(scale, [0.18, 0.08, 0.01]), localPosition: [0, (TYPING_NOTIF_HEAD_OFFSET_HEIGHT * scale) + headPos.y, 0], backgroundAlpha: BUBBLE_BG_ALPHA, canCastShadow: false, billboardMode: "full", alignment: "center", verticalAlignment: "center", textEffect: "outline fill", textEffectColor: "#000", textEffectThickness: 0.3, topMargin: -0.06 * scale, grab: {grabbable: false}, ignorePickIntersection: true, renderLayer: "front", fadeInMode: "disabled", fadeOutMode: "disabled", }, "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 disconnectedWindow.domainChanged.connect(_domainURL => ChatBubbles_DeleteAll());Window.domainConnectionRefused.connect((_msg, _code, _info) => ChatBubbles_DeleteAll()); // delete the chat bubbles and typing indicators of someone who disconnectsAvatarList.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();});
//
// chatBubbles.js
//
// Created by Ada <ada@thingvellir.net> 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
//
// SPDX-License-Identifier: Apache-2.0
//
"use strict";
const CHAT_CHANNEL = "chat";
const TYPING_NOTIFICATION_CHANNEL = "Chat-Typing";
const CONFIG_UPDATE_CHANNEL = "ChatBubbles-Config";
const BUBBLE_MIN_LIFETIME_SECS = 10;
const BUBBLE_MAX_LIFETIME_SECS = 60; // failsafe to prevent really long perma-messages
const BUBBLE_LINE_LIFETIME_SECS = 3.5;
const BUBBLE_IMAGE_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 = 36;
const BUBBLE_BG_ALPHA = 0.6;
const MAX_DISTANCE = 20;
const TYPING_NOTIF_HEAD_OFFSET_HEIGHT = 0.48;
const MSG_HEAD_OFFSET_HEIGHT = 0.6;
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\n]+/g, " ").match(/([^ \.,\/\-:;\n]*[ \.,\/\-:;\n]*)/g);
if (!tokens) return []; // Just in case the text is empty, a space or some other non bubblable message.
let lineWidth = 0;
let lineChunk = [];
let linesAccum = [];
for (let token of tokens) {
// the split regex sometimes produces empty space tokens too, so skip those
if (token.length < 1) { continue; }
const newLine = token.indexOf('\n') == 0;
// This token is either a new line or
// this token would go over the limit,
// push the line we have and start a new one
// (Skip blank new lines)
if ((newLine || (lineWidth + token.length > maxChars)) && lineWidth !== 0) {
linesAccum.push(lineChunk.join(""));
lineChunk = [];
lineWidth = 0;
}
// trim first new line character
if (newLine) {
token = token.slice(1);
}
// Look through this token for new line characters,
// and split to a new line on each.
let index = 0;
while (index > -1) {
index = token.indexOf('\n');
if (index > -1) {
lineChunk.push(token.slice(0, index));
linesAccum.push(lineChunk.join(""));
lineChunk = []
lineWidth = 0;
token = token.slice(index+1);
}
}
// 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_ResizeBubble(bubbleEntity, text, scale, height) {
const size = Entities.textSize(bubbleEntity, text);
if (size.width === 0 || size.height === 0) {
// Text size cannot be calculated immediately after entity creation;
// We'll keep trying until textSize does not report 0.
Script.setTimeout(() => {ChatBubbles_ResizeBubble(bubbleEntity, text, scale, height)}, 100);
return;
} else if (size.height <= height*0.999
|| size.height >= height*1.001
|| size.height == null
|| Number.isNaN(size.height)) {
// Text size returns unexpected values during entity
// creation. When entity sizes are too large, too small
// or invalid we ignore them.
// See https://github.com/overte-org/overte/issues/1897.
Script.setTimeout(() => {ChatBubbles_ResizeBubble(bubbleEntity, text, scale, height)}, 100);
return;
}
Entities.editEntity(bubbleEntity, {
visible: true,
dimensions: [size.width + (0.06 * scale), size.height + (0.04 * scale), 0.01],
});
}
function ChatBubbles_SpawnBubble(data, senderID) {
// this user doesn't have a bubble stack, so add one
if (!currentBubbles[senderID]) {
currentBubbles[senderID] = {};
}
const avatar = AvatarList.getAvatar(senderID);
const scale = avatar.scale;
const headIndex = avatar.getJointIndex("Head");
const headPos = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex);
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;
let lifetime;
if (link !== undefined && linkIsImage) {
height = BUBBLE_WIDTH / 3;
height *= scale;
lifetime = BUBBLE_IMAGE_LIFETIME_SECS;
bubbleEntity = Entities.addEntity({
type: "Image",
parentID: senderID,
imageURL: link,
emissive: true,
keepAspectRatio: true,
ignorePickIntersection: true,
dimensions: Vec3.multiply(scale, [BUBBLE_WIDTH, height, 0.01]),
localPosition: [0, MSG_HEAD_OFFSET_HEIGHT + headPos.y + (height / 2), 0],
canCastShadow: false,
billboardMode: "yaw",
grab: {grabbable: false},
renderLayer: "front",
fadeInMode: "disabled",
fadeOutMode: "disabled",
}, "local");
} else {
height *= scale;
lifetime = BUBBLE_LINE_LIFETIME_SECS * lineCount;
bubbleEntity = Entities.addEntity({
type: "Text",
parentID: senderID,
visible: false,
text: text,
unlit: true,
ignorePickIntersection: (link === undefined),
lineHeight: BUBBLE_LINE_HEIGHT * scale,
dimensions: Vec3.multiply(scale, [BUBBLE_WIDTH, height + 0.04, 0.01]),
localPosition: [0, MSG_HEAD_OFFSET_HEIGHT + headPos.y + (height / 2), 0],
backgroundAlpha: BUBBLE_BG_ALPHA,
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",
topMargin: -0.004 * scale, // center isn't exactly centered?
grab: {grabbable: false},
renderLayer: "front",
fadeInMode: "disabled",
fadeOutMode: "disabled",
script: (link === undefined && !linkIsImage) ? undefined :
`(function() {
this.mousePressOnEntity = function(entity, event) {
if (event.isPrimaryButton) {
const url = ${JSON.stringify(link)};
Window.openWebBrowser(url);
}
};
})`
}, "local");
Script.setTimeout(() => {
ChatBubbles_ResizeBubble(bubbleEntity, text, scale, height);
}, 100);
// this wait time is annoyingly inconsistent,
// so it needs a decent chunk of time to work properly on all messages
}
for (const bubble of Object.values(currentBubbles[senderID])) {
let { localPosition } = Entities.getEntityProperties(bubble.entity, "localPosition");
localPosition = Vec3.sum(localPosition, [0, height + (0.05 * scale), 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 * BUBBLE_BG_ALPHA });
}
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);
}, Math.min(BUBBLE_MAX_LIFETIME_SECS, Math.max(BUBBLE_MIN_LIFETIME_SECS, lifetime)) * 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 avatar = AvatarList.getAvatar(senderID);
const scale = avatar.scale;
const headIndex = avatar.getJointIndex("Head");
const headPos = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex);
const indicatorEntity = Entities.addEntity({
type: "Text",
parentID: senderID,
text: "•••",
unlit: true,
lineHeight: 0.15 * scale,
localDimensions: Vec3.multiply(scale, [0.18, 0.08, 0.01]),
localPosition: [0, (TYPING_NOTIF_HEAD_OFFSET_HEIGHT * scale) + headPos.y, 0],
backgroundAlpha: BUBBLE_BG_ALPHA,
canCastShadow: false,
billboardMode: "full",
alignment: "center",
verticalAlignment: "center",
textEffect: "outline fill",
textEffectColor: "#000",
textEffectThickness: 0.3,
topMargin: -0.06 * scale,
grab: {grabbable: false},
ignorePickIntersection: true,
renderLayer: "front",
fadeInMode: "disabled",
fadeOutMode: "disabled",
}, "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();
});