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 @@ + + + +image/svg+xml \ 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