diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js
index 31afd6e2db3..eb3641faaa5 100644
--- a/scripts/defaultScripts.js
+++ b/scripts/defaultScripts.js
@@ -22,7 +22,7 @@ var DEFAULT_SCRIPTS_COMBINED = [
"system/menu.js",
"system/bubble.js",
"system/snapshot.js",
- "system/pal.js", // "system/mod.js", // older UX, if you prefer
+ "system/people/people.js", //"system/pal.js" for older UIX, "system/mod.js" for older UIX.
"system/avatarapp.js",
"system/graphicsSettings.js",
"system/makeUserConnection.js",
diff --git a/scripts/system/people/img/default_profile_avatar.svg b/scripts/system/people/img/default_profile_avatar.svg
new file mode 100644
index 00000000000..a1f0268d110
--- /dev/null
+++ b/scripts/system/people/img/default_profile_avatar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/system/people/img/icon_black.svg b/scripts/system/people/img/icon_black.svg
new file mode 100644
index 00000000000..bed652f4105
--- /dev/null
+++ b/scripts/system/people/img/icon_black.svg
@@ -0,0 +1,80 @@
+
+
+
+
\ No newline at end of file
diff --git a/scripts/system/people/img/icon_white.svg b/scripts/system/people/img/icon_white.svg
new file mode 100644
index 00000000000..8665dfc6f7f
--- /dev/null
+++ b/scripts/system/people/img/icon_white.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/scripts/system/people/libs/contacts.js b/scripts/system/people/libs/contacts.js
new file mode 100644
index 00000000000..6b0db1ee5df
--- /dev/null
+++ b/scripts/system/people/libs/contacts.js
@@ -0,0 +1,127 @@
+//
+// contacts.js
+//
+// A small library to help with managing user contacts
+//
+// Created by Armored Dragon, 2025.
+// 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";
+
+let helper = Script.require("./helper.js");
+
+const directoryBase = Account.metaverseServerURL;
+
+let contactsLib = {
+ contacts: [],
+
+ addContact: (uuid) => {
+ return new Promise((resolve, reject) => {
+ print(`Adding contact '${uuid}'`);
+
+ const requestUrl = `${directoryBase}/api/v1/user/connection_request`;
+ const requestBody = {'node_id': helper.removeCurlyBracesFromUuid(MyAvatar.sessionUUID), 'proposed_node_id': helper.removeCurlyBracesFromUuid(uuid)};
+
+ helper.request(requestUrl, "POST", {'user_connection_request': requestBody}).then(onResponse);
+
+ function onResponse(response){
+ const responseJSON = helper.makeJSON(response);
+
+ // Error check
+ if (responseJSON.status !== "success") {
+ print(`Error sending contact request.`)
+ return reject({success: false, message: "Unknown error", response: responseJSON});
+ }
+
+
+ // We sent a request, but the recipient does not have an outgoing request to us.
+ if (responseJSON.data.connection === "pending") {
+ print(`Contact request is pending.`);
+ return resolve({success: true, message: "Contact request sent.", accepted: false});
+ }
+
+ // We sent a request, and the recipient has a outgoing request for us.
+ // We are now contacts
+ if (responseJSON.data.connection.new_connection) {
+ print(`Contact request is accepted.`);
+ return resolve({success: true, message: `Contact request for ${uuid} accepted.`, accepted: true});
+ }
+ }
+ })
+ },
+ removeContact: (username) => {
+ return new Promise((resolve, reject) => {
+ print(`Removing contact '${username}'.`);
+
+ helper.request(`${directoryBase}/api/v1/user/connections/${username}`, `DELETE`).then(onResponse);
+
+ function onResponse(response){
+ const responseJSON = helper.makeJSON(response);
+
+ helper.logJSON(responseJSON);
+
+ if (responseJSON.status !== "success") {
+ print(`Error sending contact removal request.`)
+ return reject({success: false, message: "Unknown error", response: responseJSON});
+ }
+
+ resolve({success: true, message: `Contact '${username}' was removed.`})
+ }
+ });
+ },
+ addFriend: (username) => {
+ return new Promise((resolve, reject) => {
+ print(`Adding friend '${username}'.`);
+
+ helper.request(`${directoryBase}/api/v1/user/friends`, `POST`, {username: username}).then(onResponse);
+
+ function onResponse(response) {
+ const responseJSON = helper.makeJSON(response);
+ helper.logJSON(responseJSON);
+ }
+ })
+ },
+ removeFriend: (username) => {
+ return new Promise((resolve, reject) => {
+ print(`Removing friend '${username}'.`);
+ helper.request(`${directoryBase}/api/v1/user/friends/${username}`, `DELETE`).then(onResponse);
+
+ function onResponse(response) {
+ const responseJSON = helper.makeJSON(response);
+ helper.logJSON(responseJSON);
+ }
+ })
+ },
+ getContactList: () => {
+ return new Promise((resolve, reject) => {
+ print(`Getting contact list.`);
+
+ helper.request(`https://mv.overte.org/server/api/v1/users/connections`).then(onResponse);
+
+ function onResponse(response) {
+ const responseJSON = helper.makeJSON(response);
+
+ if (responseJSON.status !== "success") {
+ print(`Error requesting contacts.`);
+ return reject({success: false, message: "Unknown error", response: responseJSON});
+ }
+
+ contactsLib.contacts = responseJSON.data.users;
+ resolve({success: true, message: "Contacts have been updated", contacts: contactsLib.contacts});
+ }
+ });
+ },
+ getContactByUsername: (username, refreshContactList = false) => {
+ return new Promise(async (resolve, reject) => {
+ if (refreshContactList) await contactsLib.getContactList();
+
+ const contactSingle = contactsLib.contacts.find((contact) => contact.username === username);
+
+ return resolve({success: true, contact: contactSingle});
+ });
+ }
+}
+
+module.exports = contactsLib;
\ No newline at end of file
diff --git a/scripts/system/people/libs/helper.js b/scripts/system/people/libs/helper.js
new file mode 100644
index 00000000000..d97703a772f
--- /dev/null
+++ b/scripts/system/people/libs/helper.js
@@ -0,0 +1,51 @@
+//
+// helper.js
+//
+// A small library that provides helper functions used throughout this application.
+//
+// Created by Armored Dragon, 2025.
+// 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";
+
+let helper = {
+ removeCurlyBracesFromUuid: (Uuid) => {
+ return Uuid.slice(1, -1);
+ },
+ request: (url, method = "GET", body) => {
+ return new Promise((resolve) => {
+ let req = new XMLHttpRequest();
+ req.onreadystatechange = function () {
+ if (req.readyState === req.DONE) {
+ if (req.status === 200) {
+ resolve(req.responseText);
+ }
+ else {
+ print("Error", req.status, req.statusText);
+ }
+ }
+ };
+
+ req.open(method, url);
+ if (method == `POST`) req.setRequestHeader("Content-Type", "application/json");
+ req.send(JSON.stringify(body));
+ })
+ },
+ makeJSON: (string) => {
+ if (typeof string === "object") return string;
+ try {
+ return JSON.parse(string);
+ } catch {
+ print(`Could not turn into JSON:\n${string}`);
+ return {};
+ }
+ },
+ logJSON: (obj) => {
+ print(JSON.stringify(obj, null, 4));
+
+ }
+}
+
+module.exports = helper;
\ No newline at end of file
diff --git a/scripts/system/people/libs/profiles.js b/scripts/system/people/libs/profiles.js
new file mode 100644
index 00000000000..53f34998e5a
--- /dev/null
+++ b/scripts/system/people/libs/profiles.js
@@ -0,0 +1,40 @@
+//
+// contacts.js
+//
+// A small library to help with viewing and parsing user profiles
+//
+// Created by Armored Dragon, 2025.
+// 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";
+
+let helper = Script.require("./helper.js");
+
+const directoryBase = Account.metaverseServerURL;
+
+let profilesLib = {
+ getProfile: (username) => {
+ return new Promise((resolve, reject) => {
+ print(`Getting profile for '${username}'.`);
+
+ const url = `${directoryBase}/api/v1/users?filter=connections&per_page=10&search=${encodeURIComponent(username)}`
+
+ helper.request(url).then(onResponse);
+
+ function onResponse(response) {
+ response = helper.makeJSON(response);
+
+ if (response.status !== "success") {
+ print(`Error requesting profile for ${username}.`);
+ return reject({success: false, message: "Unknown error", response: response});
+ }
+
+ return resolve({success: true, message: `Found ${username}'s profile.`, profile: response.data.users[0]});
+ }
+ })
+ }
+}
+
+module.exports = profilesLib;
\ No newline at end of file
diff --git a/scripts/system/people/people.js b/scripts/system/people/people.js
new file mode 100644
index 00000000000..91daf77545b
--- /dev/null
+++ b/scripts/system/people/people.js
@@ -0,0 +1,239 @@
+"use strict";
+
+const directoryBase = Account.metaverseServerURL;
+
+// FIXME: Check if focus user exists before issuing commands on them.
+// FIXME: Not having contacts breaks our own data?
+// TODO: User join / leave notifications.
+// FIXME: Better contacts page.
+// TODO: Documentation.
+// TODO: User selection distance based fallback.
+// TODO: Highlight contact if they are in the session.
+
+// Housekeeping TODO:
+// TODO: Singular focused user. Create internal use object with all relevant information.
+// TODO: Check to see if contact is present in world.
+
+let tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
+let active = false;
+
+let appButton = tablet.addButton({
+ icon: Script.resolvePath("./img/icon_white.svg"),
+ activeIcon: Script.resolvePath("./img/icon_black.svg"),
+ text: "PEOPLE",
+ sortOrder: 7,
+ isActive: active,
+});
+
+let palData = {};
+
+const selectionListName = "people.focusedUser";
+const selectionListStyle = {
+ outlineUnoccludedColor: { red: 255, green: 0, blue: 0 },
+ outlineUnoccludedAlpha: 1,
+ fillUnoccludedColor: {red: 255, green: 255, blue: 255},
+ fillUnoccludedAlpha: 0.0,
+ outlineOccludedColor: { red: 255, green: 255, blue: 255 },
+ outlineOccludedAlpha: 0.7,
+ outlineWidth: 4,
+ fillOccludedAlpha: 0.2
+};
+let contactsLib = Script.require("./libs/contacts.js");
+let profilesLib = Script.require("./libs/profiles.js");
+let helper = Script.require("./libs/helper.js");
+let iAmAdmin = Users.getCanKick();
+let adminUserData = {};
+let adminUserDataTimestamp = 0;
+
+let ignoredUsers = {};
+
+appButton.clicked.connect(toolbarButtonClicked);
+
+tablet.fromQml.connect(fromQML);
+tablet.screenChanged.connect(onScreenChanged);
+Script.scriptEnding.connect(shutdownScript);
+Script.setInterval(updatePalData, 100);
+Users.usernameFromIDReply.connect(onUsernameFromIDReply);
+
+function toolbarButtonClicked() {
+ if (active) {
+ tablet.gotoHomeScreen();
+ active = !active;
+ appButton.editProperties({ isActive: active });
+ } else {
+ tablet.loadQMLSource(Script.resolvePath("./qml/people.qml"));
+ active = !active;
+ appButton.editProperties({ isActive: active });
+ sendMyData();
+ }
+}
+
+function onScreenChanged(type, url) {
+ if (url != Script.resolvePath("./qml/people.qml")) {
+ active = false;
+ destroyHighlightSelection();
+ appButton.editProperties({
+ isActive: active,
+ });
+ }
+}
+
+function fromQML(event) {
+ console.log(`Got event from QML:\n ${JSON.stringify(event, null, 4)}`);
+
+ if (event.type == "focusedUser") {
+ if (Uuid.fromString(event.user) !== null) return highlightUser(event.user);
+ else return destroyHighlightSelection();
+ }
+
+ if (event.type == "updateMyData") {
+ return sendMyData();
+ }
+
+ if (event.type == "ignoreUser"){
+ if (ignoredUsers[event.user.sessionUUID]) {
+ // User is ignored, unignore them
+ delete ignoredUsers[event.sessionUUID];
+ Users.ignore(event.sessionUUID, false);
+ return;
+ }
+ else {
+ ignoredUsers[event.user.sessionUUID] = event.user;
+ Users.ignore(event.user.sessionUUID, true);
+ }
+ }
+
+ if (event.type == "addContact"){
+ contactsLib.addContact(event.sessionId).then(helper.logJSON);
+ return;
+ }
+
+ if (event.type == "removeContact") {
+ contactsLib.removeContact(event.username).then(helper.logJSON);
+ return;
+ }
+
+ if (event.type == "addFriend"){
+ contactsLib.addFriend(event.username).then(helper.logJSON);
+ return;
+ }
+
+ if (event.type == "removeFriend"){
+ contactsLib.removeFriend(event.username).then(helper.logJSON);
+ return;
+ }
+
+ if (event.type == "findContactByUsername") {
+ contactsLib.getContactByUsername(event.username).then((response) => {
+ if (response.success) toQML({type: "contactFromUsername", contact: response.contact})
+ })
+ return;
+ }
+}
+
+function shutdownScript() {
+ // Script has been removed.
+ console.log("Shutting Down");
+ tablet.removeButton(appButton);
+ destroyHighlightSelection();
+}
+
+function toQML(packet = { type: "" }) {
+ tablet.sendToQml(packet);
+}
+
+function updatePalData() {
+ // Updates the UI to the list of people in the session.
+ palData = AvatarManager.getPalData().data;
+
+ // Don't include ourself in the list
+ palData = palData.filter((user) => user.sessionUUID !== "");
+
+ // Set the audioLoudness value to a exponential value that fits within the bounds of the visual audio scale.
+ palData.map((user) => {user.audioLoudness = scaleAudioExponential(user.audioLoudness)});
+
+ toQML({ type: "palList", data: [...palData, ...Object.values(ignoredUsers)] });
+ toQML({ type: "adminUserData", data: adminUserData });
+
+ palData.forEach((user) => domainUserUpdate(user.sessionUUID));
+
+ function scaleAudioExponential(audioValue) {
+ let normalizedValue = audioValue / 32768;
+ let scaledValue = Math.pow(normalizedValue, 0.3);
+ return scaledValue;
+ }
+}
+
+async function sendMyData() {
+ // Send the current user to the QML UI.
+ let data = {
+ displayName: MyAvatar.displayName,
+ icon: null,
+ canKick: Users.getCanKick(),
+ findableBy: AccountServices.findableBy,
+ username: AccountServices.username
+ }
+
+ let [contactsList, profile] = await Promise.allSettled([contactsLib.getContactList(), profilesLib.getProfile(data.displayName)]);
+
+ if (contactsList.status === "fulfilled"){
+ contactsList = contactsList.value;
+ toQML({type: "connections", data: {connections: contactsLib.contacts, totalConnections: contactsLib.contacts.length}});
+ }
+
+ if (profile.status === "fulfilled") {
+ profile = profile.value;
+ data.icon = profile.profile.images.thumbnail;
+ }
+
+ toQML({ type: "myData", data: data });
+}
+
+function highlightUser(sessionUUID){
+ destroyHighlightSelection();
+
+ Selection.enableListHighlight(selectionListName, selectionListStyle);
+
+ Selection.addToSelectedItemsList(selectionListName, "avatar", sessionUUID);
+
+ const childEntitiesOfAvatar = recursivelyGetAllEntitiesOfAvatar(sessionUUID);
+ childEntitiesOfAvatar.forEach((id) => Selection.addToSelectedItemsList(selectionListName, "entity", id));
+}
+
+function recursivelyGetAllEntitiesOfAvatar(avatarId) {
+ let entityIds = [];
+
+ recurse(avatarId);
+ return entityIds;
+
+ function recurse(id) {
+ const children = Entities.getChildrenIDs(id);
+
+ children.forEach((childId) => {
+ entityIds.push(childId);
+ recurse(childId);
+ });
+ }
+}
+
+function destroyHighlightSelection(){
+ Selection.removeListFromMap(selectionListName);
+}
+
+function domainUserUpdate(sessionUUID){
+ if (!iAmAdmin) return; // Not admin, not going to work
+ if (adminUserData[sessionUUID]) return; // We already have that data!
+
+ console.log(`Requesting ${sessionUUID}'s information`)
+ adminUserData[sessionUUID] = {}; // Initialize to prevent multiple requests for a single user
+ Users.requestUsernameFromID(sessionUUID);
+}
+
+function onUsernameFromIDReply(sessionUUID, userName, machineFingerprint, isAdmin) {
+ console.log(`Got ${sessionUUID}'s information`)
+ adminUserData[sessionUUID] = {
+ username: userName,
+ isAdmin: isAdmin
+ };
+ toQML({type: "adminUserData", data: adminUserData});
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/people.qml b/scripts/system/people/qml/people.qml
new file mode 100644
index 00000000000..572d7a63a1d
--- /dev/null
+++ b/scripts/system/people/qml/people.qml
@@ -0,0 +1,309 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+import "./widgets"
+
+
+Rectangle {
+ signal sendToScript(var message);
+ color: Qt.rgba(0.1,0.1,0.1,1);
+ width: parent.width;
+ height: parent.height;
+ anchors.centerIn: parent;
+ anchors.horizontalCenter: parent.horizontalCenter;
+
+ property var users: [];
+ property var canKick: false; // The only way to tell if a user is an admin of a domain is if they have the kick permissions
+ property var myData: {icon: "../img/default_profile_avatar.svg"; displayName: ""; username: ""};
+ property var focusedUserData: {sessionDisplayName: ""; audioLoudness: 0.0};
+ property var focusedUser: null;
+ property var focusedContactData: {username: ""};
+ property var focusedContact: null;
+ property var page: "Home";
+ property var pages: ["Home", "User", "EditSelf", "Connections", "ContactPage"];
+ property var adminUserData: {};
+ property var connections: [];
+
+ // Home page
+ Column {
+ width: parent.width - 20;
+ height: parent.height;
+ spacing: 15;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ visible: page == "Home";
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ UserAbout {
+ // Current user who is logged in
+ sessionDisplayName: myData.displayName;
+ icon: myData.icon;
+ isSelf: true;
+ }
+
+ UserList {
+ // List of connected users
+ }
+ }
+
+ // Focused user page
+ ColumnLayout {
+ width: parent.width - 20;
+ height: parent.height;
+ spacing: 15;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ visible: page == "User";
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ UserAbout {
+ id: focusedUserAbout;
+ sessionDisplayName: focusedUserData && focusedUserData.sessionDisplayName || "";
+ }
+
+ UserAudio {
+ }
+
+ UserOptions {
+ Layout.fillHeight: true;
+
+ UserOptionButton {
+ buttonText: "Teleport";
+ action: () => {
+ var avatar = AvatarList.getAvatar(focusedUser);
+ MyAvatar.goToLocation(avatar.position, true, Quat.cancelOutRollAndPitch(avatar.orientation), true);
+ };
+ }
+ UserOptionButton {
+ buttonText: "Add Contact";
+ action: () => {toScript({type: "addContact", sessionId: focusedUser})};
+ }
+ UserOptionButton {
+ buttonText: "Mute";
+ action: () => {Users.personalMute(focusedUser, !Users.getPersonalMuteStatus(focusedUser))};
+ }
+ UserOptionButton {
+ buttonText: "Ignore";
+ action: () => {toScript({type: "ignoreUser", sessionUUID: focusedUser, user: focusedUserData})};
+ }
+
+ UserOptionButton {
+ buttonText: "Kick";
+ visible: canKick;
+ isDangerButton: true;
+ action: () => {Users.kick(focusedUser, Users.NO_BAN)};
+ }
+ UserOptionButton {
+ buttonText: "Ban";
+ visible: canKick;
+ isDangerButton: true;
+ action: () => {Users.kick(focusedUser, Users.BAN_BY_USERNAME | Users.BAN_BY_FINGERPRINT | Users.BAN_BY_IP)};
+ }
+ UserOptionButton {
+ buttonText: "Silence";
+ visible: canKick;
+ isDangerButton: true;
+ action: () => {Users.mute(focusedUser)};
+ }
+ }
+
+ BackButton {
+
+ }
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ }
+
+ // Edit persona
+ ColumnLayout {
+ width: parent.width - 20;
+ height: parent.height;
+ spacing: 15;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ visible: page == "EditSelf";
+
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ UserAboutEdit {
+ sessionDisplayName: myData.displayName;
+ icon: myData.icon;
+ isSelf: true;
+ }
+
+ ColumnLayout {
+ width: parent.width;
+ Layout.fillHeight: true;
+
+ Text {
+ text: "Availability";
+ color: "white";
+ font.pointSize: 18; // TODO: Sync with the other label
+ }
+
+ UserAboutAvailability {
+ status: "all";
+ }
+ UserAboutAvailability {
+ status: "connections";
+ }
+ UserAboutAvailability {
+ status: "friends";
+ }
+ UserAboutAvailability {
+ status: "none";
+ }
+ }
+
+
+
+ Item {
+ // Spacer
+ Layout.fillHeight: true;
+ }
+
+ BackButton {
+
+ }
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ }
+
+ // Connections
+ ColumnLayout {
+ width: parent.width - 20;
+ height: parent.height;
+ spacing: 15;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ visible: page == "Connections";
+
+ UserConnections {}
+
+ BackButton {}
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+ }
+
+ // Contact user page
+ ColumnLayout {
+ width: parent.width - 20;
+ height: parent.height;
+ spacing: 15;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ visible: page == "ContactPage";
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ UserAbout {
+ id: contactUserAbout;
+ sessionDisplayName: focusedContactData && focusedContactData.username || "";
+ icon: focusedContactData && focusedContactData.images.thumbnail || "";
+ }
+
+ UserOptions {
+ Layout.fillHeight: true;
+
+ // TODO: Make this button toggle and conditional?
+ UserOptionButton {
+ buttonText: "Make Friend";
+ action: () => {
+
+ };
+ }
+ UserOptionButton {
+ buttonText: "Remove Contact";
+ action: () => {toScript({type: "removeContact", username: focusedContact})};
+ }
+ }
+
+ BackButton {
+
+ }
+
+ Item {
+ // Spacer
+ height: 1;
+ width: 1;
+ }
+
+ }
+
+ function toUserPage(sessionUUID){
+ focusedUser = sessionUUID;
+ focusedUserData = users.filter((user) => user.sessionUUID === focusedUser)[0];
+ page = "User";
+ toScript({type: "focusedUser", user: focusedUser});
+ }
+
+ function toContactPage(username){
+ page = "ContactPage";
+ toScript({type: "findContactByUsername", username: username});
+ }
+
+
+ function fromScript(message) {
+ if (message.type == "myData"){
+ myData = message.data;
+ canKick = message.data.canKick;
+ return;
+ }
+
+ if (message.type == "palList") {
+ users = message.data;
+ if (focusedUser) focusedUserData = users.filter((user) => user.sessionUUID === focusedUser)[0];
+ else focusedUserData = {sessionDisplayName: "", audioLoudness: 0.0};
+ return;
+ }
+
+ if (message.type == "adminUserData") {
+ adminUserData = message.data;
+ return;
+ }
+
+ if (message.type == "connections") {
+ connections = message.data;
+ return;
+ }
+
+ if (message.type == "contactFromUsername") {
+ focusedContactData = message.contact;
+ focusedContact = message.contact.username;
+ }
+ }
+
+ // Send message to script
+ function toScript(packet){
+ sendToScript(packet)
+ }
+}
+
diff --git a/scripts/system/people/qml/widgets/BackButton.qml b/scripts/system/people/qml/widgets/BackButton.qml
new file mode 100644
index 00000000000..9a256c9e992
--- /dev/null
+++ b/scripts/system/people/qml/widgets/BackButton.qml
@@ -0,0 +1,56 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+Rectangle {
+ width: parent.width;
+ height: 40;
+ color: "#333";
+ Layout.alignment: Qt.AlignHCenter;
+
+ Column {
+ width: parent.width - 10;
+ spacing: 0;
+ anchors.verticalCenter: parent.verticalCenter;
+
+ Text {
+ x: 10;
+ width: parent.width;
+ text: "Back";
+ color: "white";
+ font.pointSize: 16;
+ horizontalAlignment: Text.AlignHCenter
+
+ // Animation for the font size of the element.
+ Behavior on font.pointSize {
+ NumberAnimation {
+ duration: 100
+ easing.type: Easing.InOutCubic
+ }
+ }
+ }
+
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ hoverEnabled: true;
+
+ onEntered: {
+ parent.color = "#555";
+ parent.children[0].children[0].font.pointSize = 20;
+ }
+
+ onExited: {
+ parent.color = "#333";
+ parent.children[0].children[0].font.pointSize = 16;
+ }
+
+ onClicked: {
+ page = "Home";
+ toScript({type: "focusedUser", user: null});
+ focusedUser = null;
+ focusedContact = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/PresentUserListElement.qml b/scripts/system/people/qml/widgets/PresentUserListElement.qml
new file mode 100644
index 00000000000..3849dfc765b
--- /dev/null
+++ b/scripts/system/people/qml/widgets/PresentUserListElement.qml
@@ -0,0 +1,67 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+Rectangle {
+ property var backgroundColor: index % 2 == 0 ? "#333" : "#2a2a2a";
+
+ width: parent.width;
+ height: canKick ? 60 : 40;
+ color: backgroundColor;
+ anchors.horizontalCenter: parent.horizontalCenter;
+
+ Column {
+ width: parent.width - 10;
+ spacing: 0;
+ anchors.verticalCenter: parent.verticalCenter;
+
+ Text {
+ x: 10;
+ width: parent.width;
+ text: sessionDisplayName;
+ color: "white";
+ font.pointSize: 16;
+ elide: Text.ElideRight;
+ maximumLineCount: 1;
+ }
+
+ Text {
+ // Users real account name.
+ visible: canKick;
+ x: 30;
+ width: parent.width;
+ text: adminUserData[users[index].sessionUUID].username || ""
+ color: "#3babe1";
+ font.pointSize: 12;
+ elide: Text.ElideRight;
+ maximumLineCount: 1;
+ }
+
+ // Animation for the x of the element.
+ Behavior on x {
+ NumberAnimation {
+ duration: 100
+ easing.type: Easing.InOutCubic
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ hoverEnabled: true;
+
+ onEntered: {
+ parent.color = "#555";
+ parent.children[0].x = 10;
+ }
+
+ onExited: {
+ parent.color = backgroundColor
+ parent.children[0].x = 0;
+ }
+
+ onClicked: {
+ toUserPage(sessionUUID);
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserAbout.qml b/scripts/system/people/qml/widgets/UserAbout.qml
new file mode 100644
index 00000000000..695f88967dd
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserAbout.qml
@@ -0,0 +1,231 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+import QtGraphicalEffects 1.0
+
+RowLayout {
+ property var sessionDisplayName: "";
+ property var icon: "../../img/default_profile_avatar.svg";
+ property var isSelf: false;
+ property var statusColor: statusColors[myData.findableBy] || "magenta";
+
+ property var statusColors: {
+ "all": "#3babe1",
+ "connections": "#3edf44",
+ "friends": "#f7ff3a",
+ "none": "#969696"
+ }
+
+ property var statusLiteral: {
+ "all": "Everyone",
+ "connections": "Contacts",
+ "friends": "Friends",
+ "none": "Offline"
+ }
+
+ width: parent.width - 20;
+ height: 80;
+ spacing: 10;
+
+ // Profile picture
+ Item {
+ height: 80;
+ width: 80;
+
+ Rectangle {
+ color: "#333";
+ radius: 100;
+ height: 80;
+ width: 80;
+ id: avatarImageBackground;
+ anchors.centerIn: parent;
+ }
+
+ Image {
+ id: avatarImageElement;
+ source: icon || "../../img/default_profile_avatar.svg";
+ sourceSize.width: 80;
+ sourceSize.height: 80;
+ z: 1;
+ anchors.centerIn: parent;
+ visible: false;
+ }
+
+ OpacityMask {
+ anchors.fill: avatarImageElement;
+ source: avatarImageElement;
+ maskSource: avatarImageBackground;
+ }
+ }
+
+ // Specific user information
+ Column {
+ width: 300;
+
+ Rectangle {
+ color: "transparent";
+ width: parent.width;
+ height: 30;
+ radius: 10;
+
+ Behavior on color {
+ ColorAnimation {
+ duration: 50
+ easing.type: Easing.InOutCubic
+ }
+ }
+
+ Text {
+ text: sessionDisplayName || "";
+ font.pointSize: 16;
+ color: "white";
+ clip: true;
+ width: parent.width - 10;
+ anchors.centerIn: parent;
+ wrapMode: Text.WrapAnywhere;
+ elide: Text.ElideRight;
+ maximumLineCount: 2;
+
+ MouseArea {
+ anchors.fill: parent;
+ visible: isSelf;
+ hoverEnabled: true;
+
+ onClicked: {
+ page = "EditSelf";
+ }
+
+ onEntered: {
+ parent.parent.color = "#333";
+ }
+ onExited: {
+ parent.parent.color = "transparent";
+ }
+
+ }
+ }
+ }
+
+ Rectangle {
+ color: "transparent";
+ width: parent.width;
+ height: 30;
+ radius: 10;
+
+ Behavior on color {
+ ColorAnimation {
+ duration: 50
+ easing.type: Easing.InOutCubic
+ }
+ }
+
+ RowLayout {
+ visible: isSelf;
+ width: parent.width - 10;
+ anchors.centerIn: parent;
+
+ Rectangle {
+ // Squarcle
+ width: 15;
+ height: 15;
+ radius: 100;
+ color: statusColor;
+ Layout.alignment: Qt.AlignVCenter;
+ }
+
+ Text {
+ x: parent.children[0].width;
+ text: statusLiteral[myData.findableBy] || "...";
+ font.pointSize: 16;
+ color: statusColor;
+ Layout.alignment: Qt.AlignVCenter;
+ }
+
+ Item {
+ // Spacer
+ Layout.fillWidth: true;
+ width: 1;
+ height: 1;
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ visible: isSelf;
+ hoverEnabled: true;
+
+ onClicked: {
+ page = "EditSelf";
+ }
+
+ onEntered: {
+ parent.parent.color = "#333";
+ }
+ onExited: {
+ parent.parent.color = "transparent";
+ }
+
+ }
+ }
+ }
+
+ Rectangle {
+ color: "transparent";
+ width: parent.width;
+ height: 30;
+ radius: 10;
+
+ Behavior on color {
+ ColorAnimation {
+ duration: 50
+ easing.type: Easing.InOutCubic
+ }
+ }
+
+ RowLayout {
+ visible: isSelf;
+ width: parent.width - 10;
+ anchors.centerIn: parent;
+
+ Rectangle {
+ // Squarcle
+ width: 15;
+ height: 15;
+ radius: 100;
+ color: "teal";
+ Layout.alignment: Qt.AlignVCenter;
+ }
+
+ Text {
+ text: (connections.totalConnections || "0");
+ color: "white"
+ font.pointSize: 16;
+ Layout.alignment: Qt.AlignVCenter;
+ }
+
+ Item {
+ Layout.fillWidth: true;
+ // Spacer
+ width: 1;
+ height: 1;
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ visible: isSelf;
+ hoverEnabled: true;
+
+ onClicked: {
+ page = "Connections";
+ }
+
+ onEntered: {
+ parent.parent.color = "#333";
+ }
+ onExited: {
+ parent.parent.color = "transparent";
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserAboutAvailability.qml b/scripts/system/people/qml/widgets/UserAboutAvailability.qml
new file mode 100644
index 00000000000..737ef02b2e9
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserAboutAvailability.qml
@@ -0,0 +1,71 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+import QtGraphicalEffects 1.0
+
+
+Rectangle {
+ property var status: "";
+ property var statusColors: {
+ "all": "#3babe1",
+ "connections": "#3edf44",
+ "friends": "#f7ff3a",
+ "none": "#969696"
+ }
+
+ property var statusLiteral: {
+ "all": "Everyone",
+ "connections": "Contacts",
+ "friends": "Friends",
+ "none": "Offline"
+ }
+
+ width: parent.width;
+ height: 60;
+ color: "#333";
+ Layout.alignment: Qt.AlignHCenter;
+
+ Column {
+ width: parent.width - 10;
+ spacing: 0;
+ anchors.verticalCenter: parent.verticalCenter;
+
+ Text {
+ x: 10;
+ width: parent.width;
+ text: statusLiteral[status];
+ color: statusColors[status];
+ font.pointSize: 16;
+ horizontalAlignment: Text.AlignHCenter
+
+ // Animation for the font size of the element.
+ Behavior on font.pointSize {
+ NumberAnimation {
+ duration: 100
+ easing.type: Easing.InOutCubic
+ }
+ }
+ }
+
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ hoverEnabled: true;
+
+ onEntered: {
+ parent.color = "#555";
+ parent.children[0].children[0].font.pointSize = 20;
+ }
+
+ onExited: {
+ parent.color = "#333";
+ parent.children[0].children[0].font.pointSize = 16;
+ }
+
+ onClicked: {
+ AccountServices.findableBy = status;
+ sendToScript({type: "updateMyData"});
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserAboutEdit.qml b/scripts/system/people/qml/widgets/UserAboutEdit.qml
new file mode 100644
index 00000000000..b3777837e68
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserAboutEdit.qml
@@ -0,0 +1,93 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+import QtGraphicalEffects 1.0
+
+RowLayout {
+ property var sessionDisplayName: "";
+ property var icon: "../../img/default_profile_avatar.svg";
+ property var isSelf: false;
+
+ property var statusColors: {
+ "all": "#3babe1",
+ "connections": "#3edf44",
+ "friends": "#f7ff3a",
+ "none": "#969696"
+ }
+
+ property var statusLiteral: {
+ "all": "Everyone",
+ "connections": "Contacts",
+ "friends": "Friends",
+ "none": "Offline"
+ }
+
+ width: parent.width - 20;
+ height: 80;
+ spacing: 10;
+
+ Item {
+ height: 80;
+ width: 80;
+
+ Rectangle {
+ color: "#333";
+ radius: 100;
+ height: 80;
+ width: 80;
+ id: avatarImageBackground;
+ anchors.centerIn: parent;
+ }
+
+ Image {
+ id: avatarImageElement;
+ source: icon || "../../img/default_profile_avatar.svg";
+ sourceSize.width: 80;
+ sourceSize.height: 80;
+ z: 1;
+ anchors.centerIn: parent;
+ visible: false;
+ }
+
+ OpacityMask {
+ anchors.fill: avatarImageElement;
+ source: avatarImageElement;
+ maskSource: avatarImageBackground;
+ }
+ }
+
+ ColumnLayout {
+ width: 300;
+ height: 40;
+
+ Item {
+ width: parent.width;
+ height: 40;
+
+ Rectangle {
+ color: "#333";
+ anchors.fill: parent;
+ width: parent.width;
+ height: parent.height;
+ }
+
+ TextInput {
+ id: displayNameEntry
+ text: myData.displayName || "";
+ font.pointSize: 16;
+ color: "white";
+ width: parent.width - 4;
+ height: parent.height - 4;
+ anchors.centerIn: parent;
+ clip: true;
+
+ onTextEdited: {
+ MyAvatar.displayName = text;
+ }
+ }
+ }
+
+ }
+
+
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserAudio.qml b/scripts/system/people/qml/widgets/UserAudio.qml
new file mode 100644
index 00000000000..78b230e490e
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserAudio.qml
@@ -0,0 +1,63 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+Column {
+ width: parent.width;
+ height: 50;
+
+ Text {
+ text: "Audio";
+ color: "white";
+ font.pointSize: 18;
+ }
+
+ Rectangle {
+ width: parent.width;
+ height: 20;
+ color: "white";
+
+ Rectangle {
+ width: (focusedUserData && focusedUserData.audioLoudness || 0) * parent.width - 4;
+ height: parent.height - 4;
+ color: "#505186";
+ anchors.verticalCenter: parent.verticalCenter;
+ x: 2
+ }
+
+ Slider {
+ from: -60;
+ to: 20;
+ value: Users.getAvatarGain(focusedUser);
+ snapMode: Slider.SnapAlways;
+ stepSize: 1;
+ width: parent.width;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ anchors.verticalCenter: parent.verticalCenter;
+
+ background: Rectangle {
+ color: "transparent"
+ }
+
+ handle: Rectangle {
+ x: parent.leftPadding + parent.visualPosition * (parent.availableWidth - width)
+ y: -14
+ width: 20
+ height: 40
+ color: "black"
+
+ Rectangle {
+ width: 16
+ height: 36
+ color: "gray"
+ anchors.horizontalCenter: parent.horizontalCenter;
+ anchors.verticalCenter: parent.verticalCenter;
+ }
+ }
+
+ onValueChanged: {
+ Users.setAvatarGain(focusedUser, value)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserConnectionElement.qml b/scripts/system/people/qml/widgets/UserConnectionElement.qml
new file mode 100644
index 00000000000..b003772a613
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserConnectionElement.qml
@@ -0,0 +1,70 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+Rectangle {
+ property var backgroundColor: index % 2 == 0 ? "#333" : "#2a2a2a";
+ property var isFriendCheckBoxInitialized: false;
+
+ width: parent.width;
+ height: 40;
+ color: backgroundColor;
+ anchors.horizontalCenter: parent.horizontalCenter;
+
+ RowLayout {
+ width: parent.width - 10;
+ spacing: 0;
+ anchors.verticalCenter: parent.verticalCenter;
+
+ Image {
+ id: avatarImageElement;
+ source: icon || "../../img/default_profile_avatar.svg";
+ sourceSize.width: 30;
+ sourceSize.height: 30;
+ width: 40;
+ }
+
+ Item {
+ // Spacer
+ width: 20;
+ height: 1;
+ }
+
+ Text {
+ Layout.fillWidth: true;
+ text: displayName;
+ color: "white";
+ font.pointSize: 16;
+ elide: Text.ElideRight;
+ maximumLineCount: 1;
+ }
+
+ // Animation for the x of the element.
+ Behavior on x {
+ NumberAnimation {
+ duration: 100
+ easing.type: Easing.InOutCubic
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ hoverEnabled: true;
+
+ onEntered: {
+ parent.color = "#555";
+ parent.children[0].x = 10;
+ }
+
+ onExited: {
+ parent.color = backgroundColor
+ parent.children[0].x = 0;
+ }
+
+ onClicked: {
+ toContactPage(displayName);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserConnections.qml b/scripts/system/people/qml/widgets/UserConnections.qml
new file mode 100644
index 00000000000..03d042cd1d3
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserConnections.qml
@@ -0,0 +1,43 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+ColumnLayout {
+ width: parent.width;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ height: parent.height;
+
+ Text {
+ id: label;
+ text: "Connections";
+ color: "white";
+ font.pointSize: 18;
+ }
+
+ Flickable {
+ visible: true;
+ width: parent.width;
+ height: parent.height;
+ Layout.fillHeight: true;
+ y: label.height + 10;
+ contentWidth: parent.width;
+ contentHeight: flickableContent.height;
+ clip: true;
+
+ Column {
+ id: flickableContent;
+ width: parent.width;
+ spacing: 0;
+
+ Repeater {
+ model: connections.connections.length;
+
+ delegate: UserConnectionElement {
+ property string displayName: connections.connections[index].username;
+ property string icon: connections.connections[index].images.thumbnail;
+ property bool isFriend: connections.connections[index].connection == "friend";
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserList.qml b/scripts/system/people/qml/widgets/UserList.qml
new file mode 100644
index 00000000000..1133be515f1
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserList.qml
@@ -0,0 +1,43 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+Column {
+ width: parent.width;
+ anchors.horizontalCenter: parent.horizontalCenter;
+ height: parent.height;
+
+ Text {
+ id: label;
+ text: "Present Users";
+ color: "white";
+ font.pointSize: 18;
+ }
+
+ Flickable {
+ visible: true;
+ width: parent.width;
+ height: parent.height;
+ Layout.fillHeight: true;
+ y: label.height + 10;
+ contentWidth: parent.width;
+ contentHeight: flickableContent.height;
+ clip: true;
+
+ Column {
+ id: flickableContent;
+ height: parent.height;
+ width: parent.width;
+ spacing: 0;
+
+ Repeater {
+ model: users.length;
+
+ delegate: PresentUserListElement {
+ property string sessionDisplayName: users[index].sessionDisplayName;
+ property string sessionUUID: users[index].sessionUUID;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserOptionButton.qml b/scripts/system/people/qml/widgets/UserOptionButton.qml
new file mode 100644
index 00000000000..6dfe0ce62e0
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserOptionButton.qml
@@ -0,0 +1,39 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+Rectangle {
+ property var buttonText: "";
+ property var isDangerButton: false;
+ property var action: null;
+
+ color: "#333";
+ width: 220;
+ height: 100;
+
+ Text {
+ text: buttonText;
+ anchors.centerIn: parent;
+ color: isDangerButton ? "#ff3030" : "white"
+ font.pointSize: 18;
+ }
+
+ MouseArea {
+ anchors.fill: parent;
+ hoverEnabled: true;
+
+ onEntered: {
+ parent.color = "#555";
+ parent.children[0].font.pointSize = 22;
+ }
+
+ onExited: {
+ parent.color = "#333";
+ parent.children[0].font.pointSize = 18;
+ }
+
+ onClicked: {
+ action();
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/system/people/qml/widgets/UserOptions.qml b/scripts/system/people/qml/widgets/UserOptions.qml
new file mode 100644
index 00000000000..0efaea2e8bf
--- /dev/null
+++ b/scripts/system/people/qml/widgets/UserOptions.qml
@@ -0,0 +1,44 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.3
+
+ColumnLayout {
+ width: parent.width;
+ height: parent.height;
+ id: userOptionsRoot;
+
+ Text {
+ id: label;
+ text: "Options";
+ color: "white";
+ font.pointSize: 18;
+ }
+
+ ScrollView {
+ width: parent.width;
+ contentHeight: gridLay.height;
+ Layout.fillHeight: true;
+ Layout.fillWidth: true;
+ clip: true;
+
+ Grid {
+ id: gridLay;
+ columns: 2
+ spacing: 10
+ width: parent.width;
+ anchors.horizontalCenter: parent.horizontalCenter;
+
+ Rectangle {
+ color: "red";
+ width: parent.width;
+ }
+ }
+
+ }
+
+ Component.onCompleted: {
+ while (userOptionsRoot.children.length > 2){
+ userOptionsRoot.children[2].parent = gridLay;
+ }
+ }
+}
\ No newline at end of file