"use strict";//// pal.js//// Created by Howard Stearns on December 9th, 2016// Copyright 2016 High Fidelity, Inc.// Copyright 2023 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///* jslint vars:true, plusplus:true, forin:true *//* global Tablet, Settings, Script, AvatarList, Users, Entities, MyAvatar, Camera, Overlays, Vec3, Quat, HMD, Controller, Account, UserActivityLogger, Messages, Window, XMLHttpRequest, print, location, getControllerWorldLocation*//* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */// (function () { // BEGIN LOCAL_SCOPEvar controllerStandard = Controller.Standard; var request = Script.require('request').request;var AppUi = Script.require('appUi'); var populateNearbyUserList, color, textures, removeOverlays, controllerComputePickRay, off, receiveMessage, avatarDisconnected, clearLocalQMLDataAndClosePAL, CHANNEL, getConnectionData, avatarAdded, avatarRemoved, avatarSessionChanged; // forward references; // hardcoding these as it appears we cannot traverse the originalTextures in overlays??? Maybe I've missed// something, will revisit as this is sorta horrible.var UNSELECTED_TEXTURES = { "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png"), "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png")};var SELECTED_TEXTURES = { "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png"), "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png")};var HOVER_TEXTURES = { "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png"), "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png")}; var UNSELECTED_COLOR = { red: 0x1F, green: 0xC6, blue: 0xA6};var SELECTED_COLOR = {red: 0xF3, green: 0x91, blue: 0x29};var HOVER_COLOR = {red: 0xD0, green: 0xD0, blue: 0xD0}; // almost white for nowvar METAVERSE_BASE = Account.metaverseServerURL; Script.include("/~/system/libraries/controllers.js"); function projectVectorOntoPlane(normalizedVector, planeNormal) { return Vec3.cross(planeNormal, Vec3.cross(normalizedVector, planeNormal));}function angleBetweenVectorsInPlane(from, to, normal) { var projectedFrom = projectVectorOntoPlane(from, normal); var projectedTo = projectVectorOntoPlane(to, normal); return Vec3.orientedAngle(projectedFrom, projectedTo, normal);} //// Overlays.//var overlays = {}; // Keeps track of all our extended overlay data objects, keyed by target identifier. function ExtendedOverlay(key, properties, selected, hasModel) { // A wrapper around overlays to store the key it is associated with. overlays[key] = this; if (hasModel) { var modelKey = key + "-m"; this.model = new ExtendedOverlay(modelKey, { "type": "Model", "modelURL": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx"), "textures": textures(selected), "ignorePickIntersection": true }, false, false); } else { this.model = undefined; } this.key = key; this.selected = selected || false; // not undefined this.hovering = false; this.activeOverlay = Entities.addEntity(properties, "local"); // We could use different overlays for (un)selected...}// Instance methods:ExtendedOverlay.prototype.deleteOverlay = function () { // remove display and data of this overlay Entities.deleteEntity(this.activeOverlay); delete overlays[this.key];}; ExtendedOverlay.prototype.editOverlay = function (properties) { // change display of this overlay Entities.editEntity(this.activeOverlay, properties);}; function color(selected, hovering, level) { var base = hovering ? HOVER_COLOR : selected ? SELECTED_COLOR : UNSELECTED_COLOR; function scale(component) { var delta = 0xFF - component; return component + (delta * level); } return {red: scale(base.red), green: scale(base.green), blue: scale(base.blue)};} function textures(selected, hovering) { return hovering ? HOVER_TEXTURES : selected ? SELECTED_TEXTURES : UNSELECTED_TEXTURES;}// so we don't have to traverse the overlays to get the last onevar lastHoveringId = 0;ExtendedOverlay.prototype.hover = function (hovering) { this.hovering = hovering; if (this.key === lastHoveringId) { if (hovering) { return; } lastHoveringId = 0; } this.editOverlay({color: color(this.selected, hovering, this.audioLevel)}); if (this.model) { this.model.editOverlay({textures: textures(this.selected, hovering)}); } if (hovering) { // un-hover the last hovering overlay if (lastHoveringId && lastHoveringId !== this.key) { ExtendedOverlay.get(lastHoveringId).hover(false); } lastHoveringId = this.key; }};ExtendedOverlay.prototype.select = function (selected) { if (this.selected === selected) { return; } UserActivityLogger.palAction(selected ? "avatar_selected" : "avatar_deselected", this.key); this.editOverlay({color: color(selected, this.hovering, this.audioLevel)}); if (this.model) { this.model.editOverlay({textures: textures(selected)}); } this.selected = selected;};// Class methods:var selectedIds = [];ExtendedOverlay.isSelected = function (id) { return -1 !== selectedIds.indexOf(id);};ExtendedOverlay.get = function (key) { // answer the extended overlay data object associated with the given avatar identifier return overlays[key];};ExtendedOverlay.some = function (iterator) { // Bails early as soon as iterator returns truthy. var key; for (key in overlays) { if (iterator(ExtendedOverlay.get(key))) { return; } }};ExtendedOverlay.unHover = function () { // calls hover(false) on lastHoveringId (if any) if (lastHoveringId) { ExtendedOverlay.get(lastHoveringId).hover(false); }}; // hit(overlay) on the one overlay intersected by pickRay, if any.// noHit() if no ExtendedOverlay was intersected (helps with hover)ExtendedOverlay.applyPickRay = function (pickRay, hit, noHit) { // TODO: this could just include the necessary overlays for better performance var pickedOverlay = Overlays.findRayIntersection(pickRay, true); // Depends on nearer coverOverlays to extend closer to us than farther ones. if (!pickedOverlay.intersects) { if (noHit) { return noHit(); } return; } ExtendedOverlay.some(function (overlay) { // See if pickedOverlay is one of ours. if ((overlay.activeOverlay) === pickedOverlay.overlayID) { hit(overlay); return true; } });}; //// Similar, for entities//function HighlightedEntity(id, entityProperties) { this.id = id; this.overlay = Entities.addEntity({ "type": "Shape", "shape": "Cube", "position": entityProperties.position, "rotation": entityProperties.rotation, "dimensions": entityProperties.dimensions, "primitiveMode": "solid", "color": { "red": 0xF3, "green": 0x91, "blue": 0x29 }, "ignorePickIntersection": true, "renderLayer": "world" // Arguable. For now, let's not distract with mysterious wires around the scene. }, "local"); HighlightedEntity.overlays.push(this);}HighlightedEntity.overlays = [];HighlightedEntity.clearOverlays = function clearHighlightedEntities() { HighlightedEntity.overlays.forEach(function (highlighted) { Entities.deleteEntity(highlighted.overlay); }); HighlightedEntity.overlays = [];};HighlightedEntity.updateOverlays = function updateHighlightedEntities() { HighlightedEntity.overlays.forEach(function (highlighted) { var properties = Entities.getEntityProperties(highlighted.id, ['position', 'rotation', 'dimensions']); Entities.editEntity(highlighted.overlay, { "position": properties.position, "rotation": properties.rotation, "dimensions": properties.dimensions }); });}; /* this contains current gain for a given node (by session id). More efficient than * querying it, plus there isn't a getGain function so why write one */var sessionGains = {};function convertDbToLinear(decibels) { // +20db = 10x, 0dB = 1x, -10dB = 0.1x, etc... // but, your perception is that something 2x as loud is +10db // so we go from -60 to +20 or 1/64x to 4x. For now, we can // maybe scale the signal this way?? return Math.pow(2, decibels / 10.0);}function fromQml(message) { // messages are {method, params}, like json-rpc. See also sendToQml. var data, connectionUserName, friendUserName; switch (message.method) { case 'selected': selectedIds = message.params; ExtendedOverlay.some(function (overlay) { var id = overlay.key; var selected = ExtendedOverlay.isSelected(id); overlay.select(selected); }); HighlightedEntity.clearOverlays(); if (selectedIds.length) { Entities.findEntitiesInFrustum(Camera.frustum).forEach(function (id) { // Because lastEditedBy is per session, the vast majority of entities won't match, // so it would probably be worth reducing marshalling costs by asking for just we need. // However, providing property name(s) is advisory and some additional properties are // included anyway. As it turns out, asking for 'lastEditedBy' gives 'position', 'rotation', // and 'dimensions', too, so we might as well make use of them instead of making a second // getEntityProperties call. // It would be nice if we could harden this against future changes by specifying all // and only these four in an array, but see // https://highfidelity.fogbugz.com/f/cases/2728/Entities-getEntityProperties-id-lastEditedBy-name-lastEditedBy-doesn-t-work var properties = Entities.getEntityProperties(id, 'lastEditedBy'); if (ExtendedOverlay.isSelected(properties.lastEditedBy)) { new HighlightedEntity(id, properties); } }); } break; case 'refresh': // old name for refreshNearby case 'refreshNearby': data = {}; ExtendedOverlay.some(function (overlay) { // capture the audio data data[overlay.key] = overlay; }); removeOverlays(); // If filter is specified from .qml instead of through settings, update the settings. if (message.params.filter !== undefined) { Settings.setValue('pal/filtered', !!message.params.filter); } populateNearbyUserList(message.params.selected, data); UserActivityLogger.palAction("refresh_nearby", ""); break; case 'refreshConnections': print('Refreshing Connections...'); UserActivityLogger.palAction("refresh_connections", ""); break; case 'removeConnection': connectionUserName = message.params; request({ uri: METAVERSE_BASE + '/api/v1/user/connections/' + connectionUserName, method: 'DELETE' }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to remove connection", connectionUserName, error || response.status); return; } sendToQml({ method: 'connectionRemoved', params: connectionUserName }); }); break; case 'removeFriend': friendUserName = message.params; print("Removing " + friendUserName + " from friends."); request({ uri: METAVERSE_BASE + '/api/v1/user/friends/' + friendUserName, method: 'DELETE' }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to unfriend " + friendUserName, error || response.status); return; } getConnectionData(friendUserName); }); break; case 'addFriend': friendUserName = message.params; print("Adding " + friendUserName + " to friends."); request({ uri: METAVERSE_BASE + '/api/v1/user/friends', method: 'POST', json: true, body: { username: friendUserName, } }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to friend " + friendUserName, error || response.status); return; } getConnectionData(friendUserName); }); break; case 'http.request': break; // Handled by request-service. case 'hideNotificationDot': shouldShowDot = false; ui.messagesWaiting(shouldShowDot); break; default: print('Unrecognized message from Pal.qml'); }} function sendToQml(message) { ui.sendMessage(message);}function updateUser(data) { print('PAL update:', JSON.stringify(data)); sendToQml({ method: 'updateUsername', params: data });}//// User management services//// These are prototype versions that will be changed when the back end changes. function requestJSON(url, callback) { // callback(data) if successfull. Logs otherwise. request({ uri: url }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to get request", error || response.status); return; } callback(response.data); });}function getProfilePicture(username, callback) { // callback(url) if successfull. (Logs otherwise) // FIXME Prototype scrapes profile picture. We should include in user status, and also make available somewhere for myself request({ uri: METAVERSE_BASE + '/users/' + username }, function (error, html) { var matched = !error && html.match(/img class="users-img" src="([^"]*)"/); if (!matched) { print('Error: Unable to get profile picture for', username, error); callback(''); return; } callback(matched[1]); });}var SAFETY_LIMIT = 400;function getAvailableConnections(domain, callback, numResultsPerPage) { // callback([{usename, location}...]) if successfull. (Logs otherwise) var url = METAVERSE_BASE + '/api/v1/users?per_page=' + (numResultsPerPage || SAFETY_LIMIT) + '&'; if (domain) { url += 'status=' + domain.slice(1, -1); // without curly braces } else { url += 'filter=connections'; // regardless of whether online } requestJSON(url, function (connectionsData) { callback(connectionsData.users); });}function getInfoAboutUser(specificUsername, callback) { var url = METAVERSE_BASE + '/api/v1/users?filter=connections&per_page=' + SAFETY_LIMIT + '&search=' + encodeURIComponent(specificUsername); requestJSON(url, function (connectionsData) { // You could have (up to SAFETY_LIMIT connections whose usernames contain the specificUsername. // Search returns all such matches. for (user in connectionsData.users) { if (connectionsData.users[user].username === specificUsername) { callback(connectionsData.users[user]); return; } } callback(false); });}function getConnectionData(specificUsername, domain) { // Update all the usernames that I am entitled to see, using my login but not dependent on canKick. function frob(user) { // get into the right format var formattedSessionId = user.location.node_id || ''; if (formattedSessionId !== '' && formattedSessionId.indexOf("{") != 0) { formattedSessionId = "{" + formattedSessionId + "}"; } return { sessionId: formattedSessionId, userName: user.username, connection: user.connection, profileUrl: user.images.thumbnail, placeName: (user.location.root || user.location.domain || {}).name || '' }; } if (specificUsername) { getInfoAboutUser(specificUsername, function (user) { if (user) { updateUser(frob(user)); } else { print('Error: Unable to find information about ' + specificUsername + ' in connectionsData!'); } }); } else if (domain) { getAvailableConnections(domain, function (users) { users.forEach(function (user) { updateUser(frob(user)); }); }); } else { print("Error: unrecognized getConnectionData()"); }} //// Main operations.//function addAvatarNode(id) { var selected = ExtendedOverlay.isSelected(id); return new ExtendedOverlay(id, { "type": "Shape", "shape": "Sphere", "renderLayer": "front", "primitiveMode": "solid", "alpha": 0.8, "color": color(selected, false, 0.0), "ignorePickIntersection": false }, selected, true);}// Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter.var avatarsOfInterest = {};function populateNearbyUserList(selectData, oldAudioData) { var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')}, data = [], avatars = AvatarList.getAvatarIdentifiers(), myPosition = filter && Camera.position, frustum = filter && Camera.frustum, verticalHalfAngle = filter && (frustum.fieldOfView / 2), horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio), orientation = filter && Camera.orientation, forward = filter && Quat.getForward(orientation), verticalAngleNormal = filter && Quat.getRight(orientation), horizontalAngleNormal = filter && Quat.getUp(orientation); avatarsOfInterest = {}; var avatarData = AvatarList.getPalData().data; avatarData.forEach(function (currentAvatarData) { var id = currentAvatarData.sessionUUID; var name = currentAvatarData.sessionDisplayName; if (!name) { // Either we got a data packet but no identity yet, or something is really messed up. In any case, // we won't be able to do anything with this user, so don't include them. // In normal circumstances, a refresh will bring in the new user, but if we're very heavily loaded, // we could be losing and gaining people randomly. print('No avatar identity data for', currentAvatarData.sessionUUID); return; } if (id && myPosition && (Vec3.distance(currentAvatarData.position, myPosition) > filter.distance)) { return; } var normal = id && filter && Vec3.normalize(Vec3.subtract(currentAvatarData.position, myPosition)); var horizontal = normal && angleBetweenVectorsInPlane(normal, forward, horizontalAngleNormal); var vertical = normal && angleBetweenVectorsInPlane(normal, forward, verticalAngleNormal); if (id && filter && ((Math.abs(horizontal) > horizontalHalfAngle) || (Math.abs(vertical) > verticalHalfAngle))) { return; } var oldAudio = oldAudioData && oldAudioData[id]; var avatarPalDatum = { profileUrl: '', displayName: name, userName: '', connection: '', sessionId: id || '', audioLevel: (oldAudio && oldAudio.audioLevel) || 0.0, avgAudioLevel: (oldAudio && oldAudio.avgAudioLevel) || 0.0, admin: false, personalMute: !!id && Users.getPersonalMuteStatus(id), // expects proper boolean, not null ignore: !!id && Users.getIgnoreStatus(id), // dittoLines 1–500 of 948. Load more
"use strict"; // // pal.js // // Created by Howard Stearns on December 9th, 2016 // Copyright 2016 High Fidelity, Inc. // Copyright 2023 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 // /* jslint vars:true, plusplus:true, forin:true */ /* global Tablet, Settings, Script, AvatarList, Users, Entities, MyAvatar, Camera, Overlays, Vec3, Quat, HMD, Controller, Account, UserActivityLogger, Messages, Window, XMLHttpRequest, print, location, getControllerWorldLocation */ /* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */ // (function () { // BEGIN LOCAL_SCOPE var controllerStandard = Controller.Standard; var request = Script.require('request').request; var AppUi = Script.require('appUi'); var populateNearbyUserList, color, textures, removeOverlays, controllerComputePickRay, off, receiveMessage, avatarDisconnected, clearLocalQMLDataAndClosePAL, CHANNEL, getConnectionData, avatarAdded, avatarRemoved, avatarSessionChanged; // forward references; // hardcoding these as it appears we cannot traverse the originalTextures in overlays??? Maybe I've missed // something, will revisit as this is sorta horrible. var UNSELECTED_TEXTURES = { "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png"), "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-idle.png") }; var SELECTED_TEXTURES = { "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png"), "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-selected.png") }; var HOVER_TEXTURES = { "idle-D": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png"), "idle-E": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx/Avatar-Overlay-v1.fbm/avatar-overlay-hover.png") }; var UNSELECTED_COLOR = { red: 0x1F, green: 0xC6, blue: 0xA6}; var SELECTED_COLOR = {red: 0xF3, green: 0x91, blue: 0x29}; var HOVER_COLOR = {red: 0xD0, green: 0xD0, blue: 0xD0}; // almost white for now var METAVERSE_BASE = Account.metaverseServerURL; Script.include("/~/system/libraries/controllers.js"); function projectVectorOntoPlane(normalizedVector, planeNormal) { return Vec3.cross(planeNormal, Vec3.cross(normalizedVector, planeNormal)); } function angleBetweenVectorsInPlane(from, to, normal) { var projectedFrom = projectVectorOntoPlane(from, normal); var projectedTo = projectVectorOntoPlane(to, normal); return Vec3.orientedAngle(projectedFrom, projectedTo, normal); } // // Overlays. // var overlays = {}; // Keeps track of all our extended overlay data objects, keyed by target identifier. function ExtendedOverlay(key, properties, selected, hasModel) { // A wrapper around overlays to store the key it is associated with. overlays[key] = this; if (hasModel) { var modelKey = key + "-m"; this.model = new ExtendedOverlay(modelKey, { "type": "Model", "modelURL": Script.resolvePath("./assets/models/Avatar-Overlay-v1.fbx"), "textures": textures(selected), "ignorePickIntersection": true }, false, false); } else { this.model = undefined; } this.key = key; this.selected = selected || false; // not undefined this.hovering = false; this.activeOverlay = Entities.addEntity(properties, "local"); // We could use different overlays for (un)selected... } // Instance methods: ExtendedOverlay.prototype.deleteOverlay = function () { // remove display and data of this overlay Entities.deleteEntity(this.activeOverlay); delete overlays[this.key]; }; ExtendedOverlay.prototype.editOverlay = function (properties) { // change display of this overlay Entities.editEntity(this.activeOverlay, properties); }; function color(selected, hovering, level) { var base = hovering ? HOVER_COLOR : selected ? SELECTED_COLOR : UNSELECTED_COLOR; function scale(component) { var delta = 0xFF - component; return component + (delta * level); } return {red: scale(base.red), green: scale(base.green), blue: scale(base.blue)}; } function textures(selected, hovering) { return hovering ? HOVER_TEXTURES : selected ? SELECTED_TEXTURES : UNSELECTED_TEXTURES; } // so we don't have to traverse the overlays to get the last one var lastHoveringId = 0; ExtendedOverlay.prototype.hover = function (hovering) { this.hovering = hovering; if (this.key === lastHoveringId) { if (hovering) { return; } lastHoveringId = 0; } this.editOverlay({color: color(this.selected, hovering, this.audioLevel)}); if (this.model) { this.model.editOverlay({textures: textures(this.selected, hovering)}); } if (hovering) { // un-hover the last hovering overlay if (lastHoveringId && lastHoveringId !== this.key) { ExtendedOverlay.get(lastHoveringId).hover(false); } lastHoveringId = this.key; } }; ExtendedOverlay.prototype.select = function (selected) { if (this.selected === selected) { return; } UserActivityLogger.palAction(selected ? "avatar_selected" : "avatar_deselected", this.key); this.editOverlay({color: color(selected, this.hovering, this.audioLevel)}); if (this.model) { this.model.editOverlay({textures: textures(selected)}); } this.selected = selected; }; // Class methods: var selectedIds = []; ExtendedOverlay.isSelected = function (id) { return -1 !== selectedIds.indexOf(id); }; ExtendedOverlay.get = function (key) { // answer the extended overlay data object associated with the given avatar identifier return overlays[key]; }; ExtendedOverlay.some = function (iterator) { // Bails early as soon as iterator returns truthy. var key; for (key in overlays) { if (iterator(ExtendedOverlay.get(key))) { return; } } }; ExtendedOverlay.unHover = function () { // calls hover(false) on lastHoveringId (if any) if (lastHoveringId) { ExtendedOverlay.get(lastHoveringId).hover(false); } }; // hit(overlay) on the one overlay intersected by pickRay, if any. // noHit() if no ExtendedOverlay was intersected (helps with hover) ExtendedOverlay.applyPickRay = function (pickRay, hit, noHit) { // TODO: this could just include the necessary overlays for better performance var pickedOverlay = Overlays.findRayIntersection(pickRay, true); // Depends on nearer coverOverlays to extend closer to us than farther ones. if (!pickedOverlay.intersects) { if (noHit) { return noHit(); } return; } ExtendedOverlay.some(function (overlay) { // See if pickedOverlay is one of ours. if ((overlay.activeOverlay) === pickedOverlay.overlayID) { hit(overlay); return true; } }); }; // // Similar, for entities // function HighlightedEntity(id, entityProperties) { this.id = id; this.overlay = Entities.addEntity({ "type": "Shape", "shape": "Cube", "position": entityProperties.position, "rotation": entityProperties.rotation, "dimensions": entityProperties.dimensions, "primitiveMode": "solid", "color": { "red": 0xF3, "green": 0x91, "blue": 0x29 }, "ignorePickIntersection": true, "renderLayer": "world" // Arguable. For now, let's not distract with mysterious wires around the scene. }, "local"); HighlightedEntity.overlays.push(this); } HighlightedEntity.overlays = []; HighlightedEntity.clearOverlays = function clearHighlightedEntities() { HighlightedEntity.overlays.forEach(function (highlighted) { Entities.deleteEntity(highlighted.overlay); }); HighlightedEntity.overlays = []; }; HighlightedEntity.updateOverlays = function updateHighlightedEntities() { HighlightedEntity.overlays.forEach(function (highlighted) { var properties = Entities.getEntityProperties(highlighted.id, ['position', 'rotation', 'dimensions']); Entities.editEntity(highlighted.overlay, { "position": properties.position, "rotation": properties.rotation, "dimensions": properties.dimensions }); }); }; /* this contains current gain for a given node (by session id). More efficient than * querying it, plus there isn't a getGain function so why write one */ var sessionGains = {}; function convertDbToLinear(decibels) { // +20db = 10x, 0dB = 1x, -10dB = 0.1x, etc... // but, your perception is that something 2x as loud is +10db // so we go from -60 to +20 or 1/64x to 4x. For now, we can // maybe scale the signal this way?? return Math.pow(2, decibels / 10.0); } function fromQml(message) { // messages are {method, params}, like json-rpc. See also sendToQml. var data, connectionUserName, friendUserName; switch (message.method) { case 'selected': selectedIds = message.params; ExtendedOverlay.some(function (overlay) { var id = overlay.key; var selected = ExtendedOverlay.isSelected(id); overlay.select(selected); }); HighlightedEntity.clearOverlays(); if (selectedIds.length) { Entities.findEntitiesInFrustum(Camera.frustum).forEach(function (id) { // Because lastEditedBy is per session, the vast majority of entities won't match, // so it would probably be worth reducing marshalling costs by asking for just we need. // However, providing property name(s) is advisory and some additional properties are // included anyway. As it turns out, asking for 'lastEditedBy' gives 'position', 'rotation', // and 'dimensions', too, so we might as well make use of them instead of making a second // getEntityProperties call. // It would be nice if we could harden this against future changes by specifying all // and only these four in an array, but see // https://highfidelity.fogbugz.com/f/cases/2728/Entities-getEntityProperties-id-lastEditedBy-name-lastEditedBy-doesn-t-work var properties = Entities.getEntityProperties(id, 'lastEditedBy'); if (ExtendedOverlay.isSelected(properties.lastEditedBy)) { new HighlightedEntity(id, properties); } }); } break; case 'refresh': // old name for refreshNearby case 'refreshNearby': data = {}; ExtendedOverlay.some(function (overlay) { // capture the audio data data[overlay.key] = overlay; }); removeOverlays(); // If filter is specified from .qml instead of through settings, update the settings. if (message.params.filter !== undefined) { Settings.setValue('pal/filtered', !!message.params.filter); } populateNearbyUserList(message.params.selected, data); UserActivityLogger.palAction("refresh_nearby", ""); break; case 'refreshConnections': print('Refreshing Connections...'); UserActivityLogger.palAction("refresh_connections", ""); break; case 'removeConnection': connectionUserName = message.params; request({ uri: METAVERSE_BASE + '/api/v1/user/connections/' + connectionUserName, method: 'DELETE' }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to remove connection", connectionUserName, error || response.status); return; } sendToQml({ method: 'connectionRemoved', params: connectionUserName }); }); break; case 'removeFriend': friendUserName = message.params; print("Removing " + friendUserName + " from friends."); request({ uri: METAVERSE_BASE + '/api/v1/user/friends/' + friendUserName, method: 'DELETE' }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to unfriend " + friendUserName, error || response.status); return; } getConnectionData(friendUserName); }); break; case 'addFriend': friendUserName = message.params; print("Adding " + friendUserName + " to friends."); request({ uri: METAVERSE_BASE + '/api/v1/user/friends', method: 'POST', json: true, body: { username: friendUserName, } }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to friend " + friendUserName, error || response.status); return; } getConnectionData(friendUserName); }); break; case 'http.request': break; // Handled by request-service. case 'hideNotificationDot': shouldShowDot = false; ui.messagesWaiting(shouldShowDot); break; default: print('Unrecognized message from Pal.qml'); } } function sendToQml(message) { ui.sendMessage(message); } function updateUser(data) { print('PAL update:', JSON.stringify(data)); sendToQml({ method: 'updateUsername', params: data }); } // // User management services // // These are prototype versions that will be changed when the back end changes. function requestJSON(url, callback) { // callback(data) if successfull. Logs otherwise. request({ uri: url }, function (error, response) { if (error || (response.status !== 'success')) { print("Error: unable to get request", error || response.status); return; } callback(response.data); }); } function getProfilePicture(username, callback) { // callback(url) if successfull. (Logs otherwise) // FIXME Prototype scrapes profile picture. We should include in user status, and also make available somewhere for myself request({ uri: METAVERSE_BASE + '/users/' + username }, function (error, html) { var matched = !error && html.match(/img class="users-img" src="([^"]*)"/); if (!matched) { print('Error: Unable to get profile picture for', username, error); callback(''); return; } callback(matched[1]); }); } var SAFETY_LIMIT = 400; function getAvailableConnections(domain, callback, numResultsPerPage) { // callback([{usename, location}...]) if successfull. (Logs otherwise) var url = METAVERSE_BASE + '/api/v1/users?per_page=' + (numResultsPerPage || SAFETY_LIMIT) + '&'; if (domain) { url += 'status=' + domain.slice(1, -1); // without curly braces } else { url += 'filter=connections'; // regardless of whether online } requestJSON(url, function (connectionsData) { callback(connectionsData.users); }); } function getInfoAboutUser(specificUsername, callback) { var url = METAVERSE_BASE + '/api/v1/users?filter=connections&per_page=' + SAFETY_LIMIT + '&search=' + encodeURIComponent(specificUsername); requestJSON(url, function (connectionsData) { // You could have (up to SAFETY_LIMIT connections whose usernames contain the specificUsername. // Search returns all such matches. for (user in connectionsData.users) { if (connectionsData.users[user].username === specificUsername) { callback(connectionsData.users[user]); return; } } callback(false); }); } function getConnectionData(specificUsername, domain) { // Update all the usernames that I am entitled to see, using my login but not dependent on canKick. function frob(user) { // get into the right format var formattedSessionId = user.location.node_id || ''; if (formattedSessionId !== '' && formattedSessionId.indexOf("{") != 0) { formattedSessionId = "{" + formattedSessionId + "}"; } return { sessionId: formattedSessionId, userName: user.username, connection: user.connection, profileUrl: user.images.thumbnail, placeName: (user.location.root || user.location.domain || {}).name || '' }; } if (specificUsername) { getInfoAboutUser(specificUsername, function (user) { if (user) { updateUser(frob(user)); } else { print('Error: Unable to find information about ' + specificUsername + ' in connectionsData!'); } }); } else if (domain) { getAvailableConnections(domain, function (users) { users.forEach(function (user) { updateUser(frob(user)); }); }); } else { print("Error: unrecognized getConnectionData()"); } } // // Main operations. // function addAvatarNode(id) { var selected = ExtendedOverlay.isSelected(id); return new ExtendedOverlay(id, { "type": "Shape", "shape": "Sphere", "renderLayer": "front", "primitiveMode": "solid", "alpha": 0.8, "color": color(selected, false, 0.0), "ignorePickIntersection": false }, selected, true); } // Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter. var avatarsOfInterest = {}; function populateNearbyUserList(selectData, oldAudioData) { var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')}, data = [], avatars = AvatarList.getAvatarIdentifiers(), myPosition = filter && Camera.position, frustum = filter && Camera.frustum, verticalHalfAngle = filter && (frustum.fieldOfView / 2), horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio), orientation = filter && Camera.orientation, forward = filter && Quat.getForward(orientation), verticalAngleNormal = filter && Quat.getRight(orientation), horizontalAngleNormal = filter && Quat.getUp(orientation); avatarsOfInterest = {}; var avatarData = AvatarList.getPalData().data; avatarData.forEach(function (currentAvatarData) { var id = currentAvatarData.sessionUUID; var name = currentAvatarData.sessionDisplayName; if (!name) { // Either we got a data packet but no identity yet, or something is really messed up. In any case, // we won't be able to do anything with this user, so don't include them. // In normal circumstances, a refresh will bring in the new user, but if we're very heavily loaded, // we could be losing and gaining people randomly. print('No avatar identity data for', currentAvatarData.sessionUUID); return; } if (id && myPosition && (Vec3.distance(currentAvatarData.position, myPosition) > filter.distance)) { return; } var normal = id && filter && Vec3.normalize(Vec3.subtract(currentAvatarData.position, myPosition)); var horizontal = normal && angleBetweenVectorsInPlane(normal, forward, horizontalAngleNormal); var vertical = normal && angleBetweenVectorsInPlane(normal, forward, verticalAngleNormal); if (id && filter && ((Math.abs(horizontal) > horizontalHalfAngle) || (Math.abs(vertical) > verticalHalfAngle))) { return; } var oldAudio = oldAudioData && oldAudioData[id]; var avatarPalDatum = { profileUrl: '', displayName: name, userName: '', connection: '', sessionId: id || '', audioLevel: (oldAudio && oldAudio.audioLevel) || 0.0, avgAudioLevel: (oldAudio && oldAudio.avgAudioLevel) || 0.0, admin: false, personalMute: !!id && Users.getPersonalMuteStatus(id), // expects proper boolean, not null ignore: !!id && Users.getIgnoreStatus(id), // ditto