// // Pal.qml // qml/hifi // // People Action List // // Created by Howard Stearns on 12/12/2016 // Copyright 2016 High Fidelity, Inc. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // import QtQuick 2.5 import QtQuick.Controls 1.4 import "../styles-uit" import "../controls-uit" as HifiControls // references HMD, Users, UserActivityLogger from root context Rectangle { id: pal // Size width: parent.width height: parent.height // Style color: "#E3E3E3" // Properties property int myCardHeight: 90 property int rowHeight: 70 property int actionButtonWidth: 55 property int nameCardWidth: palContainer.width - actionButtonWidth*(iAmAdmin ? 4 : 2) - 4 - hifi.dimensions.scrollbarBackgroundWidth property var myData: ({displayName: "", userName: "", audioLevel: 0.0, admin: true}) // valid dummy until set property var ignored: ({}); // Keep a local list of ignored avatars & their data. Necessary because HashMap is slow to respond after ignoring. property var userModelData: [] // This simple list is essentially a mirror of the userModel listModel without all the extra complexities. property bool iAmAdmin: false // Keep a local list of per-avatar gainSliderValueDBs. Far faster than fetching this data from the server. // NOTE: if another script modifies the per-avatar gain, this value won't be accurate! property var gainSliderValueDB: ({}); HifiConstants { id: hifi } // The letterbox used for popup messages LetterboxMessage { id: letterboxMessage z: 999 // Force the popup on top of everything else } function letterbox(headerGlyph, headerText, message) { letterboxMessage.headerGlyph = headerGlyph letterboxMessage.headerText = headerText letterboxMessage.text = message letterboxMessage.visible = true letterboxMessage.popupRadius = 0 } // This is the container for the PAL Rectangle { property bool punctuationMode: false id: palContainer // Size width: pal.width - 10 height: pal.height - 10 // Style color: pal.color // Anchors anchors.centerIn: pal // Properties radius: hifi.dimensions.borderRadius // This contains the current user's NameCard and will contain other information in the future Rectangle { id: myInfo // Size width: palContainer.width height: myCardHeight // Style color: pal.color // Anchors anchors.top: palContainer.top // Properties radius: hifi.dimensions.borderRadius // This NameCard refers to the current user's NameCard (the one above the table) NameCard { id: myCard // Properties displayName: myData.displayName userName: myData.userName audioLevel: myData.audioLevel isMyCard: true // Size width: nameCardWidth height: parent.height // Anchors anchors.left: parent.left } } // Rectangles used to cover up rounded edges on bottom of MyInfo Rectangle Rectangle { color: pal.color width: palContainer.width height: 10 anchors.top: myInfo.bottom anchors.left: parent.left } Rectangle { color: pal.color width: palContainer.width height: 10 anchors.bottom: table.top anchors.left: parent.left } // Rectangle that houses "ADMIN" string Rectangle { id: adminTab // Size width: 2*actionButtonWidth + hifi.dimensions.scrollbarBackgroundWidth + 2 height: 40 // Anchors anchors.bottom: myInfo.bottom anchors.bottomMargin: -10 anchors.right: myInfo.right // Properties visible: iAmAdmin // Style color: hifi.colors.tableRowLightEven radius: hifi.dimensions.borderRadius border.color: hifi.colors.lightGrayText border.width: 2 // "ADMIN" text RalewaySemiBold { id: adminTabText text: "ADMIN" // Text size size: hifi.fontSizes.tableHeading + 2 // Anchors anchors.top: parent.top anchors.topMargin: 8 anchors.left: parent.left anchors.right: parent.right anchors.rightMargin: hifi.dimensions.scrollbarBackgroundWidth // Style font.capitalization: Font.AllUppercase color: hifi.colors.redHighlight // Alignment horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignTop } } // This TableView refers to the table (below the current user's NameCard) HifiControls.Table { id: table // Size height: palContainer.height - myInfo.height - 4 width: palContainer.width - 4 // Anchors anchors.left: parent.left anchors.top: myInfo.bottom // Properties centerHeaderText: true sortIndicatorVisible: true headerVisible: true onSortIndicatorColumnChanged: sortModel() onSortIndicatorOrderChanged: sortModel() TableViewColumn { id: displayNameHeader role: "displayName" title: table.rowCount + (table.rowCount === 1 ? " NAME" : " NAMES") width: nameCardWidth movable: false resizable: false } TableViewColumn { role: "personalMute" title: "MUTE" width: actionButtonWidth movable: false resizable: false } TableViewColumn { role: "ignore" title: "IGNORE" width: actionButtonWidth movable: false resizable: false } TableViewColumn { visible: iAmAdmin role: "mute" title: "SILENCE" width: actionButtonWidth movable: false resizable: false } TableViewColumn { visible: iAmAdmin role: "kick" title: "BAN" width: actionButtonWidth movable: false resizable: false } model: ListModel { id: userModel } // This Rectangle refers to each Row in the table. rowDelegate: Rectangle { // The only way I know to specify a row height. // Size height: rowHeight color: styleData.selected ? hifi.colors.orangeHighlight : styleData.alternate ? hifi.colors.tableRowLightEven : hifi.colors.tableRowLightOdd } // This Item refers to the contents of each Cell itemDelegate: Item { id: itemCell property bool isCheckBox: styleData.role === "personalMute" || styleData.role === "ignore" property bool isButton: styleData.role === "mute" || styleData.role === "kick" // This NameCard refers to the cell that contains an avatar's // DisplayName and UserName NameCard { id: nameCard // Properties displayName: styleData.value userName: model ? model.userName : "" audioLevel: model ? model.audioLevel : 0.0 visible: !isCheckBox && !isButton uuid: model ? model.sessionId : "" selected: styleData.selected isAdmin: model && model.admin // Size width: nameCardWidth height: parent.height // Anchors anchors.left: parent.left } // This CheckBox belongs in the columns that contain the stateful action buttons ("Mute" & "Ignore" for now) // KNOWN BUG with the Checkboxes: When clicking in the center of the sorting header, the checkbox // will appear in the "hovered" state. Hovering over the checkbox will fix it. // Clicking on the sides of the sorting header doesn't cause this problem. // I'm guessing this is a QT bug and not anything I can fix. I spent too long trying to work around it... // I'm just going to leave the minor visual bug in. HifiControls.CheckBox { id: actionCheckBox visible: isCheckBox anchors.centerIn: parent checked: model ? model[styleData.role] : false // If this is a "Personal Mute" checkbox, disable the checkbox if the "Ignore" checkbox is checked. enabled: !(styleData.role === "personalMute" && (model ? model["ignore"] : true)) boxSize: 24 onClicked: { var newValue = !model[styleData.role] userModel.setProperty(model.userIndex, styleData.role, newValue) userModelData[model.userIndex][styleData.role] = newValue // Defensive programming Users[styleData.role](model.sessionId, newValue) UserActivityLogger["palAction"](newValue ? styleData.role : "un-" + styleData.role, model.sessionId) if (styleData.role === "ignore") { userModel.setProperty(model.userIndex, "personalMute", newValue) userModelData[model.userIndex]["personalMute"] = newValue // Defensive programming if (newValue) { ignored[model.sessionId] = userModelData[model.userIndex] } else { delete ignored[model.sessionId] } } // http://doc.qt.io/qt-5/qtqml-syntax-propertybinding.html#creating-property-bindings-from-javascript // I'm using an explicit binding here because clicking a checkbox breaks the implicit binding as set by // "checked:" statement above. checked = Qt.binding(function() { return (model[styleData.role])}) } } // This Button belongs in the columns that contain the stateless action buttons ("Silence" & "Ban" for now) HifiControls.Button { id: actionButton color: 2 // Red visible: isButton anchors.centerIn: parent width: 32 height: 24 onClicked: { Users[styleData.role](model.sessionId) UserActivityLogger["palAction"](styleData.role, model.sessionId) if (styleData.role === "kick") { // Just for now, while we cannot undo "Ban": userModel.remove(model.userIndex) delete userModelData[model.userIndex] // Defensive programming sortModel() } } // muted/error glyphs HiFiGlyphs { text: (styleData.role === "kick") ? hifi.glyphs.error : hifi.glyphs.muted // Size size: parent.height*1.3 // Anchors anchors.fill: parent // Style horizontalAlignment: Text.AlignHCenter color: enabled ? hifi.buttons.textColor[actionButton.color] : hifi.buttons.disabledTextColor[actionButton.colorScheme] } } } } // Refresh button Rectangle { // Size width: hifi.dimensions.tableHeaderHeight-1 height: hifi.dimensions.tableHeaderHeight-1 // Anchors anchors.left: table.left anchors.leftMargin: 4 anchors.top: table.top // Style color: hifi.colors.tableBackgroundLight // Actual refresh icon HiFiGlyphs { id: reloadButton text: hifi.glyphs.reloadSmall // Size size: parent.width*1.5 // Anchors anchors.fill: parent // Style horizontalAlignment: Text.AlignHCenter color: hifi.colors.darkGray } MouseArea { id: reloadButtonArea // Anchors anchors.fill: parent hoverEnabled: true // Everyone likes a responsive refresh button! // So use onPressed instead of onClicked onPressed: { reloadButton.color = hifi.colors.lightGrayText pal.sendToScript({method: 'refresh'}) } onReleased: reloadButton.color = (containsMouse ? hifi.colors.baseGrayHighlight : hifi.colors.darkGray) onEntered: reloadButton.color = hifi.colors.baseGrayHighlight onExited: reloadButton.color = (pressed ? hifi.colors.lightGrayText: hifi.colors.darkGray) } } // Separator between user and admin functions Rectangle { // Size width: 2 height: table.height // Anchors anchors.left: adminTab.left anchors.top: table.top // Properties visible: iAmAdmin color: hifi.colors.lightGrayText } TextMetrics { id: displayNameHeaderMetrics text: displayNameHeader.title // font: displayNameHeader.font // was this always undefined? giving error now... } // This Rectangle refers to the [?] popup button next to "NAMES" Rectangle { color: hifi.colors.tableBackgroundLight width: 20 height: hifi.dimensions.tableHeaderHeight - 2 anchors.left: table.left anchors.top: table.top anchors.topMargin: 1 anchors.leftMargin: nameCardWidth/2 + displayNameHeaderMetrics.width/2 + 6 RalewayRegular { id: helpText text: "[?]" size: hifi.fontSizes.tableHeading + 2 font.capitalization: Font.AllUppercase color: hifi.colors.darkGray horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter anchors.fill: parent } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton hoverEnabled: true onClicked: letterbox(hifi.glyphs.question, "Display Names", "Bold names in the list are avatar display names.
" + "If a display name isn't set, a unique session display name is assigned." + "

Administrators of this domain can also see the username or machine ID associated with each avatar present.") onEntered: helpText.color = hifi.colors.baseGrayHighlight onExited: helpText.color = hifi.colors.darkGray } } // This Rectangle refers to the [?] popup button next to "ADMIN" Rectangle { visible: iAmAdmin color: adminTab.color width: 20 height: 28 anchors.right: adminTab.right anchors.rightMargin: 10 + hifi.dimensions.scrollbarBackgroundWidth anchors.top: adminTab.top anchors.topMargin: 2 RalewayRegular { id: adminHelpText text: "[?]" size: hifi.fontSizes.tableHeading + 2 font.capitalization: Font.AllUppercase color: hifi.colors.redHighlight horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter anchors.fill: parent } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton hoverEnabled: true onClicked: letterbox(hifi.glyphs.question, "Admin Actions", "Silence mutes a user's microphone. Silenced users can unmute themselves by clicking "UNMUTE" on their toolbar.

" + "Ban removes a user from this domain and prevents them from returning. Admins can un-ban users from the Sandbox Domain Settings page.") onEntered: adminHelpText.color = "#94132e" onExited: adminHelpText.color = hifi.colors.redHighlight } } HifiControls.Keyboard { id: keyboard raised: myCard.currentlyEditingDisplayName && HMD.active numeric: parent.punctuationMode anchors { bottom: parent.bottom left: parent.left right: parent.right } } } // Timer used when selecting table rows that aren't yet present in the model // (i.e. when selecting avatars using edit.js or sphere overlays) Timer { property bool selected // Selected or deselected? property int userIndex // The userIndex of the avatar we want to select id: selectionTimer onTriggered: { if (selected) { table.selection.clear(); // for now, no multi-select table.selection.select(userIndex); table.positionViewAtRow(userIndex, ListView.Beginning); } else { table.selection.deselect(userIndex); } } } function findSessionIndex(sessionId, optionalData) { // no findIndex in .qml var data = optionalData || userModelData, length = data.length; for (var i = 0; i < length; i++) { if (data[i].sessionId === sessionId) { return i; } } return -1; } function fromScript(message) { switch (message.method) { case 'users': var data = message.params; var index = -1; index = findSessionIndex('', data); if (index !== -1) { iAmAdmin = Users.canKick; myData = data[index]; data.splice(index, 1); } else { console.log("This user's data was not found in the user list. PAL will not function properly."); } userModelData = data; for (var ignoredID in ignored) { index = findSessionIndex(ignoredID); if (index === -1) { // Add back any missing ignored to the PAL, because they sometimes take a moment to show up. userModelData.push(ignored[ignoredID]); } else { // Already appears in PAL; update properties of existing element in model data userModelData[index] = ignored[ignoredID]; } } sortModel(); break; case 'select': var sessionIds = message.params[0]; var selected = message.params[1]; var alreadyRefreshed = message.params[2]; var userIndex = findSessionIndex(sessionIds[0]); if (sessionIds.length > 1) { letterbox("", "", 'Only one user can be selected at a time.'); } else if (userIndex < 0) { // If we've already refreshed the PAL and the avatar still isn't present in the model... if (alreadyRefreshed === true) { letterbox('', '', 'The last editor of this object is either you or not among this list of users.'); } else { pal.sendToScript({method: 'refresh', params: message.params}); } } else { // If we've already refreshed the PAL and found the avatar in the model if (alreadyRefreshed === true) { // Wait a little bit before trying to actually select the avatar in the table selectionTimer.interval = 250; } else { // If we've found the avatar in the model and didn't need to refresh, // select the avatar in the table immediately selectionTimer.interval = 0; } selectionTimer.selected = selected; selectionTimer.userIndex = userIndex; selectionTimer.start(); } break; // Received an "updateUsername()" request from the JS case 'updateUsername': // The User ID (UUID) is the first parameter in the message. var userId = message.params[0]; // The text that goes in the userName field is the second parameter in the message. var userName = message.params[1]; var admin = message.params[2]; // If the userId is empty, we're updating "myData". if (!userId) { myData.userName = userName; myCard.userName = userName; // Defensive programming } else { // Get the index in userModel and userModelData associated with the passed UUID var userIndex = findSessionIndex(userId); if (userIndex != -1) { // Set the userName appropriately userModel.setProperty(userIndex, "userName", userName); userModelData[userIndex].userName = userName; // Defensive programming // Set the admin status appropriately userModel.setProperty(userIndex, "admin", admin); userModelData[userIndex].admin = admin; // Defensive programming } } break; case 'updateAudioLevel': for (var userId in message.params) { var audioLevel = message.params[userId]; // If the userId is 0, we're updating "myData". if (userId == 0) { myData.audioLevel = audioLevel; myCard.audioLevel = audioLevel; // Defensive programming } else { var userIndex = findSessionIndex(userId); if (userIndex != -1) { userModel.setProperty(userIndex, "audioLevel", audioLevel); userModelData[userIndex].audioLevel = audioLevel; // Defensive programming } } } break; case 'clearLocalQMLData': ignored = {}; gainSliderValueDB = {}; break; case 'avatarDisconnected': var sessionID = message.params[0]; delete ignored[sessionID]; break; default: console.log('Unrecognized message:', JSON.stringify(message)); } } function sortModel() { var sortProperty = table.getColumn(table.sortIndicatorColumn).role; var before = (table.sortIndicatorOrder === Qt.AscendingOrder) ? -1 : 1; var after = -1 * before; userModelData.sort(function (a, b) { var aValue = a[sortProperty].toString().toLowerCase(), bValue = b[sortProperty].toString().toLowerCase(); switch (true) { case (aValue < bValue): return before; case (aValue > bValue): return after; default: return 0; } }); table.selection.clear(); userModel.clear(); var userIndex = 0; userModelData.forEach(function (datum) { function init(property) { if (datum[property] === undefined) { datum[property] = false; } } ['personalMute', 'ignore', 'mute', 'kick'].forEach(init); datum.userIndex = userIndex++; userModel.append(datum); }); } signal sendToScript(var message); function noticeSelection() { var userIds = []; table.selection.forEach(function (userIndex) { userIds.push(userModelData[userIndex].sessionId); }); pal.sendToScript({method: 'selected', params: userIds}); } Connections { target: table.selection onSelectionChanged: pal.noticeSelection() } }