overte/scripts/communityScripts/chatBubbles.js

333 lines
10 KiB
JavaScript

//
// 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
"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 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;
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;
try {
const maybeURL = data.message.trim();
if (maybeURL.startsWith("https://") || maybeURL.startsWith("http://")) {
link = maybeURL;
}
} catch (e) {}
const [text, lineCount] = ChatBubbles_WrapText(data.message);
const height = lineCount * BUBBLE_LINE_HEIGHT;
const 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) ? 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(() => {
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;
}
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;
}
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();
});