diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js index 31afd6e2db..d185daadb5 100644 --- a/scripts/defaultScripts.js +++ b/scripts/defaultScripts.js @@ -46,7 +46,7 @@ var DEFAULT_SCRIPTS_SEPARATE = [ "communityScripts/notificationCore/notificationCore.js", "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", + "system/domainChat/domainChat.js", //"system/chat.js" ]; diff --git a/scripts/communityScripts/armored-chat/README.md b/scripts/system/domainChat/README.md similarity index 93% rename from scripts/communityScripts/armored-chat/README.md rename to scripts/system/domainChat/README.md index 2385494676..8ed2e8d911 100644 --- a/scripts/communityScripts/armored-chat/README.md +++ b/scripts/system/domainChat/README.md @@ -1,15 +1,15 @@ -# Armored Chat +# Domain Chat -1. What is Armored Chat +1. What is Domain Chat 2. User manual - Installation - Settings - Usability tips 3. Development -## What is Armored Chat +## What is Domain Chat -Armored Chat is a chat application strictly made to communicate between players in the same domain. It is made using QML and to be as light weight as reasonably possible. +Domain Chat is a chat application strictly made to communicate between players in the same domain. It is made using QML and to be as light weight as reasonably possible. ### Dependencies @@ -21,7 +21,7 @@ For notifications, AC uses [notificationCore.js](https://github.com/overte-org/o ### Installation -Armored Chat is preinstalled courtesy of [defaultScripts.js](https://github.com/overte-org/overte/blob/8661e8a858663b48e8485c2cd7120dc3e2d7b87e/scripts/defaultScripts.js). +Domain Chat is preinstalled courtesy of [defaultScripts.js](https://github.com/overte-org/overte/blob/8661e8a858663b48e8485c2cd7120dc3e2d7b87e/scripts/defaultScripts.js). If AC is not preinstalled, or for some other reason it can not be automatically installed, you can install it manually by following [these instructions](https://github.com/overte-org/overte/blob/8661e8a858663b48e8485c2cd7120dc3e2d7b87e/scripts/defaultScripts.js) to open your script management application, and loading the script url: @@ -33,7 +33,7 @@ https://raw.githubusercontent.com/overte-org/overte/master/scripts/communityScri ### Settings -Armored Chat comes with basic settings for managing itself. +Domain Chat comes with basic settings for managing itself. #### External window diff --git a/scripts/communityScripts/armored-chat/armored_chat.js b/scripts/system/domainChat/domainChat.js similarity index 55% rename from scripts/communityScripts/armored-chat/armored_chat.js rename to scripts/system/domainChat/domainChat.js index 779dc3ff54..74de8d1b9b 100644 --- a/scripts/communityScripts/armored-chat/armored_chat.js +++ b/scripts/system/domainChat/domainChat.js @@ -1,5 +1,5 @@ // -// armored_chat.js +// domainChat.js // // Created by Armored Dragon, 2024. // Copyright 2024 Overte e.V. @@ -10,12 +10,19 @@ (() => { ("use strict"); + Script.include([ + "./formatting.js" + ]) + var appIsVisible = false; var settings = { external_window: false, maximum_messages: 200, - join_notification: true + join_notification: true, + switchToInternalOnHeadsetUsed: true, + enableEmbedding: false // Prevents information leakage, default false }; + let temporaryChangeModeToVirtual = false; // Global vars var tablet; @@ -28,15 +35,11 @@ var palData = AvatarManager.getPalData().data; Controller.keyPressEvent.connect(keyPressEvent); - Messages.subscribe("Chat"); // Floofchat Messages.subscribe("chat"); Messages.messageReceived.connect(receivedMessage); - AvatarManager.avatarAddedEvent.connect((sessionId) => { - _avatarAction("connected", sessionId); - }); - AvatarManager.avatarRemovedEvent.connect((sessionId) => { - _avatarAction("left", sessionId); - }); + AvatarManager.avatarAddedEvent.connect((sessionId) => { _avatarAction("connected", sessionId); }); + AvatarManager.avatarRemovedEvent.connect((sessionId) => { _avatarAction("left", sessionId); }); + HMD.displayModeChanged.connect(_onHMDDisplayModeChanged); startup(); @@ -46,6 +49,7 @@ appButton = tablet.addButton({ icon: Script.resolvePath("./img/icon_white.png"), activeIcon: Script.resolvePath("./img/icon_black.png"), + sortOrder: 8, text: "CHAT", isActive: appIsVisible, }); @@ -61,7 +65,7 @@ appButton.clicked.connect(toggleMainChatWindow); quickMessage = new OverlayWindow({ - source: Script.resolvePath("./armored_chat_quick_message.qml"), + source: Script.resolvePath("./domainChatQuick.qml"), }); _openWindow(); @@ -78,7 +82,7 @@ } function _openWindow() { chatOverlayWindow = new Desktop.createWindow( - Script.resolvePath("./armored_chat.qml"), + Script.resolvePath("./domainChat.qml"), { title: "Chat", size: { x: 550, y: 400 }, @@ -92,64 +96,36 @@ chatOverlayWindow.fromQml.connect(fromQML); quickMessage.fromQml.connect(fromQML); } - function receivedMessage(channel, message) { + async function receivedMessage(channel, message) { // Is the message a chat message? channel = channel.toLowerCase(); if (channel !== "chat") return; - message = JSON.parse(message); - - // Get the message data - const currentTimestamp = _getTimestamp(); - const timeArray = _formatTimestamp(currentTimestamp); - - if (!message.channel) message.channel = "domain"; // We don't know where to put this message. Assume it is a domain wide message. - if (message.forApp) return; // Floofchat - - // Floofchat compatibility hook - message = floofChatCompatibilityConversion(message); - message.channel = message.channel.toLowerCase(); - - // Check the channel. If the channel is not one we have, do nothing. - if (!channels.includes(message.channel)) return; - - // If message is local, and if player is too far away from location, do nothing. - if (message.channel == "local" && isTooFar(message.position)) return; - - // Format the timestamp - message.timeString = timeArray[0]; - message.dateString = timeArray[1]; - - // Update qml view of to new message - _emitEvent({ type: "show_message", ...message }); - - // Show new message on screen - Messages.sendLocalMessage( - "Floof-Notif", - JSON.stringify({ - sender: message.displayName, - text: message.message, - }) - ); - - // Save message to history - let savedMessage = message; + if ((message = formatting.toJSON(message)) == null) return; // Make sure we are working with a JSON object we expect, otherwise kill + message = formatting.addTimeAndDateStringToPacket(message); + + if (!message.channel) message.channel = "domain"; // We don't know where to put this message. Assume it is a domain wide message. + message.channel = message.channel.toLowerCase(); // Only recognize channel names as lower case. + + if (!channels.includes(message.channel)) return; // Check the channel. If the channel is not one we have, do nothing. + if (message.channel == "local" && isTooFar(message.position)) return; // If message is local, and if player is too far away from location, do nothing. + + let formattedMessagePacket = { ...message }; + formattedMessagePacket.message = await formatting.parseMessage(message.message, settings.enableEmbedding) + + _emitEvent({ type: "show_message", ...formattedMessagePacket }); // Update qml view of to new message. + _notificationCoreMessage(message.displayName, message.message) // Show a new message on screen. + + // Create a new variable based on the message that will be saved. + let trimmedPacket = formatting.trimPacketToSave(message); + messageHistory.push(trimmedPacket); - // Remove unnecessary data. - delete savedMessage.position; - delete savedMessage.timeString; - delete savedMessage.dateString; - delete savedMessage.action; - - savedMessage.timestamp = currentTimestamp; - - messageHistory.push(savedMessage); while (messageHistory.length > settings.maximum_messages) { messageHistory.shift(); } Settings.setValue("ArmoredChat-Messages", messageHistory); - // Check to see if the message is close enough to the user function isTooFar(messagePosition) { + // Check to see if the message is close enough to the user return Vec3.distance(MyAvatar.position, messagePosition) > maxLocalDistance; } } @@ -160,15 +136,16 @@ break; case "setting_change": // Set the setting value, and save the config - settings[event.setting] = event.value; // Update local settings - _saveSettings(); // Save local settings + settings[event.setting] = event.value; // Update local settings + _saveSettings(); // Save local settings // Extra actions to preform. switch (event.setting) { case "external_window": - chatOverlayWindow.presentationMode = event.value - ? Desktop.PresentationMode.NATIVE - : Desktop.PresentationMode.VIRTUAL; + _changePresentationMode(event.value); + break; + case "switchToInternalOnHeadsetUsed": + _onHMDDisplayModeChanged(HMD.active); break; } @@ -202,6 +179,24 @@ }); } } + function _onHMDDisplayModeChanged(isHMDActive){ + // If the user enabled automatic switching to internal when they put on a headset... + if (!settings.switchToInternalOnHeadsetUsed) return; + + if (isHMDActive) temporaryChangeModeToVirtual = true; + else temporaryChangeModeToVirtual = false; + + _changePresentationMode(settings.external_window); + } + function _changePresentationMode(changeToExternal){ + if (temporaryChangeModeToVirtual) changeToExternal = false; + + chatOverlayWindow.presentationMode = changeToExternal + ? Desktop.PresentationMode.NATIVE + : Desktop.PresentationMode.VIRTUAL; + + console.log(`Presentation mode was changed to ${chatOverlayWindow.presentationMode}`); + } function _sendMessage(message, channel) { if (message.length == 0) return; @@ -215,11 +210,9 @@ action: "send_chat_message", }) ); - - floofChatCompatibilitySendMessage(message, channel); } function _avatarAction(type, sessionId) { - Script.setTimeout(() => { + Script.setTimeout(async () => { if (type == "connected") { palData = AvatarManager.getPalData().data; } @@ -236,101 +229,68 @@ } // Format the packet - let message = {}; - const timeArray = _formatTimestamp(_getTimestamp()); - message.timeString = timeArray[0]; - message.dateString = timeArray[1]; + let message = addTimeAndDateStringToPacket({}); message.message = `${displayName} ${type}`; // Show new message on screen if (settings.join_notification){ - Messages.sendLocalMessage( - "Floof-Notif", - JSON.stringify({ - sender: displayName, - text: type, - }) - ); + _notificationCoreMessage(displayName, type) } - _emitEvent({ type: "notification", ...message }); + // Format notification message + let formattedMessagePacket = {...message}; + formattedMessagePacket.message = await formatting.parseMessage(message.message); + + _emitEvent({ type: "notification", ...formattedMessagePacket }); }, 1500); } - function _loadSettings() { + async function _loadSettings() { settings = Settings.getValue("ArmoredChat-Config", settings); + console.log("Loading settings: ", jstr(settings)); if (messageHistory) { // Load message history - messageHistory.forEach((message) => { - const timeArray = _formatTimestamp(_getTimestamp()); - message.timeString = timeArray[0]; - message.dateString = timeArray[1]; - _emitEvent({ type: "show_message", ...message }); - }); + for (message of messageHistory) { + messagePacket = { ...message }; // Create new variable + messagePacket = formatting.addTimeAndDateStringToPacket(messagePacket); // Add timestamp + messagePacket.message = await formatting.parseMessage(messagePacket.message, settings.enableEmbedding); // Parse the message for the UI + + _emitEvent({ type: "show_message", ...messagePacket }); // Send message to UI + } } - // Send current settings to the app - _emitEvent({ type: "initial_settings", settings: settings }); + _emitEvent({ type: "initial_settings", settings: settings }); // Send current settings to the app } function _saveSettings() { - console.log("Saving config"); + console.log("Saving settings: ", jstr(settings)); Settings.setValue("ArmoredChat-Config", settings); } - function _getTimestamp(){ - return Date.now(); - } - function _formatTimestamp(timestamp){ - let timeArray = []; - - timeArray.push(new Date().toLocaleTimeString(undefined, { - hour12: false, - })); - - timeArray.push(new Date(timestamp).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - })); - - return timeArray; + function _notificationCoreMessage(displayName, message){ + console.log("Sending notification to notificationCore:", `Display name: ${displayName}\n Message: ${message}`); + Messages.sendLocalMessage( + "Floof-Notif", + JSON.stringify({ sender: displayName, text: message }) + ); } - /** * Emit a packet to the HTML front end. Easy communication! * @param {Object} packet - The Object packet to emit to the HTML * @param {("show_message"|"clear_messages"|"notification"|"initial_settings")} packet.type - The type of packet it is */ function _emitEvent(packet = { type: "" }) { - chatOverlayWindow.sendToQml(packet); - } - - // - // Floofchat compatibility functions - // Added to ease the transition between Floofchat to ArmoredChat - // These functions can be safely removed at a much later date. - function floofChatCompatibilityConversion(message) { - if (message.type === "TransmitChatMessage" && !message.forApp) { - return { - position: message.position, - message: message.message, - displayName: message.displayName, - channel: message.channel.toLowerCase(), - }; + if (packet.type == `show_message`) { + // Don't show the message contents, this is a courtesy to prevent message leakage in the logs. + let strippedPacket = {...packet}; + delete strippedPacket.message + console.log("Sending packet to QML interface", jstr(strippedPacket)); + } + else { + console.log("Sending packet to QML interface", jstr(packet)); } - return message; - } - function floofChatCompatibilitySendMessage(message, channel) { - Messages.sendMessage( - "Chat", - JSON.stringify({ - position: MyAvatar.position, - message: message, - displayName: MyAvatar.sessionDisplayName, - channel: channel.charAt(0).toUpperCase() + channel.slice(1), - type: "TransmitChatMessage", - forApp: "Floof", - }) - ); + chatOverlayWindow.sendToQml(packet); } + + // Debug and developer functions and data + const jstr = (object) => JSON.stringify(object, null, 4); // JSON Stringify function with formatting })(); diff --git a/scripts/communityScripts/armored-chat/armored_chat.qml b/scripts/system/domainChat/domainChat.qml similarity index 69% rename from scripts/communityScripts/armored-chat/armored_chat.qml rename to scripts/system/domainChat/domainChat.qml index 07eb75c626..28ec98d390 100644 --- a/scripts/communityScripts/armored-chat/armored_chat.qml +++ b/scripts/system/domainChat/domainChat.qml @@ -2,14 +2,16 @@ import QtQuick 2.7 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.3 import controlsUit 1.0 as HifiControlsUit +import "./qml_widgets" Rectangle { color: Qt.rgba(0.1,0.1,0.1,1) signal sendToScript(var message); - property string pageVal: "local" - property string last_message_user: "" - property date last_message_time: new Date() + property string pageVal: "local"; + property date last_message_time: new Date(); + property bool initialized: false; + // When the window is created on the script side, the window starts open. // Once the QML window is created wait, then send the initialized signal. @@ -162,18 +164,14 @@ Rectangle { model: getChannel(pageVal) delegate: Loader { property int delegateIndex: model.index - property string delegateText: model.text + property var delegateText: model.message property string delegateUsername: model.username property string delegateDate: model.date sourceComponent: { - if (model.type === "chat") { - return template_chat_message; - } else if (model.type === "notification") { - return template_notification; - } + if (model.type === "chat") return template_chat_message; + if (model.type === "notification") return template_notification; } - } } } @@ -191,7 +189,6 @@ Rectangle { } } - ListModel { id: local } @@ -373,180 +370,93 @@ Rectangle { } } } - } - } - - } - - // Templates - Component { - id: template_chat_message - - Rectangle { - property int index: delegateIndex - property string texttest: delegateText - property string username: delegateUsername - property string date: delegateDate - - height: Math.max(65, children[1].height + 30) - color: index % 2 === 0 ? "transparent" : Qt.rgba(0.15,0.15,0.15,1) - width: listview.parent.parent.width - Layout.fillWidth: true - - Item { - width: parent.width - 10 - anchors.horizontalCenter: parent.horizontalCenter - height: 22 + // Switch to internal on VR Mode + Rectangle { + width: parent.width + height: 40 + color: "transparent" - Text{ - text: username - color: "lightgray" - } + Text { + text: "Force Virtual window in VR" + color: "white" + font.pointSize: 12 + anchors.verticalCenter: parent.verticalCenter + } - Text{ - anchors.right: parent.right - text: date - color: "lightgray" - } - } + CheckBox { + id: s_force_vw_in_vr + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter - TextEdit { - anchors.top: parent.children[0].bottom - x: 5 - text: texttest - color:"white" - font.pointSize: 12 - readOnly: true - selectByMouse: true - selectByKeyboard: true - width: parent.width * 0.8 - height: contentHeight - wrapMode: Text.Wrap - textFormat: TextEdit.RichText - - onLinkActivated: { - Window.openWebBrowser(link) + onCheckedChanged: { + toScript({type: 'setting_change', setting: 'switchToInternalOnHeadsetUsed', value: checked}) + } + } } - } - } - } - - Component { - id: template_notification - - Rectangle{ - property int index: delegateIndex - property string texttest: delegateText - property string username: delegateUsername - property string date: delegateDate - color: "#171717" - width: parent.width - height: 40 - - Item { - width: 10 - height: parent.height - + // Toggle media embedding Rectangle { - height: parent.height - width: 5 - color: "#505186" - } - } + width: parent.width + height: 40 + color: "transparent" + Text { + text: "Enable media embedding" + color: "white" + font.pointSize: 12 + anchors.verticalCenter: parent.verticalCenter + } - Item { - width: parent.width - parent.children[0].width - 5 - height: parent.height - anchors.left: parent.children[0].right - - TextEdit{ - text: texttest - color:"white" - font.pointSize: 12 - readOnly: true - width: parent.width * 0.8 - selectByMouse: true - selectByKeyboard: true - height: parent.height - wrapMode: Text.Wrap - verticalAlignment: Text.AlignVCenter - font.italic: true - } + CheckBox { + id: s_enable_embedding + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter - Text { - text: date - color:"white" - font.pointSize: 12 - anchors.right: parent.right - height: parent.height - wrapMode: Text.Wrap - horizontalAlignment: Text.AlignRight - verticalAlignment: Text.AlignVCenter - font.italic: true + onCheckedChanged: { + toScript({type: 'setting_change', setting: 'enableEmbedding', value: checked}) + } + } } } - } } + // Templates + TemplateChatMessage { id: template_chat_message } + TemplateNotification { id: template_notification } + property var channels: { "local": local, "domain": domain, } function scrollToBottom(bypassDistanceCheck = false, extraMoveDistance = 0) { - const totalHeight = listview.height; // Total height of the content - const currentPosition = messageViewFlickable.contentY; // Current position of the view - const windowHeight = listview.parent.parent.height; // Total height of the window + const totalHeight = listview.height; // Total height of the content + const currentPosition = messageViewFlickable.contentY; // Current position of the view + const windowHeight = listview.parent.parent.height; // Total height of the window const bottomPosition = currentPosition + windowHeight; // Check if the view is within 300 units from the bottom const closeEnoughToBottom = totalHeight - bottomPosition <= 300; if (!bypassDistanceCheck && !closeEnoughToBottom) return; - if (totalHeight < windowHeight) return; // No reason to scroll, we don't have an overflow. - if (bottomPosition == totalHeight) return; // At the bottom, do nothing. + if (totalHeight < windowHeight) return; // No reason to scroll, we don't have an overflow. + if (bottomPosition == totalHeight) return; // At the bottom, do nothing. messageViewFlickable.contentY = listview.height - listview.parent.parent.height; messageViewFlickable.returnToBounds(); } - function addMessage(username, message, date, channel, type){ channel = getChannel(channel) // Format content - message = formatContent(message); - message = embedImages(message); - if (type === "notification"){ - channel.append({ text: message, date: date, type: "notification" }); - last_message_user = ""; + channel.append({ message: message, date: date, type: "notification" }); scrollToBottom(null, 30); - - last_message_time = new Date(); - return; - } - - var current_time = new Date(); - var elapsed_time = current_time - last_message_time; - var elapsed_minutes = elapsed_time / (1000 * 60); - - var last_item_index = channel.count - 1; - var last_item = channel.get(last_item_index); - - if (last_message_user === username && elapsed_minutes < 1 && last_item){ - message = "
" + message - last_item.text = last_item.text += "\n" + message; - load_scroll_timer.running = true; - last_message_time = new Date(); return; } - last_message_user = username; - last_message_time = new Date(); - channel.append({ text: message, username: username, date: date, type: type }); + channel.append({ message: message, username: username, date: date, type: type }); load_scroll_timer.running = true; } @@ -554,37 +464,6 @@ Rectangle { return channels[id]; } - function formatContent(mess) { - var arrow = /\ {return `` + match + ` 🗗`}); - - var newline = /\n/gi; - mess = mess.replace(newline, "
"); - return mess - } - - function embedImages(mess){ - var image_link = /(https?:(\/){2})[\w.-]+(?:\.[\w\.-]+)+(?:\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)(?:png|jpe?g|gif|bmp|svg|webp)/g; - var matches = mess.match(image_link); - var new_message = "" - var listed = [] - var total_emeds = 0 - - new_message += mess - - for (var i = 0; matches && matches.length > i && total_emeds < 3; i++){ - if (!listed.includes(matches[i])) { - new_message += "
" - listed.push(matches[i]); - total_emeds++ - } - } - return new_message; - } - // Messages from script function fromScript(message) { @@ -600,15 +479,23 @@ Rectangle { domain.clear(); break; case "initial_settings": + print(`Got settings:\n ${JSON.stringify(message.settings, null, 4)}`); 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.switchToInternalOnHeadsetUsed) s_force_vw_in_vr.checked = true; + if (message.settings.enableEmbedding) s_enable_embedding.checked = true; + + initialized = true; // Application is ready + break; } } // Send message to script function toScript(packet){ + if (packet.type === "setting_change" && !initialized) return; // Don't announce a change in settings if not ready + sendToScript(packet) } } diff --git a/scripts/communityScripts/armored-chat/armored_chat_quick_message.qml b/scripts/system/domainChat/domainChatQuick.qml similarity index 100% rename from scripts/communityScripts/armored-chat/armored_chat_quick_message.qml rename to scripts/system/domainChat/domainChatQuick.qml diff --git a/scripts/system/domainChat/formatting.js b/scripts/system/domainChat/formatting.js new file mode 100644 index 0000000000..c534f3a720 --- /dev/null +++ b/scripts/system/domainChat/formatting.js @@ -0,0 +1,167 @@ +// +// formatting.js +// +// Created by Armored Dragon, 2024. +// Copyright 2024 Overte e.V. +// +// This just does some basic formatting and minor housekeeping for the domainChat.js application +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html + +const formatting = { + toJSON: function(data) { + if (typeof data == "object") return data; // Already JSON + + try { + const parsedData = JSON.parse(data); + return parsedData; + } catch (e) { + console.log('Failed to convert data to JSON.') + return null; // Could not convert to json, some error; + } + }, + addTimeAndDateStringToPacket: function(packet) { + // Gets the current time and adds it to a given packet + const timeArray = formatting.helpers._timestampArray(packet.timestamp); + packet.timeString = timeArray[0]; + packet.dateString = timeArray[1]; + return packet; + }, + trimPacketToSave: function(packet) { + // Takes a packet, and returns a packet containing only what is needed to save. + let newPacket = { + channel: packet.channel || "", + displayName: packet.displayName || "", + message: packet.message || "", + timestamp: packet.timestamp || formatting.helpers.getTimestamp(), + }; + return newPacket; + }, + parseMessage: async function(message, enableEmbedding) { + const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; + const overteLocationRegex = /hifi:\/\/[a-zA-Z0-9_-]+\/[-+]?\d*\.?\d+,[+-]?\d*\.?\d+,[+-]?\d*\.?\d+\/[-+]?\d*\.?\d+,[+-]?\d*\.?\d+,[+-]?\d*\.?\d+,[+-]?\d*\.?\d+/; + + let runningMessage = message; // The remaining message that will be parsed + let messageArray = []; // An array of messages that are split up by the formatting functions + + const regexPatterns = [ + { type: "url", regex: urlRegex }, + { type: "overteLocation", regex: overteLocationRegex } + ] + + while (true) { + let firstMatch = _findFirstMatch(); + + if (firstMatch == null) { + // If there is no more text to parse, break out of the loop and return the message array. + // Format any remaining text as a basic 'text' type. + if (runningMessage.trim() != "") messageArray.push({type: 'text', value: runningMessage}); + + // Append a final 'fill width' to the message text. + messageArray.push({type: 'messageEnd'}); + break; + } + + _formatMessage(firstMatch); + } + + // Embed images in the message array. + if (enableEmbedding) { + for (dataChunk of messageArray){ + if (dataChunk.type == 'url'){ + let url = dataChunk.value; + + const res = await formatting.helpers.fetch(url, {method: 'GET'}); // TODO: Replace with 'HEAD' method. https://github.com/overte-org/overte/issues/1273 + const contentType = res.getResponseHeader("content-type"); + + if (contentType.startsWith('image/')) { + messageArray.push({type: 'imageEmbed', value: url}); + continue; + } + if (contentType.startsWith('video/')){ + messageArray.push({type: 'videoEmbed', value: url}); + continue; + } + } + } + } + + return messageArray; + + function _formatMessage(firstMatch){ + let indexOfFirstMatch = firstMatch[0]; + let regex = regexPatterns[firstMatch[1]].regex; + + let foundMatch = runningMessage.match(regex)[0]; + + if (runningMessage.substring(0, indexOfFirstMatch) != "") messageArray.push({type: 'text', value: runningMessage.substring(0, indexOfFirstMatch)}); + messageArray.push({type: regexPatterns[firstMatch[1]].type, value: runningMessage.substring(indexOfFirstMatch, indexOfFirstMatch + foundMatch.length)}); + + runningMessage = runningMessage.substring(indexOfFirstMatch + foundMatch.length); // Remove the part of the message we have worked with + } + + function _findFirstMatch(){ + let indexOfFirstMatch = Infinity; + let indexOfRegexPattern = Infinity; + + for (let i = 0; regexPatterns.length > i; i++){ + let indexOfMatch = runningMessage.search(regexPatterns[i].regex); + + if (indexOfMatch == -1) continue; // No match found + + if (indexOfMatch < indexOfFirstMatch) { + indexOfFirstMatch = indexOfMatch; + indexOfRegexPattern = i; + } + } + + if (indexOfFirstMatch !== Infinity) return [indexOfFirstMatch, indexOfRegexPattern]; // If there was a found match + return null; // No found match + } + }, + + helpers: { + // Small functions that are used often in the other functions. + _timestampArray: function(timestamp) { + const currentDate = timestamp || formatting.helpers.getTimestamp(); + let timeArray = []; + + timeArray.push(new Date(currentDate).toLocaleTimeString(undefined, { + hour12: false, + })); + + timeArray.push(new Date(currentDate).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + })); + + return timeArray; + }, + getTimestamp: function(){ + return Date.now(); + }, + fetch: function (url, options = {method: "GET"}) { + return new Promise((resolve, reject) => { + let req = new XMLHttpRequest(); + + req.onreadystatechange = function () { + + if (req.readyState === req.DONE) { + if (req.status === 200) { + resolve(req); + + } else { + console.log("Error", req.status, req.statusText); + reject(); + } + } + }; + + req.open(options.method, url); + req.send(); + }); + } + } +} diff --git a/scripts/communityScripts/armored-chat/img/icon_black.png b/scripts/system/domainChat/img/icon_black.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/icon_black.png rename to scripts/system/domainChat/img/icon_black.png diff --git a/scripts/communityScripts/armored-chat/img/icon_white.png b/scripts/system/domainChat/img/icon_white.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/icon_white.png rename to scripts/system/domainChat/img/icon_white.png diff --git a/scripts/communityScripts/armored-chat/img/ui/send.svg b/scripts/system/domainChat/img/ui/send.svg similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/send.svg rename to scripts/system/domainChat/img/ui/send.svg diff --git a/scripts/communityScripts/armored-chat/img/ui/send_black.png b/scripts/system/domainChat/img/ui/send_black.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/send_black.png rename to scripts/system/domainChat/img/ui/send_black.png diff --git a/scripts/communityScripts/armored-chat/img/ui/send_white.png b/scripts/system/domainChat/img/ui/send_white.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/send_white.png rename to scripts/system/domainChat/img/ui/send_white.png diff --git a/scripts/communityScripts/armored-chat/img/ui/settings_black.png b/scripts/system/domainChat/img/ui/settings_black.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/settings_black.png rename to scripts/system/domainChat/img/ui/settings_black.png diff --git a/scripts/communityScripts/armored-chat/img/ui/settings_white.png b/scripts/system/domainChat/img/ui/settings_white.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/settings_white.png rename to scripts/system/domainChat/img/ui/settings_white.png diff --git a/scripts/communityScripts/armored-chat/img/ui/social_black.png b/scripts/system/domainChat/img/ui/social_black.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/social_black.png rename to scripts/system/domainChat/img/ui/social_black.png diff --git a/scripts/communityScripts/armored-chat/img/ui/social_white.png b/scripts/system/domainChat/img/ui/social_white.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/social_white.png rename to scripts/system/domainChat/img/ui/social_white.png diff --git a/scripts/communityScripts/armored-chat/img/ui/world_black.png b/scripts/system/domainChat/img/ui/world_black.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/world_black.png rename to scripts/system/domainChat/img/ui/world_black.png diff --git a/scripts/communityScripts/armored-chat/img/ui/world_white.png b/scripts/system/domainChat/img/ui/world_white.png similarity index 100% rename from scripts/communityScripts/armored-chat/img/ui/world_white.png rename to scripts/system/domainChat/img/ui/world_white.png diff --git a/scripts/system/domainChat/qml_widgets/TemplateChatMessage.qml b/scripts/system/domainChat/qml_widgets/TemplateChatMessage.qml new file mode 100644 index 0000000000..0f97a614ae --- /dev/null +++ b/scripts/system/domainChat/qml_widgets/TemplateChatMessage.qml @@ -0,0 +1,178 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.3 + +Component { + id: template_chat_message + + Rectangle { + property int index: delegateIndex + + height: Math.max(65, children[1].height + 30) + color: index % 2 === 0 ? "transparent" : Qt.rgba(0.15,0.15,0.15,1) + width: listview.parent.parent.width + Layout.fillWidth: true + + Item { + width: parent.width - 10 + anchors.horizontalCenter: parent.horizontalCenter + height: 22 + + TextEdit { + text: delegateUsername; + color: "lightgray"; + readOnly: true; + selectByMouse: true; + selectByKeyboard: true; + } + + Text { + anchors.right: parent.right; + text: delegateDate; + color: "lightgray"; + } + } + + Flow { + anchors.top: parent.children[0].bottom; + width: parent.width; + x: 5 + id: messageBoxFlow + + Repeater { + model: delegateText; + + Item { + width: parent.width; + height: children[0].contentHeight; + + TextEdit { + text: model.value || "" + font.pointSize: 12 + wrapMode: TextEdit.WordWrap + width: parent.width * 0.8 + visible: model.type === 'text' || model.type === 'mention'; + readOnly: true + selectByMouse: true + selectByKeyboard: true + + color: { + switch (model.type) { + case "mention": + return "purple"; + default: + return "white"; + } + } + } + + RowLayout { + width: urlTypeTextDisplay.width; + visible: model.type === 'url'; + + TextEdit { + id: urlTypeTextDisplay; + text: model.value || ""; + font.pointSize: 12; + wrapMode: Text.Wrap; + color: "#4EBAFD"; + font.underline: true; + width: parent.width; + readOnly: true + selectByMouse: true + selectByKeyboard: true + + MouseArea { + anchors.fill: parent; + + onClicked: { + Window.openWebBrowser(model.value); + } + } + } + + Text { + text: "🗗"; + font.pointSize: 10; + wrapMode: Text.Wrap; + color: "white"; + + MouseArea { + anchors.fill: parent; + + onClicked: { + Qt.openUrlExternally(model.value); + } + } + } + } + + RowLayout { + visible: model.type === 'overteLocation'; + width: Math.min(messageBoxFlow.width, children[0].children[1].contentWidth + 35); + height: 20; + Layout.leftMargin: 5 + Layout.rightMargin: 5 + + Rectangle { + width: parent.width; + height: 20; + color: "lightgray" + radius: 2; + + Image { + source: "../img/ui/world_black.png" + width: 18; + height: 18; + sourceSize.width: 18 + sourceSize.height: 18 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 2 + anchors.rightMargin: 10 + } + + TextEdit { + text: model.type === 'overteLocation' ? model.value.split('hifi://')[1].split('/')[0] : ''; + color: "black" + font.pointSize: 12 + x: parent.children[0].width + 5; + anchors.verticalCenter: parent.verticalCenter + readOnly: true + selectByMouse: true + selectByKeyboard: true + } + + MouseArea { + anchors.fill: parent; + + onClicked: { + Window.openUrl(model.value); + } + } + } + } + + Item { + Layout.fillWidth: true; + visible: model.type === 'messageEnd'; + } + + Item { + visible: model.type === 'imageEmbed'; + width: messageBoxFlow.width; + height: 200 + + AnimatedImage { + source: model.type === 'imageEmbed' ? model.value : '' + height: Math.min(sourceSize.height, 200); + fillMode: Image.PreserveAspectFit + } + } + + + } + } + } + } +} \ No newline at end of file diff --git a/scripts/system/domainChat/qml_widgets/TemplateNotification.qml b/scripts/system/domainChat/qml_widgets/TemplateNotification.qml new file mode 100644 index 0000000000..4b9797d7f4 --- /dev/null +++ b/scripts/system/domainChat/qml_widgets/TemplateNotification.qml @@ -0,0 +1,41 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.3 + +Component { + id: template_notification + + Rectangle { + color: "#171717" + width: parent.width + height: 40 + + RowLayout { + width: parent.width + height: parent.height + + Rectangle { + height: parent.height + width: 5 + color: "#505186" + } + + Repeater { + model: delegateText + + TextEdit { + visible: model.value != undefined; + text: model.value || "" + color: "white" + font.pointSize: 12 + readOnly: true + selectByMouse: true + selectByKeyboard: true + height: root.height + wrapMode: Text.Wrap + font.italic: true + } + } + } + } +}