diff --git a/assignment-client/src/avatars/AvatarMixer.cpp b/assignment-client/src/avatars/AvatarMixer.cpp index 5ad4849f38..f09caac4b6 100644 --- a/assignment-client/src/avatars/AvatarMixer.cpp +++ b/assignment-client/src/avatars/AvatarMixer.cpp @@ -437,17 +437,20 @@ void AvatarMixer::handleNodeIgnoreRequestPacket(QSharedPointer while (message->getBytesLeftToRead()) { // parse out the UUID being ignored from the packet QUuid ignoredUUID = QUuid::fromRfc4122(message->readWithoutCopy(NUM_BYTES_RFC4122_UUID)); - // Reset the lastBroadcastTime for the ignored avatar to 0 - // so the AvatarMixer knows it'll have to send identity data about the ignored avatar - // to the ignorer if the ignorer unignores. - nodeData->setLastBroadcastTime(ignoredUUID, 0); - // Reset the lastBroadcastTime for the ignorer (FROM THE PERSPECTIVE OF THE IGNORED) to 0 - // so the AvatarMixer knows it'll have to send identity data about the ignorer - // to the ignored if the ignorer unignores. - auto ignoredNode = nodeList->nodeWithUUID(ignoredUUID); - AvatarMixerClientData* ignoredNodeData = reinterpret_cast(ignoredNode->getLinkedData()); - ignoredNodeData->setLastBroadcastTime(senderNode->getUUID(), 0); + if (nodeList->nodeWithUUID(ignoredUUID)) { + // Reset the lastBroadcastTime for the ignored avatar to 0 + // so the AvatarMixer knows it'll have to send identity data about the ignored avatar + // to the ignorer if the ignorer unignores. + nodeData->setLastBroadcastTime(ignoredUUID, 0); + + // Reset the lastBroadcastTime for the ignorer (FROM THE PERSPECTIVE OF THE IGNORED) to 0 + // so the AvatarMixer knows it'll have to send identity data about the ignorer + // to the ignored if the ignorer unignores. + auto ignoredNode = nodeList->nodeWithUUID(ignoredUUID); + AvatarMixerClientData* ignoredNodeData = reinterpret_cast(ignoredNode->getLinkedData()); + ignoredNodeData->setLastBroadcastTime(senderNode->getUUID(), 0); + } if (addToIgnore) { senderNode->addIgnoredNode(ignoredUUID); diff --git a/interface/resources/icons/connection.svg b/interface/resources/icons/connection.svg new file mode 100644 index 0000000000..05b23abf9a --- /dev/null +++ b/interface/resources/icons/connection.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/interface/resources/icons/profilePicLoading.gif b/interface/resources/icons/profilePicLoading.gif new file mode 100644 index 0000000000..4500f4dda3 Binary files /dev/null and b/interface/resources/icons/profilePicLoading.gif differ diff --git a/interface/resources/qml/controls-uit/CheckBox.qml b/interface/resources/qml/controls-uit/CheckBox.qml index 9fc484586a..60e136d13a 100644 --- a/interface/resources/qml/controls-uit/CheckBox.qml +++ b/interface/resources/qml/controls-uit/CheckBox.qml @@ -90,7 +90,7 @@ Original.CheckBox { label: Label { text: control.text colorScheme: checkBox.colorScheme - x: checkBox.boxSize / 2 + x: 2 wrapMode: Text.Wrap enabled: checkBox.enabled } diff --git a/interface/resources/qml/controls-uit/ComboBox.qml b/interface/resources/qml/controls-uit/ComboBox.qml index 2dea535c06..be6a439e57 100644 --- a/interface/resources/qml/controls-uit/ComboBox.qml +++ b/interface/resources/qml/controls-uit/ComboBox.qml @@ -216,7 +216,7 @@ FocusScope { anchors.leftMargin: hifi.dimensions.textPadding anchors.verticalCenter: parent.verticalCenter id: popupText - text: listView.model[index] ? listView.model[index] : "" + text: listView.model[index] ? listView.model[index] : (listView.model.get(index).text ? listView.model.get(index).text : "") size: hifi.fontSizes.textFieldInput color: hifi.colors.baseGray } diff --git a/interface/resources/qml/controls-uit/Table.qml b/interface/resources/qml/controls-uit/Table.qml index c7e0809b29..11d1920f95 100644 --- a/interface/resources/qml/controls-uit/Table.qml +++ b/interface/resources/qml/controls-uit/Table.qml @@ -48,11 +48,12 @@ TableView { HiFiGlyphs { id: titleSort text: sortIndicatorOrder == Qt.AscendingOrder ? hifi.glyphs.caratUp : hifi.glyphs.caratDn - color: hifi.colors.baseGrayHighlight + color: hifi.colors.darkGray + opacity: 0.6; size: hifi.fontSizes.tableHeadingIcon anchors { left: titleText.right - leftMargin: -hifi.fontSizes.tableHeadingIcon / 3 - (centerHeaderText ? 3 : 0) + leftMargin: -hifi.fontSizes.tableHeadingIcon / 3 - (centerHeaderText ? 5 : 0) right: parent.right rightMargin: hifi.dimensions.tablePadding verticalCenter: titleText.verticalCenter @@ -89,7 +90,6 @@ TableView { Rectangle { color: "#00000000" anchors { fill: parent; margins: -2 } - radius: hifi.dimensions.borderRadius border.color: isLightColorScheme ? hifi.colors.lightGrayText : hifi.colors.baseGrayHighlight border.width: 2 } diff --git a/interface/resources/qml/controls/WebView.qml b/interface/resources/qml/controls/WebView.qml index ae96590e97..a3badd7e1f 100644 --- a/interface/resources/qml/controls/WebView.qml +++ b/interface/resources/qml/controls/WebView.qml @@ -8,6 +8,8 @@ Item { property alias url: root.url property alias scriptURL: root.userScriptUrl property alias eventBridge: eventBridgeWrapper.eventBridge + property alias canGoBack: root.canGoBack; + property var goBack: root.goBack; property bool keyboardEnabled: true // FIXME - Keyboard HMD only: Default to false property bool keyboardRaised: false property bool punctuationMode: false @@ -101,11 +103,11 @@ Item { } onNewViewRequested:{ - // desktop is not defined for web-entities - if (desktop) { - var component = Qt.createComponent("../Browser.qml"); - var newWindow = component.createObject(desktop); - request.openIn(newWindow.webView); + // desktop is not defined for web-entities or tablet + if (typeof desktop !== "undefined") { + desktop.openBrowserWindow(request, profile); + } else { + console.log("onNewViewRequested: desktop not defined"); } } } diff --git a/interface/resources/qml/desktop/Desktop.qml b/interface/resources/qml/desktop/Desktop.qml index cc64d0f2b4..d8aedf6666 100644 --- a/interface/resources/qml/desktop/Desktop.qml +++ b/interface/resources/qml/desktop/Desktop.qml @@ -490,6 +490,13 @@ FocusScope { desktop.forceActiveFocus(); } + function openBrowserWindow(request, profile) { + var component = Qt.createComponent("../Browser.qml"); + var newWindow = component.createObject(desktop); + newWindow.webView.profile = profile; + request.openIn(newWindow.webView); + } + FocusHack { id: focusHack; } Rectangle { diff --git a/interface/resources/qml/hifi/ComboDialog.qml b/interface/resources/qml/hifi/ComboDialog.qml new file mode 100644 index 0000000000..f5bb7dcfcc --- /dev/null +++ b/interface/resources/qml/hifi/ComboDialog.qml @@ -0,0 +1,154 @@ +// +// ComboDialog.qml +// qml/hifi +// +// Created by Zach Fox on 3/31/2017 +// Copyright 2017 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" + +Item { + property var dialogTitleText; + property var optionTitleText; + property var optionBodyText; + property var optionValues; + property var selectedOptionIndex; + property int dialogWidth; + property int dialogHeight; + property int comboOptionTextSize: 18; + FontLoader { id: ralewayRegular; source: "../../fonts/Raleway-Regular.ttf"; } + FontLoader { id: ralewaySemiBold; source: "../../fonts/Raleway-SemiBold.ttf"; } + visible: false; + id: combo; + anchors.fill: parent; + onVisibleChanged: { + populateComboListViewModel(); + } + + Rectangle { + id: dialogBackground; + anchors.fill: parent; + color: "black"; + opacity: 0.5; + } + + Rectangle { + id: dialogContainer; + color: "white"; + anchors.centerIn: dialogBackground; + width: dialogWidth; + height: dialogHeight; + + RalewayRegular { + id: dialogTitle; + text: dialogTitleText; + anchors.top: parent.top; + anchors.topMargin: 20; + anchors.left: parent.left; + anchors.leftMargin: 20; + size: 24; + color: 'black'; + horizontalAlignment: Text.AlignLeft; + verticalAlignment: Text.AlignTop; + } + + ListModel { + id: comboListViewModel; + } + + ListView { + id: comboListView; + anchors.top: dialogTitle.bottom; + anchors.topMargin: 20; + anchors.bottom: parent.bottom; + anchors.left: parent.left; + anchors.right: parent.right; + model: comboListViewModel; + delegate: comboListViewDelegate; + + Component { + id: comboListViewDelegate; + Rectangle { + id: comboListViewItemContainer; + // Size + height: childrenRect.height + 10; + width: dialogContainer.width; + color: selectedOptionIndex === index ? '#cee6ff' : 'white'; + Rectangle { + id: comboOptionSelected; + visible: selectedOptionIndex === index ? true : false; + color: hifi.colors.blueAccent; + anchors.left: parent.left; + anchors.leftMargin: 20; + anchors.top: parent.top; + anchors.topMargin: 20; + width: 25; + height: width; + radius: width; + border.width: 3; + border.color: hifi.colors.blueHighlight; + } + + + RalewaySemiBold { + id: optionTitle; + text: titleText; + anchors.top: parent.top; + anchors.left: comboOptionSelected.right; + anchors.leftMargin: 20; + anchors.right: parent.right; + height: 30; + size: comboOptionTextSize; + wrapMode: Text.WordWrap; + } + + RalewayRegular { + id: optionBody; + text: bodyText; + anchors.top: optionTitle.bottom; + anchors.bottom: parent.bottom; + anchors.left: comboOptionSelected.right; + anchors.leftMargin: 25; + anchors.right: parent.right; + size: comboOptionTextSize; + wrapMode: Text.WordWrap; + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + hoverEnabled: true; + onEntered: comboListViewItemContainer.color = hifi.colors.blueHighlight + onExited: comboListViewItemContainer.color = selectedOptionIndex === index ? '#cee6ff' : 'white'; + onClicked: { + GlobalServices.findableBy = optionValue; + UserActivityLogger.palAction("set_availability", optionValue); + print('Setting availability:', optionValue); + } + } + } + } + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: { + combo.visible = false; + } + } + + function populateComboListViewModel() { + comboListViewModel.clear(); + optionTitleText.forEach(function(titleText, index) { + comboListViewModel.insert(index, {"titleText": titleText, "bodyText": optionBodyText[index], "optionValue": optionValues[index]}); + }); + } +} \ No newline at end of file diff --git a/interface/resources/qml/hifi/NameCard.qml b/interface/resources/qml/hifi/NameCard.qml index 020a85b46d..86cc0218a4 100644 --- a/interface/resources/qml/hifi/NameCard.qml +++ b/interface/resources/qml/hifi/NameCard.qml @@ -14,392 +14,499 @@ import QtQuick.Controls 1.4 import QtQuick.Controls.Styles 1.4 import QtGraphicalEffects 1.0 import "../styles-uit" +import "toolbars" + +// references Users, UserActivityLogger, MyAvatar, Vec3, Quat, AddressManager from root context Item { id: thisNameCard - // Anchors - anchors { - verticalCenter: parent.verticalCenter - leftMargin: 10 - rightMargin: 10 - } + // Size + width: isMyCard ? pal.myCardWidth - anchors.leftMargin : pal.nearbyNameCardWidth; + height: isMyCard ? pal.myCardHeight : pal.rowHeight; + anchors.left: parent.left + anchors.leftMargin: 5 + anchors.top: parent.top; // Properties + property string profileUrl: ""; + property string defaultBaseUrl: AddressManager.metaverseServerUrl; + property string connectionStatus : "" property string uuid: "" property string displayName: "" property string userName: "" property real displayNameTextPixelSize: 18 - property int usernameTextHeight: 12 + property int usernameTextPixelSize: 14 property real audioLevel: 0.0 property real avgAudioLevel: 0.0 property bool isMyCard: false property bool selected: false property bool isAdmin: false - property bool currentlyEditingDisplayName: false + property bool isPresent: true + property string profilePicBorderColor: (connectionStatus == "connection" ? hifi.colors.indigoAccent : (connectionStatus == "friend" ? hifi.colors.greenHighlight : "transparent")) - /* User image commented out for now - will probably be re-introduced later. - Column { + Item { id: avatarImage + visible: profileUrl !== "" && userName !== ""; // Size - height: parent.height - width: height + height: isMyCard ? 70 : 42; + width: visible ? height : 0; + anchors.top: parent.top; + anchors.topMargin: isMyCard ? 0 : 8; + anchors.left: parent.left + clip: true Image { id: userImage - source: "../../icons/defaultNameCardUser.png" + source: profileUrl !== "" ? ((0 === profileUrl.indexOf("http")) ? profileUrl : (defaultBaseUrl + profileUrl)) : ""; + mipmap: true; // Anchors + anchors.fill: parent + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Item { + width: userImage.width; + height: userImage.height; + Rectangle { + anchors.centerIn: parent; + width: userImage.width; // This works because userImage is square + height: width; + radius: width; + } + } + } + } + AnimatedImage { + source: "../../icons/profilePicLoading.gif" + anchors.fill: parent; + visible: userImage.status != Image.Ready; + } + StateImage { + id: infoHoverImage; + visible: false; + imageURL: "../../images/info-icon-2-state.svg"; + size: 32; + buttonState: 1; + anchors.centerIn: parent; + } + MouseArea { + anchors.fill: parent + enabled: selected || isMyCard; + hoverEnabled: enabled + onClicked: { + userInfoViewer.url = defaultBaseUrl + "/users/" + userName; + userInfoViewer.visible = true; + } + onEntered: infoHoverImage.visible = true; + onExited: infoHoverImage.visible = false; + } + } + + // Colored border around avatarImage + Rectangle { + id: avatarImageBorder; + visible: avatarImage.visible; + anchors.verticalCenter: avatarImage.verticalCenter; + anchors.horizontalCenter: avatarImage.horizontalCenter; + width: avatarImage.width + border.width; + height: avatarImage.height + border.width; + color: "transparent" + radius: avatarImage.height; + border.color: profilePicBorderColor; + border.width: 4; + } + + // DisplayName field for my card + Rectangle { + id: myDisplayName + visible: isMyCard + // Size + width: parent.width - avatarImage.width - anchors.leftMargin - anchors.rightMargin*2; + height: 40 + // Anchors + anchors.top: avatarImage.top + anchors.left: avatarImage.right + anchors.leftMargin: avatarImage.visible ? 5 : 0; + anchors.rightMargin: 5; + // Style + color: hifi.colors.textFieldLightBackground + border.color: hifi.colors.blueHighlight + border.width: 0 + TextInput { + id: myDisplayNameText + // Properties + text: thisNameCard.displayName + maximumLength: 256 + clip: true + // Size width: parent.width height: parent.height - } - } - */ - Item { - id: textContainer - // Size - width: parent.width - /*avatarImage.width - parent.spacing - */parent.anchors.leftMargin - parent.anchors.rightMargin - height: selected || isMyCard ? childrenRect.height : childrenRect.height - 15 - anchors.verticalCenter: parent.verticalCenter - - // DisplayName field for my card - Rectangle { - id: myDisplayName - visible: isMyCard - // Size - width: parent.width + 70 - height: 35 // Anchors - anchors.top: parent.top + anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - anchors.leftMargin: -10 + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: editGlyph.width + editGlyph.anchors.rightMargin // Style - color: hifi.colors.textFieldLightBackground - border.color: hifi.colors.blueHighlight - border.width: 0 - TextInput { - id: myDisplayNameText - // Properties - text: thisNameCard.displayName - maximumLength: 256 - clip: true - // Size - width: parent.width - height: parent.height - // Anchors - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.right: parent.right - anchors.rightMargin: editGlyph.width + editGlyph.anchors.rightMargin - // Style - color: hifi.colors.darkGray - FontLoader { id: firaSansSemiBold; source: "../../fonts/FiraSans-SemiBold.ttf"; } - font.family: firaSansSemiBold.name - font.pixelSize: displayNameTextPixelSize - selectionColor: hifi.colors.blueHighlight - selectedTextColor: "black" - // Text Positioning - verticalAlignment: TextInput.AlignVCenter - horizontalAlignment: TextInput.AlignLeft - // Signals - onEditingFinished: { - pal.sendToScript({method: 'displayNameUpdate', params: text}) - cursorPosition = 0 - focus = false - myDisplayName.border.width = 0 - color = hifi.colors.darkGray - currentlyEditingDisplayName = false - } - } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - hoverEnabled: true - onClicked: { - myDisplayName.border.width = 1 - myDisplayNameText.focus ? myDisplayNameText.cursorPosition = myDisplayNameText.positionAt(mouseX, mouseY, TextInput.CursorOnCharacter) : myDisplayNameText.selectAll(); - myDisplayNameText.focus = true - myDisplayNameText.color = "black" - currentlyEditingDisplayName = true - } - onDoubleClicked: { - myDisplayNameText.selectAll(); - myDisplayNameText.focus = true; - currentlyEditingDisplayName = true - } - onEntered: myDisplayName.color = hifi.colors.lightGrayText - onExited: myDisplayName.color = hifi.colors.textFieldLightBackground - } - // Edit pencil glyph - HiFiGlyphs { - id: editGlyph - text: hifi.glyphs.editPencil - // Text Size - size: displayNameTextPixelSize*1.5 - // Anchors - anchors.right: parent.right - anchors.rightMargin: 5 - anchors.verticalCenter: parent.verticalCenter - // Style - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - color: hifi.colors.baseGray - } - } - // Spacer for DisplayName for my card - Item { - id: myDisplayNameSpacer - width: 1 - height: 4 - // Anchors - anchors.top: myDisplayName.bottom - } - // DisplayName container for others' cards - Item { - id: displayNameContainer - visible: !isMyCard - // Size - width: parent.width - height: displayNameTextPixelSize + 4 - // Anchors - anchors.top: parent.top - anchors.left: parent.left - // DisplayName Text for others' cards - FiraSansSemiBold { - id: displayNameText - // Properties - text: thisNameCard.displayName - elide: Text.ElideRight - // Size - width: isAdmin ? Math.min(displayNameTextMetrics.tightBoundingRect.width + 8, parent.width - adminLabelText.width - adminLabelQuestionMark.width + 8) : parent.width - // Anchors - anchors.top: parent.top - anchors.left: parent.left - // Text Size - size: displayNameTextPixelSize - // Text Positioning - verticalAlignment: Text.AlignVCenter - // Style - color: hifi.colors.darkGray - } - TextMetrics { - id: displayNameTextMetrics - font: displayNameText.font - text: displayNameText.text - } - // "ADMIN" label for other users' cards - RalewaySemiBold { - id: adminLabelText - visible: isAdmin - text: "ADMIN" - // Text size - size: displayNameText.size - 4 - // Anchors - anchors.verticalCenter: parent.verticalCenter - anchors.left: displayNameText.right - // Style - font.capitalization: Font.AllUppercase - color: hifi.colors.redHighlight - // Alignment - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignTop - } - // This Rectangle refers to the [?] popup button next to "ADMIN" - Item { - id: adminLabelQuestionMark - visible: isAdmin - // Size - width: 20 - height: displayNameText.height - // Anchors - anchors.verticalCenter: parent.verticalCenter - anchors.left: adminLabelText.right - RalewayRegular { - id: adminLabelQuestionMarkText - text: "[?]" - size: adminLabelText.size - 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, - "Domain Admin", - "This user is an admin on this domain. Admins can Silence and Ban other users at their discretion - so be extra nice!") - onEntered: adminLabelQuestionMarkText.color = "#94132e" - onExited: adminLabelQuestionMarkText.color = hifi.colors.redHighlight - } - } - } - - // UserName Text - FiraSansRegular { - id: userNameText - // Properties - text: thisNameCard.userName - elide: Text.ElideRight - visible: thisNameCard.displayName - // Size - width: parent.width - // Anchors - anchors.top: isMyCard ? myDisplayNameSpacer.bottom : displayNameContainer.bottom - // Text Size - size: thisNameCard.usernameTextHeight + color: hifi.colors.darkGray + FontLoader { id: firaSansSemiBold; source: "../../fonts/FiraSans-SemiBold.ttf"; } + font.family: firaSansSemiBold.name + font.pixelSize: displayNameTextPixelSize + selectionColor: hifi.colors.blueAccent + selectedTextColor: "black" // Text Positioning - verticalAlignment: Text.AlignVCenter + verticalAlignment: TextInput.AlignVCenter + horizontalAlignment: TextInput.AlignLeft + autoScroll: false; + // Signals + onEditingFinished: { + if (MyAvatar.displayName !== text) { + MyAvatar.displayName = text; + UserActivityLogger.palAction("display_name_change", text); + } + cursorPosition = 0 + focus = false + myDisplayName.border.width = 0 + color = hifi.colors.darkGray + pal.currentlyEditingDisplayName = false + autoScroll = false; + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + myDisplayName.border.width = 1 + myDisplayNameText.focus ? myDisplayNameText.cursorPosition = myDisplayNameText.positionAt(mouseX, mouseY, TextInput.CursorOnCharacter) : myDisplayNameText.selectAll(); + myDisplayNameText.focus = true + myDisplayNameText.color = "black" + pal.currentlyEditingDisplayName = true + myDisplayNameText.autoScroll = true; + } + onDoubleClicked: { + myDisplayNameText.selectAll(); + myDisplayNameText.focus = true; + pal.currentlyEditingDisplayName = true + myDisplayNameText.autoScroll = true; + } + onEntered: myDisplayName.color = hifi.colors.lightGrayText; + onExited: myDisplayName.color = hifi.colors.textFieldLightBackground; + } + // Edit pencil glyph + HiFiGlyphs { + id: editGlyph + text: hifi.glyphs.editPencil + // Text Size + size: displayNameTextPixelSize*1.5 + // Anchors + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.verticalCenter: parent.verticalCenter // Style + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter color: hifi.colors.baseGray } - - // Spacer - Item { - id: userNameSpacer - height: 4 - width: parent.width - // Anchors - anchors.top: userNameText.bottom - } - - // VU Meter - Rectangle { - id: nameCardVUMeter - // Size - width: isMyCard ? myDisplayName.width - 70 : ((gainSlider.value - gainSlider.minimumValue)/(gainSlider.maximumValue - gainSlider.minimumValue)) * parent.width - height: 8 - // Anchors - anchors.top: userNameSpacer.bottom - // Style - radius: 4 - color: "#c5c5c5" - visible: isMyCard || selected - // Rectangle for the zero-gain point on the VU meter - Rectangle { - id: vuMeterZeroGain - visible: gainSlider.visible - // Size - width: 4 - height: 18 - // Style - color: hifi.colors.darkGray - // Anchors - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: (-gainSlider.minimumValue)/(gainSlider.maximumValue - gainSlider.minimumValue) * gainSlider.width - 4 - } - // Rectangle for the VU meter line - Rectangle { - id: vuMeterLine - width: gainSlider.width - visible: gainSlider.visible - // Style - color: vuMeterBase.color - radius: nameCardVUMeter.radius - height: nameCardVUMeter.height / 2 - anchors.verticalCenter: nameCardVUMeter.verticalCenter - } - // Rectangle for the VU meter base - Rectangle { - id: vuMeterBase - // Anchors - anchors.fill: parent - visible: isMyCard || selected - // Style - color: parent.color - radius: parent.radius - } - // Rectangle for the VU meter audio level - Rectangle { - id: vuMeterLevel - visible: isMyCard || selected - // Size - width: (thisNameCard.audioLevel) * parent.width - // Style - color: parent.color - radius: parent.radius - // Anchors - anchors.bottom: parent.bottom - anchors.top: parent.top - anchors.left: parent.left - } - // Gradient for the VU meter audio level - LinearGradient { - anchors.fill: vuMeterLevel - source: vuMeterLevel - start: Qt.point(0, 0) - end: Qt.point(parent.width, 0) - gradient: Gradient { - GradientStop { position: 0.0; color: "#2c8e72" } - GradientStop { position: 0.9; color: "#1fc6a6" } - GradientStop { position: 0.91; color: "#ea4c5f" } - GradientStop { position: 1.0; color: "#ea4c5f" } - } - } - } - - // Per-Avatar Gain Slider - Slider { - id: gainSlider - // Size - width: parent.width - height: 14 - // Anchors - anchors.verticalCenter: nameCardVUMeter.verticalCenter + } + // DisplayName container for others' cards + Item { + id: displayNameContainer + visible: !isMyCard && pal.activeTab !== "connectionsTab" + // Size + width: parent.width - anchors.leftMargin - avatarImage.width - anchors.leftMargin; + height: displayNameTextPixelSize + 4 + // Anchors + anchors.top: avatarImage.top; + anchors.left: avatarImage.right + anchors.leftMargin: avatarImage.visible ? 5 : 0; + // DisplayName Text for others' cards + FiraSansSemiBold { + id: displayNameText // Properties - visible: !isMyCard && selected - value: Users.getAvatarGain(uuid) - minimumValue: -60.0 - maximumValue: 20.0 - stepSize: 5 - updateValueWhileDragging: true - onValueChanged: updateGainFromQML(uuid, value, false) - onPressedChanged: { - if (!pressed) { - updateGainFromQML(uuid, value, true) + text: thisNameCard.displayName + elide: Text.ElideRight + // Size + width: isAdmin ? Math.min(displayNameTextMetrics.tightBoundingRect.width + 8, parent.width - adminLabelText.width - adminLabelQuestionMark.width + 8) : parent.width + // Anchors + anchors.top: parent.top + anchors.left: parent.left + // Text Size + size: displayNameTextPixelSize + // Text Positioning + verticalAlignment: Text.AlignTop + // Style + color: hifi.colors.darkGray; + MouseArea { + anchors.fill: parent + enabled: selected && pal.activeTab == "nearbyTab" && thisNameCard.userName !== "" && isPresent; + hoverEnabled: enabled + onClicked: { + goToUserInDomain(thisNameCard.uuid); + UserActivityLogger.palAction("go_to_user_in_domain", thisNameCard.uuid); } + onEntered: { + displayNameText.color = hifi.colors.blueHighlight; + userNameText.color = hifi.colors.blueHighlight; + } + onExited: { + displayNameText.color = hifi.colors.darkGray + userNameText.color = hifi.colors.blueAccent; + } + } + } + TextMetrics { + id: displayNameTextMetrics + font: displayNameText.font + text: displayNameText.text + } + // "ADMIN" label for other users' cards + RalewaySemiBold { + id: adminLabelText + visible: isAdmin + text: "ADMIN" + // Text size + size: displayNameText.size - 4 + // Anchors + anchors.verticalCenter: parent.verticalCenter + anchors.left: displayNameText.right + // Style + font.capitalization: Font.AllUppercase + color: hifi.colors.redHighlight + // Alignment + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignTop + } + // This Rectangle refers to the [?] popup button next to "ADMIN" + Item { + id: adminLabelQuestionMark + visible: isAdmin + // Size + width: 20 + height: displayNameText.height + // Anchors + anchors.verticalCenter: parent.verticalCenter + anchors.left: adminLabelText.right + RalewayRegular { + id: adminLabelQuestionMarkText + text: "[?]" + size: adminLabelText.size + font.capitalization: Font.AllUppercase + color: hifi.colors.redHighlight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + anchors.fill: parent } MouseArea { anchors.fill: parent - onWheel: { - // Do nothing. - } - onDoubleClicked: { - gainSlider.value = 0.0 - } - onPressed: { - // Pass through to Slider - mouse.accepted = false - } - onReleased: { - // the above mouse.accepted seems to make this - // never get called, nonetheless... - mouse.accepted = false - } - } - style: SliderStyle { - groove: Rectangle { - color: "#c5c5c5" - implicitWidth: gainSlider.width - implicitHeight: 4 - radius: 2 - opacity: 0 - } - handle: Rectangle { - anchors.centerIn: parent - color: (control.pressed || control.hovered) ? "#00b4ef" : "#8F8F8F" - implicitWidth: 10 - implicitHeight: 16 - } + enabled: isPresent + hoverEnabled: enabled + onClicked: letterbox(hifi.glyphs.question, + "Domain Admin", + "This user is an admin on this domain. Admins can Silence and Ban other users at their discretion - so be extra nice!") + onEntered: adminLabelQuestionMarkText.color = "#94132e" + onExited: adminLabelQuestionMarkText.color = hifi.colors.redHighlight } } } + // UserName Text + FiraSansRegular { + id: userNameText + // Properties + text: thisNameCard.userName === "Unknown user" ? "not logged in" : thisNameCard.userName; + elide: Text.ElideRight + visible: thisNameCard.userName !== ""; + // Size + width: parent.width + height: pal.activeTab == "nearbyTab" || isMyCard ? usernameTextPixelSize + 4 : parent.height; + // Anchors + anchors.top: isMyCard ? myDisplayName.bottom : (pal.activeTab == "nearbyTab" ? displayNameContainer.bottom : parent.top); + anchors.left: avatarImage.right; + anchors.leftMargin: avatarImage.visible ? 5 : 0; + anchors.rightMargin: 5; + // Text Size + size: pal.activeTab == "nearbyTab" || isMyCard ? usernameTextPixelSize : displayNameTextPixelSize; + // Text Positioning + verticalAlignment: Text.AlignVCenter; + // Style + color: hifi.colors.blueAccent; + MouseArea { + anchors.fill: parent + enabled: selected && pal.activeTab == "nearbyTab" && thisNameCard.userName !== "" && isPresent; + hoverEnabled: enabled + onClicked: { + goToUserInDomain(thisNameCard.uuid); + UserActivityLogger.palAction("go_to_user_in_domain", thisNameCard.uuid); + } + onEntered: { + displayNameText.color = hifi.colors.blueHighlight; + userNameText.color = hifi.colors.blueHighlight; + } + onExited: { + displayNameText.color = hifi.colors.darkGray; + userNameText.color = hifi.colors.blueAccent; + } + } + } + // VU Meter + Rectangle { + id: nameCardVUMeter + // Size + width: isMyCard ? myDisplayName.width - 20 : ((gainSlider.value - gainSlider.minimumValue)/(gainSlider.maximumValue - gainSlider.minimumValue)) * (gainSlider.width); + height: 8 + // Anchors + anchors.bottom: isMyCard ? avatarImage.bottom : parent.bottom; + anchors.bottomMargin: isMyCard ? 0 : height; + anchors.left: isMyCard ? userNameText.left : parent.left; + // Style + radius: 4 + color: "#c5c5c5" + visible: (isMyCard || (selected && pal.activeTab == "nearbyTab")) && isPresent + // Rectangle for the zero-gain point on the VU meter + Rectangle { + id: vuMeterZeroGain + visible: gainSlider.visible + // Size + width: 4 + height: 18 + // Style + color: hifi.colors.darkGray + // Anchors + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: (-gainSlider.minimumValue)/(gainSlider.maximumValue - gainSlider.minimumValue) * gainSlider.width - 4 + } + // Rectangle for the VU meter line + Rectangle { + id: vuMeterLine + width: gainSlider.width + visible: gainSlider.visible + // Style + color: vuMeterBase.color + radius: nameCardVUMeter.radius + height: nameCardVUMeter.height / 2 + anchors.verticalCenter: nameCardVUMeter.verticalCenter + } + // Rectangle for the VU meter base + Rectangle { + id: vuMeterBase + // Anchors + anchors.fill: parent + visible: isMyCard || selected + // Style + color: parent.color + radius: parent.radius + } + // Rectangle for the VU meter audio level + Rectangle { + id: vuMeterLevel + visible: isMyCard || selected + // Size + width: (thisNameCard.audioLevel) * parent.width + // Style + color: parent.color + radius: parent.radius + // Anchors + anchors.bottom: parent.bottom + anchors.top: parent.top + anchors.left: parent.left + } + // Gradient for the VU meter audio level + LinearGradient { + anchors.fill: vuMeterLevel + source: vuMeterLevel + start: Qt.point(0, 0) + end: Qt.point(parent.width, 0) + gradient: Gradient { + GradientStop { position: 0.0; color: "#2c8e72" } + GradientStop { position: 0.9; color: "#1fc6a6" } + GradientStop { position: 0.91; color: "#ea4c5f" } + GradientStop { position: 1.0; color: "#ea4c5f" } + } + } + } + + // Per-Avatar Gain Slider + Slider { + id: gainSlider + // Size + width: thisNameCard.width; + height: 14 + // Anchors + anchors.verticalCenter: nameCardVUMeter.verticalCenter; + anchors.left: nameCardVUMeter.left; + // Properties + visible: !isMyCard && selected && pal.activeTab == "nearbyTab" && isPresent; + value: Users.getAvatarGain(uuid) + minimumValue: -60.0 + maximumValue: 20.0 + stepSize: 5 + updateValueWhileDragging: true + onValueChanged: { + if (uuid !== "") { + updateGainFromQML(uuid, value, false); + } + } + onPressedChanged: { + if (!pressed) { + updateGainFromQML(uuid, value, true) + } + } + MouseArea { + anchors.fill: parent + onWheel: { + // Do nothing. + } + onDoubleClicked: { + gainSlider.value = 0.0 + } + onPressed: { + // Pass through to Slider + mouse.accepted = false + } + onReleased: { + // the above mouse.accepted seems to make this + // never get called, nonetheless... + mouse.accepted = false + } + } + style: SliderStyle { + groove: Rectangle { + color: "#c5c5c5" + implicitWidth: gainSlider.width + implicitHeight: 4 + radius: 2 + opacity: 0 + } + handle: Rectangle { + anchors.centerIn: parent + color: (control.pressed || control.hovered) ? "#00b4ef" : "#8F8F8F" + implicitWidth: 10 + implicitHeight: 16 + } + } + } + function updateGainFromQML(avatarUuid, sliderValue, isReleased) { Users.setAvatarGain(avatarUuid, sliderValue); if (isReleased) { UserActivityLogger.palAction("avatar_gain_changed", avatarUuid); } } + + // Function body by Howard Stearns 2017-01-08 + function goToUserInDomain(avatarUuid) { + var avatar = AvatarList.getAvatar(avatarUuid); + if (!avatar) { + console.log("This avatar is no longer present. goToUserInDomain() failed."); + return; + } + var vector = Vec3.subtract(avatar.position, MyAvatar.position); + var distance = Vec3.length(vector); + var target = Vec3.multiply(Vec3.normalize(vector), distance - 2.0); + // FIXME: We would like the avatar to recompute the avatar's "maybe fly" test at the new position, so that if high enough up, + // the avatar goes into fly mode rather than falling. However, that is not exposed to Javascript right now. + // FIXME: it would be nice if this used the same teleport steps and smoothing as in the teleport.js script. + // Note, however, that this script allows teleporting to a person in the air, while teleport.js is going to a grounded target. + MyAvatar.orientation = Quat.lookAtSimple(MyAvatar.position, avatar.position); + MyAvatar.position = Vec3.sum(MyAvatar.position, target); + } } diff --git a/interface/resources/qml/hifi/Pal.qml b/interface/resources/qml/hifi/Pal.qml index 25362d98f1..06360ce183 100644 --- a/interface/resources/qml/hifi/Pal.qml +++ b/interface/resources/qml/hifi/Pal.qml @@ -16,7 +16,8 @@ import QtQuick.Controls 1.4 import QtGraphicalEffects 1.0 import Qt.labs.settings 1.0 import "../styles-uit" -import "../controls-uit" as HifiControls +import "../controls-uit" as HifiControlsUit +import "../controls" as HifiControls // references HMD, Users, UserActivityLogger from root context @@ -28,25 +29,36 @@ Rectangle { // Style color: "#E3E3E3"; // Properties - property int myCardHeight: 90; - property int rowHeight: 70; + property int myCardWidth: width - upperRightInfoContainer.width; + property int myCardHeight: 80; + property int rowHeight: 60; property int actionButtonWidth: 55; - property int actionButtonAllowance: actionButtonWidth * 2; - property int minNameCardWidth: palContainer.width - (actionButtonAllowance * 2) - 4 - hifi.dimensions.scrollbarBackgroundWidth; - property int nameCardWidth: minNameCardWidth + (iAmAdmin ? 0 : actionButtonAllowance); - property var myData: ({displayName: "", userName: "", audioLevel: 0.0, avgAudioLevel: 0.0, admin: true}); // valid dummy until set + property int locationColumnWidth: 170; + property int nearbyNameCardWidth: nearbyTable.width - (iAmAdmin ? (actionButtonWidth * 4) : (actionButtonWidth * 2)) - 4 - hifi.dimensions.scrollbarBackgroundWidth; + property int connectionsNameCardWidth: connectionsTable.width - locationColumnWidth - actionButtonWidth - 4 - hifi.dimensions.scrollbarBackgroundWidth; + property var myData: ({profileUrl: "", displayName: "", userName: "", audioLevel: 0.0, avgAudioLevel: 0.0, admin: true, placeName: "", connection: "", isPresent: 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 var nearbyUserModelData: []; // This simple list is essentially a mirror of the nearbyUserModel listModel without all the extra complexities. + property var connectionsUserModelData: []; // This simple list is essentially a mirror of the connectionsUserModel listModel without all the extra complexities. property bool iAmAdmin: false; + property var activeTab: "nearbyTab"; + property bool currentlyEditingDisplayName: false + property bool punctuationMode: false; HifiConstants { id: hifi; } // The letterbox used for popup messages LetterboxMessage { - id: letterboxMessage; z: 999; // Force the popup on top of everything else } + // The ComboDialog used for setting availability + ComboDialog { + id: comboDialog; + z: 999; // Force the ComboDialog on top of everything else + dialogWidth: parent.width - 100; + dialogHeight: parent.height - 100; + } function letterbox(headerGlyph, headerText, message) { letterboxMessage.headerGlyph = headerGlyph; letterboxMessage.headerText = headerText; @@ -54,124 +66,280 @@ Rectangle { letterboxMessage.visible = true; letterboxMessage.popupRadius = 0; } + function popupComboDialog(dialogTitleText, optionTitleText, optionBodyText, optionValues) { + comboDialog.dialogTitleText = dialogTitleText; + comboDialog.optionTitleText = optionTitleText; + comboDialog.optionBodyText = optionBodyText; + comboDialog.optionValues = optionValues; + comboDialog.selectedOptionIndex = ['all', 'connections', 'friends', 'none'].indexOf(GlobalServices.findableBy); + comboDialog.populateComboListViewModel(); + comboDialog.visible = true; + } Settings { id: settings; category: "pal"; property bool filtered: false; property int nearDistance: 30; - property int sortIndicatorColumn: 1; - property int sortIndicatorOrder: Qt.AscendingOrder; + property int nearbySortIndicatorColumn: 1; + property int nearbySortIndicatorOrder: Qt.AscendingOrder; + property int connectionsSortIndicatorColumn: 0; + property int connectionsSortIndicatorOrder: Qt.AscendingOrder; } - function getSelectedSessionIDs() { + function getSelectedNearbySessionIDs() { var sessionIDs = []; - table.selection.forEach(function (userIndex) { - sessionIDs.push(userModelData[userIndex].sessionId); + nearbyTable.selection.forEach(function (userIndex) { + var datum = nearbyUserModelData[userIndex]; + if (datum) { // Might have been filtered out + sessionIDs.push(datum.sessionId); + } }); return sessionIDs; } - function refreshWithFilter() { - // We should just be able to set settings.filtered to filter.checked, but see #3249, so send to .js for saving. - var userIds = getSelectedSessionIDs(); - var params = {filter: filter.checked && {distance: settings.nearDistance}}; + function getSelectedConnectionsUserNames() { + var userNames = []; + connectionsTable.selection.forEach(function (userIndex) { + var datum = connectionsUserModelData[userIndex]; + if (datum) { + userNames.push(datum.userName); + } + }); + return userNames; + } + function refreshNearbyWithFilter() { + // We should just be able to set settings.filtered to inViewCheckbox.checked, but see #3249, so send to .js for saving. + var userIds = getSelectedNearbySessionIDs(); + var params = {filter: inViewCheckbox.checked && {distance: settings.nearDistance}}; if (userIds.length > 0) { params.selected = [[userIds[0]], true, true]; } - pal.sendToScript({method: 'refresh', params: params}); + pal.sendToScript({method: 'refreshNearby', params: params}); } - // 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; + Item { + id: palTabContainer; // Anchors - anchors.centerIn: pal; - // Properties - radius: hifi.dimensions.borderRadius; - - // This contains the current user's NameCard and will contain other information in the future + anchors { + top: myInfo.bottom; + bottom: parent.bottom; + left: parent.left; + right: parent.right; + } Rectangle { - id: myInfo; - // Size - width: palContainer.width; - height: myCardHeight; - // Style - color: pal.color; + id: tabSelectorContainer; // 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; - avgAudioLevel: myData.avgAudioLevel; - isMyCard: true; - // Size - width: minNameCardWidth; - height: parent.height; + anchors { + top: parent.top; + horizontalCenter: parent.horizontalCenter; + } + width: parent.width; + height: 50; + Rectangle { + id: nearbyTabSelector; // Anchors - anchors.left: parent.left; - } - Row { - HifiControls.CheckBox { - id: filter; - checked: settings.filtered; - text: "in view"; - boxSize: reload.height * 0.70; - onCheckedChanged: refreshWithFilter(); - } - HifiControls.GlyphButton { - id: reload; - glyph: hifi.glyphs.reload; - width: reload.height; - onClicked: refreshWithFilter(); - } - spacing: 50; anchors { - right: parent.right; top: parent.top; - topMargin: 10; + left: parent.left; + } + width: parent.width/2; + height: parent.height; + color: activeTab == "nearbyTab" ? "white" : "#CCCCCC"; + MouseArea { + anchors.fill: parent; + onClicked: { + if (activeTab != "nearbyTab") { + refreshNearbyWithFilter(); + } + activeTab = "nearbyTab"; + connectionsHelpText.color = hifi.colors.baseGray; + } + } + + // "NEARBY" Text Container + Item { + id: nearbyTabSelectorTextContainer; + anchors.fill: parent; + anchors.leftMargin: 15; + // "NEARBY" text + RalewaySemiBold { + id: nearbyTabSelectorText; + text: "NEARBY"; + // Text size + size: hifi.fontSizes.tabularData; + // Anchors + anchors.fill: parent; + // Style + font.capitalization: Font.AllUppercase; + color: activeTab === "nearbyTab" ? hifi.colors.blueAccent : hifi.colors.baseGray; + // Alignment + horizontalAlignment: Text.AlignHLeft; + verticalAlignment: Text.AlignVCenter; + } + // "In View" Checkbox + HifiControlsUit.CheckBox { + id: inViewCheckbox; + visible: activeTab == "nearbyTab"; + anchors.right: reloadNearbyContainer.left; + anchors.rightMargin: 25; + anchors.verticalCenter: parent.verticalCenter; + checked: settings.filtered; + text: "in view"; + boxSize: 24; + onCheckedChanged: refreshNearbyWithFilter(); + } + // Refresh button + Rectangle { + id: reloadNearbyContainer + visible: activeTab == "nearbyTab"; + anchors.verticalCenter: parent.verticalCenter; + anchors.right: parent.right; + anchors.rightMargin: 6; + height: reloadNearby.height; + width: height; + HifiControlsUit.GlyphButton { + id: reloadNearby; + width: reloadNearby.height; + glyph: hifi.glyphs.reload; + onClicked: { + refreshNearbyWithFilter(); + } + } + } + } + } + Rectangle { + id: connectionsTabSelector; + // Anchors + anchors { + top: parent.top; + left: nearbyTabSelector.right; + } + width: parent.width/2; + height: parent.height; + color: activeTab == "connectionsTab" ? "white" : "#CCCCCC"; + MouseArea { + anchors.fill: parent; + onClicked: { + if (activeTab != "connectionsTab") { + connectionsLoading.visible = false; + connectionsLoading.visible = true; + pal.sendToScript({method: 'refreshConnections'}); + } + activeTab = "connectionsTab"; + connectionsHelpText.color = hifi.colors.blueAccent; + } + } + + // "CONNECTIONS" Text Container + Item { + id: connectionsTabSelectorTextContainer; + anchors.fill: parent; + anchors.leftMargin: 15; + // Refresh button + Rectangle { + visible: activeTab == "connectionsTab"; + anchors.verticalCenter: parent.verticalCenter; + anchors.right: parent.right; + anchors.rightMargin: 6; + height: reloadConnections.height; + width: height; + HifiControlsUit.GlyphButton { + id: reloadConnections; + width: reloadConnections.height; + glyph: hifi.glyphs.reload; + onClicked: { + connectionsLoading.visible = false; + connectionsLoading.visible = true; + pal.sendToScript({method: 'refreshConnections'}); + } + } + } + // "CONNECTIONS" text + RalewaySemiBold { + id: connectionsTabSelectorText; + text: "CONNECTIONS"; + // Text size + size: hifi.fontSizes.tabularData; + // Anchors + anchors.fill: parent; + // Style + font.capitalization: Font.AllUppercase; + color: activeTab === "connectionsTab" ? hifi.colors.blueAccent : hifi.colors.baseGray; + // Alignment + horizontalAlignment: Text.AlignHLeft; + verticalAlignment: Text.AlignVCenter; + } + TextMetrics { + id: connectionsTabSelectorTextMetrics; + text: connectionsTabSelectorText.text; + } + + // This Rectangle refers to the [?] popup button next to "CONNECTIONS" + Rectangle { + color: connectionsTabSelector.color; + width: 20; + height: connectionsTabSelectorText.height - 2; + anchors.left: connectionsTabSelectorTextContainer.left; + anchors.top: connectionsTabSelectorTextContainer.top; + anchors.topMargin: 1; + anchors.leftMargin: connectionsTabSelectorTextMetrics.width + 42; + RalewayRegular { + id: connectionsHelpText; + text: "[?]"; + size: connectionsTabSelectorText.size + 6; + font.capitalization: Font.AllUppercase; + color: connectionsTabSelectorText.color; + horizontalAlignment: Text.AlignHCenter; + verticalAlignment: Text.AlignVCenter; + anchors.fill: parent; + } + MouseArea { + anchors.fill: parent; + hoverEnabled: true; + enabled: activeTab === "connectionsTab"; + onClicked: letterbox(hifi.glyphs.question, + "Connections and Friends", + "Purple borders around profile pictures are Connections.
" + + "When your availability is set to Everyone, Connections can see your username and location.

" + + "Green borders around profile pictures are Friends.
" + + "When your availability is set to Friends, only Friends can see your username and location."); + onEntered: connectionsHelpText.color = hifi.colors.blueHighlight; + onExited: connectionsHelpText.color = hifi.colors.blueAccent; + } + } } } } - // 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; + + /***************************************** + NEARBY TAB + *****************************************/ + Rectangle { + id: nearbyTab; + // Anchors + anchors { + top: tabSelectorContainer.bottom; + topMargin: 12 + (iAmAdmin ? -adminTab.anchors.topMargin : 0); + bottom: parent.bottom; + bottomMargin: 12; + horizontalCenter: parent.horizontalCenter; } + width: parent.width - 12; + visible: activeTab == "nearbyTab"; + // Rectangle that houses "ADMIN" string Rectangle { id: adminTab; // Size - width: 2*actionButtonWidth + hifi.dimensions.scrollbarBackgroundWidth + 2; + width: 2*actionButtonWidth + hifi.dimensions.scrollbarBackgroundWidth + 6; height: 40; // Anchors - anchors.bottom: myInfo.bottom; - anchors.bottomMargin: -10; - anchors.right: myInfo.right; + anchors.top: parent.top; + anchors.topMargin: -30; + anchors.right: parent.right; // Properties visible: iAmAdmin; // Style color: hifi.colors.tableRowLightEven; - radius: hifi.dimensions.borderRadius; border.color: hifi.colors.lightGrayText; border.width: 2; // "ADMIN" text @@ -194,27 +362,24 @@ Rectangle { 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; + // This TableView refers to the Nearby Table (on the "Nearby" tab below the current user's NameCard) + HifiControlsUit.Table { + id: nearbyTable; + flickableItem.interactive: true; // Anchors - anchors.left: parent.left; - anchors.top: myInfo.bottom; + anchors.fill: parent; // Properties centerHeaderText: true; sortIndicatorVisible: true; headerVisible: true; - sortIndicatorColumn: settings.sortIndicatorColumn; - sortIndicatorOrder: settings.sortIndicatorOrder; + sortIndicatorColumn: settings.nearbySortIndicatorColumn; + sortIndicatorOrder: settings.nearbySortIndicatorOrder; onSortIndicatorColumnChanged: { - settings.sortIndicatorColumn = sortIndicatorColumn; + settings.nearbySortIndicatorColumn = sortIndicatorColumn; sortModel(); } onSortIndicatorOrderChanged: { - settings.sortIndicatorOrder = sortIndicatorOrder; + settings.nearbySortIndicatorOrder = sortIndicatorOrder; sortModel(); } @@ -229,8 +394,8 @@ Rectangle { TableViewColumn { id: displayNameHeader; role: "displayName"; - title: table.rowCount + (table.rowCount === 1 ? " NAME" : " NAMES"); - width: nameCardWidth; + title: nearbyTable.rowCount + (nearbyTable.rowCount === 1 ? " NAME" : " NAMES"); + width: nearbyNameCardWidth; movable: false; resizable: false; } @@ -258,16 +423,14 @@ Rectangle { resizable: false; } model: ListModel { - id: userModel; + id: nearbyUserModel; } - // This Rectangle refers to each Row in the table. + // This Rectangle refers to each Row in the nearbyTable. rowDelegate: Rectangle { // The only way I know to specify a row height. // Size - height: styleData.selected ? rowHeight : rowHeight - 15; - color: styleData.selected - ? hifi.colors.orangeHighlight - : styleData.alternate ? hifi.colors.tableRowLightEven : hifi.colors.tableRowLightOdd; + height: rowHeight + (styleData.selected ? 15 : 0); + color: rowColor(styleData.selected, styleData.alternate); } // This Item refers to the contents of each Cell @@ -276,27 +439,31 @@ Rectangle { property bool isCheckBox: styleData.role === "personalMute" || styleData.role === "ignore"; property bool isButton: styleData.role === "mute" || styleData.role === "kick"; property bool isAvgAudio: styleData.role === "avgAudioLevel"; + opacity: !isButton ? (model && model.isPresent ? 1.0 : 0.4) : 1.0; // Admin actions shouldn't turn gray // This NameCard refers to the cell that contains an avatar's // DisplayName and UserName NameCard { id: nameCard; // Properties + profileUrl: (model && model.profileUrl) || ""; displayName: styleData.value; userName: model ? model.userName : ""; + connectionStatus: model ? model.connection : ""; audioLevel: model ? model.audioLevel : 0.0; avgAudioLevel: model ? model.avgAudioLevel : 0.0; visible: !isCheckBox && !isButton && !isAvgAudio; uuid: model ? model.sessionId : ""; selected: styleData.selected; isAdmin: model && model.admin; + isPresent: model && model.isPresent; // Size - width: nameCardWidth; + width: nearbyNameCardWidth; height: parent.height; // Anchors anchors.left: parent.left; } - HifiControls.GlyphButton { + HifiControlsUit.GlyphButton { function getGlyph() { var fileName = "vol_"; if (model && model.personalMute) { @@ -312,43 +479,44 @@ Rectangle { size: height; anchors.verticalCenter: parent.verticalCenter; anchors.horizontalCenter: parent.horizontalCenter; + enabled: (model ? !model["ignore"] && model["isPresent"] : true); onClicked: { // cannot change mute status when ignoring if (!model["ignore"]) { var newValue = !model["personalMute"]; - userModel.setProperty(model.userIndex, "personalMute", newValue); - userModelData[model.userIndex]["personalMute"] = newValue; // Defensive programming + nearbyUserModel.setProperty(model.userIndex, "personalMute", newValue); + nearbyUserModelData[model.userIndex]["personalMute"] = newValue; // Defensive programming Users["personalMute"](model.sessionId, newValue); UserActivityLogger["palAction"](newValue ? "personalMute" : "un-personalMute", model.sessionId); } } } - // This CheckBox belongs in the columns that contain the stateful action buttons ("Mute" & "Ignore" for now) + // This CheckBox belongs in the columns that contain the stateful action buttons ("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 { + HifiControlsUit.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)); + // If this is an "Ignore" checkbox, disable the checkbox if user isn't present. + enabled: styleData.role === "ignore" ? (model ? model["isPresent"] : true) : true; boxSize: 24; onClicked: { var newValue = !model[styleData.role]; - userModel.setProperty(model.userIndex, styleData.role, newValue); - userModelData[model.userIndex][styleData.role] = newValue; // Defensive programming + nearbyUserModel.setProperty(model.userIndex, styleData.role, newValue); + nearbyUserModelData[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 + nearbyUserModel.setProperty(model.userIndex, "personalMute", newValue); + nearbyUserModelData[model.userIndex]["personalMute"] = newValue; // Defensive programming if (newValue) { - ignored[model.sessionId] = userModelData[model.userIndex]; + ignored[model.sessionId] = nearbyUserModelData[model.userIndex]; } else { delete ignored[model.sessionId]; } @@ -362,7 +530,7 @@ Rectangle { } // This Button belongs in the columns that contain the stateless action buttons ("Silence" & "Ban" for now) - HifiControls.Button { + HifiControlsUit.Button { id: actionButton; color: 2; // Red visible: isButton; @@ -373,8 +541,8 @@ Rectangle { Users[styleData.role](model.sessionId); UserActivityLogger["palAction"](styleData.role, model.sessionId); if (styleData.role === "kick") { - userModelData.splice(model.userIndex, 1); - userModel.remove(model.userIndex); // after changing userModelData, b/c ListModel can frob the data + nearbyUserModelData.splice(model.userIndex, 1); + nearbyUserModel.remove(model.userIndex); // after changing nearbyUserModelData, b/c ListModel can frob the data } } // muted/error glyphs @@ -397,10 +565,10 @@ Rectangle { Rectangle { // Size width: 2; - height: table.height; + height: nearbyTable.height; // Anchors anchors.left: adminTab.left; - anchors.top: table.top; + anchors.top: nearbyTable.top; // Properties visible: iAmAdmin; color: hifi.colors.lightGrayText; @@ -415,10 +583,10 @@ Rectangle { color: hifi.colors.tableBackgroundLight; width: 20; height: hifi.dimensions.tableHeaderHeight - 2; - anchors.left: table.left; - anchors.top: table.top; + anchors.left: nearbyTable.left; + anchors.top: nearbyTable.top; anchors.topMargin: 1; - anchors.leftMargin: actionButtonWidth + nameCardWidth/2 + displayNameHeaderMetrics.width/2 + 6; + anchors.leftMargin: actionButtonWidth + nearbyNameCardWidth/2 + displayNameHeaderMetrics.width/2 + 6; RalewayRegular { id: helpText; text: "[?]"; @@ -431,13 +599,16 @@ Rectangle { } 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."); + "Purple borders around profile pictures are connections.
" + + "Green borders around profile pictures are friends.
" + + "(TEMPORARY LANGUAGE) In some situations, you can also see others' usernames.
" + + "If you can see someone's username, you can GoTo them by selecting them in the PAL, then clicking their name.
" + + "
If someone's display name isn't set, a unique session display name is assigned to them.
" + + "
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; } @@ -449,7 +620,7 @@ Rectangle { width: 20; height: 28; anchors.right: adminTab.right; - anchors.rightMargin: 10 + hifi.dimensions.scrollbarBackgroundWidth; + anchors.rightMargin: 12 + hifi.dimensions.scrollbarBackgroundWidth; anchors.top: adminTab.top; anchors.topMargin: 2; RalewayRegular { @@ -464,7 +635,6 @@ Rectangle { } MouseArea { anchors.fill: parent; - acceptedButtons: Qt.LeftButton; hoverEnabled: true; onClicked: letterbox(hifi.glyphs.question, "Admin Actions", @@ -474,20 +644,520 @@ Rectangle { onExited: adminHelpText.color = hifi.colors.redHighlight; } } + } // "Nearby" Tab - HifiControls.Keyboard { + + /***************************************** + CONNECTIONS TAB + *****************************************/ + Rectangle { + id: connectionsTab; + color: "white"; + // Anchors + anchors { + top: tabSelectorContainer.bottom; + topMargin: 12; + bottom: parent.bottom; + bottomMargin: 12; + horizontalCenter: parent.horizontalCenter; + } + width: parent.width - 12; + visible: activeTab == "connectionsTab"; + + AnimatedImage { + id: connectionsLoading; + source: "../../icons/profilePicLoading.gif" + width: 120; + height: width; + anchors.top: parent.top; + anchors.topMargin: 185; + anchors.horizontalCenter: parent.horizontalCenter; + visible: true; + onVisibleChanged: { + if (visible) { + connectionsTimeoutTimer.start(); + } else { + connectionsTimeoutTimer.stop(); + connectionsRefreshProblemText.visible = false; + } + } + } + + // "This is taking too long..." text + FiraSansSemiBold { + id: connectionsRefreshProblemText + // Properties + text: "This is taking longer than normal.\nIf you get stuck, try refreshing the Connections tab."; + // Anchors + anchors.top: connectionsLoading.bottom; + anchors.topMargin: 10; + anchors.left: parent.left; + anchors.bottom: parent.bottom; + width: parent.width; + // Text Size + size: 16; + // Text Positioning + verticalAlignment: Text.AlignTop; + horizontalAlignment: Text.AlignHCenter; + wrapMode: Text.WordWrap; + // Style + color: hifi.colors.darkGray; + } + + // This TableView refers to the Connections Table (on the "Connections" tab below the current user's NameCard) + HifiControlsUit.Table { + id: connectionsTable; + flickableItem.interactive: true; + visible: !connectionsLoading.visible; + // Anchors + anchors.fill: parent; + // Properties + centerHeaderText: true; + sortIndicatorVisible: true; + headerVisible: true; + sortIndicatorColumn: settings.connectionsSortIndicatorColumn; + sortIndicatorOrder: settings.connectionsSortIndicatorOrder; + onSortIndicatorColumnChanged: { + settings.connectionsSortIndicatorColumn = sortIndicatorColumn; + sortConnectionsModel(); + } + onSortIndicatorOrderChanged: { + settings.connectionsSortIndicatorOrder = sortIndicatorOrder; + sortConnectionsModel(); + } + + TableViewColumn { + id: connectionsUserNameHeader; + role: "userName"; + title: connectionsTable.rowCount + (connectionsTable.rowCount === 1 ? " NAME" : " NAMES"); + width: connectionsNameCardWidth; + movable: false; + resizable: false; + } + TableViewColumn { + role: "placeName"; + title: "LOCATION"; + width: locationColumnWidth; + movable: false; + resizable: false; + } + TableViewColumn { + role: "friends"; + title: "FRIEND"; + width: actionButtonWidth; + movable: false; + resizable: false; + } + + model: ListModel { + id: connectionsUserModel; + } + + // This Rectangle refers to each Row in the connectionsTable. + rowDelegate: Rectangle { + // Size + height: rowHeight; + color: rowColor(styleData.selected, styleData.alternate); + } + + // This Item refers to the contents of each Cell + itemDelegate: Item { + id: connectionsItemCell; + + // This NameCard refers to the cell that contains a connection's UserName + NameCard { + id: connectionsNameCard; + // Properties + visible: styleData.role === "userName"; + profileUrl: (model && model.profileUrl) || ""; + displayName: ""; + userName: model ? model.userName : ""; + connectionStatus : model ? model.connection : ""; + selected: styleData.selected; + // Size + width: connectionsNameCardWidth; + height: parent.height; + // Anchors + anchors.left: parent.left; + } + + // LOCATION data + FiraSansRegular { + id: connectionsLocationData + // Properties + visible: styleData.role === "placeName"; + text: (model && model.placeName) || ""; + elide: Text.ElideRight; + // Size + width: parent.width; + // Anchors + anchors.fill: parent; + // Text Size + size: 16; + // Text Positioning + verticalAlignment: Text.AlignVCenter + // Style + color: hifi.colors.blueAccent; + font.underline: true; + MouseArea { + anchors.fill: parent + hoverEnabled: enabled + enabled: connectionsNameCard.selected && pal.activeTab == "connectionsTab" + onClicked: { + AddressManager.goToUser(model.userName); + UserActivityLogger.palAction("go_to_user", model.userName); + } + onEntered: connectionsLocationData.color = hifi.colors.blueHighlight; + onExited: connectionsLocationData.color = hifi.colors.blueAccent; + } + } + + // "Friends" checkbox + HifiControlsUit.CheckBox { + id: friendsCheckBox; + visible: styleData.role === "friends" && model.userName !== myData.userName; + anchors.centerIn: parent; + checked: model ? (model["connection"] === "friend" ? true : false) : false; + boxSize: 24; + onClicked: { + var newValue = !(model["connection"] === "friend"); + connectionsUserModel.setProperty(model.userIndex, styleData.role, newValue); + connectionsUserModelData[model.userIndex][styleData.role] = newValue; // Defensive programming + pal.sendToScript({method: newValue ? 'addFriend' : 'removeFriend', params: model.userName}); + + UserActivityLogger["palAction"](newValue ? styleData.role : "un-" + styleData.role, 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["connection"] === "friend" ? true : false)}); + } + } + } + } + + // "Make a Connection" instructions + Rectangle { + id: connectionInstructions; + visible: connectionsTable.rowCount === 0 && !connectionsLoading.visible; + anchors.fill: connectionsTable; + anchors.topMargin: hifi.dimensions.tableHeaderHeight; + color: "white"; + + RalewayRegular { + id: makeAConnectionText; + // Properties + text: "Make a Connection"; + // Anchors + anchors.top: parent.top; + anchors.topMargin: 60; + anchors.left: parent.left; + anchors.right: parent.right; + // Text Size + size: 24; + // Text Positioning + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter; + // Style + color: hifi.colors.darkGray; + } + + Image { + id: connectionImage; + source: "../../icons/connection.svg"; + width: 150; + height: 150; + mipmap: true; + // Anchors + anchors.top: makeAConnectionText.bottom; + anchors.topMargin: 15; + anchors.horizontalCenter: parent.horizontalCenter; + } + + FontLoader { id: ralewayRegular; source: "../../fonts/Raleway-Regular.ttf"; } + Text { + id: connectionHelpText; + // Anchors + anchors.top: connectionImage.bottom; + anchors.topMargin: 15; + anchors.left: parent.left + anchors.leftMargin: 40; + anchors.right: parent.right + anchors.rightMargin: 10; + // Text alignment + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHLeft + // Style + font.pixelSize: 18; + font.family: ralewayRegular.name + color: hifi.colors.darkGray + wrapMode: Text.WordWrap + textFormat: Text.StyledText; + // Text + text: HMD.active ? + "When you meet someone you want to remember later, you can connect with a handshake:

" + + "1. Put your hand out onto their hand and squeeze your controller's grip button on its side.
" + + "2. Once the other person puts their hand onto yours, you'll see your connection form.
" + + "3. After about 3 seconds, you're connected!" + : + "When you meet someone you want to remember later, you can connect with a handshake:

" + + "1. Press and hold the 'x' key to extend your arm.
" + + "2. Once the other person puts their hand onto yours, you'll see your connection form.
" + + "3. After about 3 seconds, you're connected!"; + } + + } + } // "Connections" Tab + } // palTabContainer + + // This contains the current user's NameCard and will contain other information in the future + Rectangle { + id: myInfo; + // Size + width: pal.width; + height: myCardHeight; + // Style + color: pal.color; + // Anchors + anchors.top: pal.top; + anchors.topMargin: 10; + anchors.left: pal.left; + // This NameCard refers to the current user's NameCard (the one above the nearbyTable) + NameCard { + id: myCard; + // Properties + profileUrl: myData.profileUrl; + displayName: myData.displayName; + userName: myData.userName; + audioLevel: myData.audioLevel; + avgAudioLevel: myData.avgAudioLevel; + isMyCard: true; + isPresent: true; + // Size + width: myCardWidth; + height: parent.height; + // Anchors + anchors.top: parent.top + anchors.left: parent.left; + } + Item { + id: upperRightInfoContainer; + width: 160; + height: parent.height; + anchors.top: parent.top; + anchors.right: parent.right; + + RalewayRegular { + id: availabilityText; + text: "set availability"; + // Text size + size: hifi.fontSizes.tabularData; + // Anchors + anchors.top: availabilityComboBox.bottom; + anchors.horizontalCenter: parent.horizontalCenter; + // Style + color: hifi.colors.baseGrayHighlight; + // Alignment + horizontalAlignment: Text.AlignHCenter; + verticalAlignment: Text.AlignTop; + } + /*Rectangle { + id: availabilityComboBox; + // Anchors + anchors.top: parent.top; + anchors.horizontalCenter: parent.horizontalCenter; + // Size + width: parent.width; + height: 40; + MouseArea { + anchors.fill: parent + onClicked: { + popupComboDialog("Set your list visibility", + ["Everyone", "Friends and Connections", "Friends Only", "Appear Offline"], + ["You will be invisible in everyone's 'People' list.\nAnyone will be able to jump to your location if the domain allows.", + "You will be visible in the 'People' list only for those with whom you are connected or friends.\nThey will be able to jump to your location if the domain allows.", + "You will be visible in the 'People' list only for those with whom you are friends.\nThey will be able to jump to your location if the domain allows.", + "You will not be visible in the 'People' list of any other users."], + ["all", "connections", "friends", "none"]); + } + } + }*/ + + HifiControlsUit.ComboBox { + function determineAvailabilityIndex() { + return ['all', 'connections', 'friends', 'none'].indexOf(GlobalServices.findableBy) + } + id: availabilityComboBox; + // Anchors + anchors.top: parent.top; + anchors.horizontalCenter: parent.horizontalCenter; + // Size + width: parent.width; + height: 40; + currentIndex: determineAvailabilityIndex(); + model: ListModel { + id: availabilityComboBoxListItems + ListElement { text: "Everyone"; value: "all"; } + ListElement { text: "All Connections"; value: "connections"; } + ListElement { text: "Friends Only"; value: "friends"; } + ListElement { text: "Appear Offline"; value: "none" } + } + onCurrentIndexChanged: { + GlobalServices.findableBy = availabilityComboBoxListItems.get(currentIndex).value; + UserActivityLogger.palAction("set_availability", availabilityComboBoxListItems.get(currentIndex).value); + print('Setting availability:', JSON.stringify(GlobalServices.findableBy)); + } + } + } + } + + HifiControlsUit.Keyboard { id: keyboard; - raised: myCard.currentlyEditingDisplayName && HMD.active; + raised: currentlyEditingDisplayName && HMD.mounted; numeric: parent.punctuationMode; anchors { bottom: parent.bottom; left: parent.left; right: parent.right; } - } - } + } // Keyboard - // Timer used when selecting table rows that aren't yet present in the model + Item { + id: webViewContainer; + anchors.fill: parent; + + Rectangle { + id: navigationContainer; + visible: userInfoViewer.visible; + height: 60; + anchors { + top: parent.top; + left: parent.left; + right: parent.right; + } + color: hifi.colors.faintGray; + + Item { + id: backButton + anchors { + top: parent.top; + left: parent.left; + } + height: parent.height - addressBar.height; + width: parent.width/2; + + FiraSansSemiBold { + // Properties + text: "BACK"; + elide: Text.ElideRight; + // Anchors + anchors.fill: parent; + // Text Size + size: 16; + // Text Positioning + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter; + // Style + color: backButtonMouseArea.containsMouse || !userInfoViewer.canGoBack ? hifi.colors.lightGray : hifi.colors.darkGray; + MouseArea { + id: backButtonMouseArea; + anchors.fill: parent + hoverEnabled: enabled + onClicked: { + if (userInfoViewer.canGoBack) { + userInfoViewer.goBack(); + } + } + } + } + } + + Item { + id: closeButtonContainer + anchors { + top: parent.top; + right: parent.right; + } + height: parent.height - addressBar.height; + width: parent.width/2; + + FiraSansSemiBold { + id: closeButton; + // Properties + text: "CLOSE"; + elide: Text.ElideRight; + // Anchors + anchors.fill: parent; + // Text Size + size: 16; + // Text Positioning + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter; + // Style + color: hifi.colors.redHighlight; + MouseArea { + anchors.fill: parent + hoverEnabled: enabled + onClicked: userInfoViewer.visible = false; + onEntered: closeButton.color = hifi.colors.redAccent; + onExited: closeButton.color = hifi.colors.redHighlight; + } + } + } + + Item { + id: addressBar + anchors { + top: closeButtonContainer.bottom; + left: parent.left; + right: parent.right; + } + height: 30; + width: parent.width; + + FiraSansRegular { + // Properties + text: userInfoViewer.url; + elide: Text.ElideRight; + // Anchors + anchors.fill: parent; + anchors.leftMargin: 5; + // Text Size + size: 14; + // Text Positioning + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignLeft; + // Style + color: hifi.colors.lightGray; + } + } + } + + Rectangle { + id: webViewBackground; + color: "white"; + visible: userInfoViewer.visible; + anchors { + top: navigationContainer.bottom; + bottom: parent.bottom; + left: parent.left; + right: parent.right; + } + } + + HifiControls.WebView { + id: userInfoViewer; + anchors { + top: navigationContainer.bottom; + bottom: parent.bottom; + left: parent.left; + right: parent.right; + } + visible: false; + } + } + + // Timer used when selecting nearbyTable 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? @@ -495,17 +1165,29 @@ Rectangle { id: selectionTimer; onTriggered: { if (selected) { - table.selection.clear(); // for now, no multi-select - table.selection.select(userIndex); - table.positionViewAtRow(userIndex, ListView.Beginning); + nearbyTable.selection.clear(); // for now, no multi-select + nearbyTable.selection.select(userIndex); + nearbyTable.positionViewAtRow(userIndex, ListView.Beginning); } else { - table.selection.deselect(userIndex); + nearbyTable.selection.deselect(userIndex); } } } - function findSessionIndex(sessionId, optionalData) { // no findIndex in .qml - var data = optionalData || userModelData, length = data.length; + // Timer used when refreshing the Connections tab + Timer { + id: connectionsTimeoutTimer; + interval: 3000; // 3 seconds + onTriggered: { + connectionsRefreshProblemText.visible = true; + } + } + + function rowColor(selected, alternate) { + return selected ? hifi.colors.orangeHighlight : alternate ? hifi.colors.tableRowLightEven : hifi.colors.tableRowLightOdd; + } + function findNearbySessionIndex(sessionId, optionalData) { // no findIndex in .qml + var data = optionalData || nearbyUserModelData, length = data.length; for (var i = 0; i < length; i++) { if (data[i].sessionId === sessionId) { return i; @@ -515,10 +1197,10 @@ Rectangle { } function fromScript(message) { switch (message.method) { - case 'users': + case 'nearbyUsers': var data = message.params; var index = -1; - index = findSessionIndex('', data); + index = findNearbySessionIndex('', data); if (index !== -1) { iAmAdmin = Users.canKick; myData = data[index]; @@ -526,39 +1208,48 @@ Rectangle { } else { console.log("This user's data was not found in the user list. PAL will not function properly."); } - userModelData = data; + nearbyUserModelData = data; for (var ignoredID in ignored) { - index = findSessionIndex(ignoredID); + index = findNearbySessionIndex(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]); + nearbyUserModelData.push(ignored[ignoredID]); } else { // Already appears in PAL; update properties of existing element in model data - userModelData[index] = ignored[ignoredID]; + nearbyUserModelData[index] = ignored[ignoredID]; } } sortModel(); + reloadNearby.color = 0; + break; + case 'connections': + var data = message.params; + console.log('Got connection data: ', JSON.stringify(data)); + connectionsUserModelData = data; + sortConnectionsModel(); + connectionsLoading.visible = false; + connectionsRefreshProblemText.visible = false; break; case 'select': var sessionIds = message.params[0]; var selected = message.params[1]; var alreadyRefreshed = message.params[2]; - var userIndex = findSessionIndex(sessionIds[0]); + var userIndex = findNearbySessionIndex(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.'); + letterbox('', '', 'The user you attempted to select is no longer available.'); } else { pal.sendToScript({method: 'refresh', params: {selected: 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 + // Wait a little bit before trying to actually select the avatar in the nearbyTable 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 + // select the avatar in the nearbyTable immediately selectionTimer.interval = 0; } selectionTimer.selected = selected; @@ -568,26 +1259,25 @@ Rectangle { 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 + // Get the connection status + var connectionStatus = message.params.connection; + // If the connection status isn't "self"... + if (connectionStatus !== "self") { + // Get the index in nearbyUserModel and nearbyUserModelData associated with the passed UUID + var userIndex = findNearbySessionIndex(message.params.sessionId); + if (userIndex !== -1) { + ['userName', 'admin', 'connection', 'profileUrl', 'placeName'].forEach(function (name) { + var value = message.params[name]; + if (value === undefined) { + return; + } + nearbyUserModel.setProperty(userIndex, name, value); + nearbyUserModelData[userIndex][name] = value; // for refill after sort + }); } + } else if (message.params.profileUrl) { + myData.profileUrl = message.params.profileUrl; + myCard.profileUrl = message.params.profileUrl; } break; case 'updateAudioLevel': @@ -601,12 +1291,12 @@ Rectangle { myData.avgAudioLevel = avgAudioLevel; myCard.avgAudioLevel = avgAudioLevel; } else { - var userIndex = findSessionIndex(userId); + var userIndex = findNearbySessionIndex(userId); if (userIndex != -1) { - userModel.setProperty(userIndex, "audioLevel", audioLevel); - userModelData[userIndex].audioLevel = audioLevel; // Defensive programming - userModel.setProperty(userIndex, "avgAudioLevel", avgAudioLevel); - userModelData[userIndex].avgAudioLevel = avgAudioLevel; + nearbyUserModel.setProperty(userIndex, "audioLevel", audioLevel); + nearbyUserModelData[userIndex].audioLevel = audioLevel; // Defensive programming + nearbyUserModel.setProperty(userIndex, "avgAudioLevel", avgAudioLevel); + nearbyUserModelData[userIndex].avgAudioLevel = avgAudioLevel; } } } @@ -618,18 +1308,35 @@ Rectangle { var sessionID = message.params[0]; delete ignored[sessionID]; break; + case 'palIsStale': + var sessionID = message.params[0]; + var reason = message.params[1]; + var userIndex = findNearbySessionIndex(sessionID); + if (userIndex != -1) { + if (!nearbyUserModelData[userIndex].ignore) { + if (reason !== 'avatarAdded') { + nearbyUserModel.setProperty(userIndex, "isPresent", false); + nearbyUserModelData[userIndex].isPresent = false; + nearbyTable.selection.deselect(userIndex); + } + reloadNearby.color = 2; + } + } else { + reloadNearby.color = 2; + } + break; default: console.log('Unrecognized message:', JSON.stringify(message)); } } function sortModel() { - var column = table.getColumn(table.sortIndicatorColumn); + var column = nearbyTable.getColumn(nearbyTable.sortIndicatorColumn); var sortProperty = column ? column.role : "displayName"; - var before = (table.sortIndicatorOrder === Qt.AscendingOrder) ? -1 : 1; + var before = (nearbyTable.sortIndicatorOrder === Qt.AscendingOrder) ? -1 : 1; var after = -1 * before; // get selection(s) before sorting - var selectedIDs = getSelectedSessionIDs(); - userModelData.sort(function (a, b) { + var selectedIDs = getSelectedNearbySessionIDs(); + nearbyUserModelData.sort(function (a, b) { var aValue = a[sortProperty].toString().toLowerCase(), bValue = b[sortProperty].toString().toLowerCase(); switch (true) { case (aValue < bValue): return before; @@ -637,39 +1344,77 @@ Rectangle { default: return 0; } }); - table.selection.clear(); + nearbyTable.selection.clear(); - userModel.clear(); + nearbyUserModel.clear(); var userIndex = 0; var newSelectedIndexes = []; - userModelData.forEach(function (datum) { + nearbyUserModelData.forEach(function (datum) { function init(property) { if (datum[property] === undefined) { - datum[property] = false; + // These properties must have values of type 'string'. + if (property === 'userName' || property === 'profileUrl' || property === 'placeName' || property === 'connection') { + datum[property] = ""; + // All other properties must have values of type 'bool'. + } else { + datum[property] = false; + } } } - ['personalMute', 'ignore', 'mute', 'kick'].forEach(init); + ['personalMute', 'ignore', 'mute', 'kick', 'admin', 'userName', 'profileUrl', 'placeName', 'connection'].forEach(init); datum.userIndex = userIndex++; - userModel.append(datum); + nearbyUserModel.append(datum); if (selectedIDs.indexOf(datum.sessionId) != -1) { newSelectedIndexes.push(datum.userIndex); } }); if (newSelectedIndexes.length > 0) { - table.selection.select(newSelectedIndexes); - table.positionViewAtRow(newSelectedIndexes[0], ListView.Beginning); + nearbyTable.selection.select(newSelectedIndexes); + nearbyTable.positionViewAtRow(newSelectedIndexes[0], ListView.Beginning); + } + } + function sortConnectionsModel() { + var column = connectionsTable.getColumn(connectionsTable.sortIndicatorColumn); + var sortProperty = column ? column.role : "userName"; + var before = (connectionsTable.sortIndicatorOrder === Qt.AscendingOrder) ? -1 : 1; + var after = -1 * before; + // get selection(s) before sorting + var selectedIDs = getSelectedConnectionsUserNames(); + connectionsUserModelData.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; + } + }); + connectionsTable.selection.clear(); + + connectionsUserModel.clear(); + var userIndex = 0; + var newSelectedIndexes = []; + connectionsUserModelData.forEach(function (datum) { + datum.userIndex = userIndex++; + connectionsUserModel.append(datum); + if (selectedIDs.indexOf(datum.sessionId) != -1) { + newSelectedIndexes.push(datum.userIndex); + } + }); + if (newSelectedIndexes.length > 0) { + connectionsTable.selection.select(newSelectedIndexes); + connectionsTable.positionViewAtRow(newSelectedIndexes[0], ListView.Beginning); } } signal sendToScript(var message); function noticeSelection() { var userIds = []; - table.selection.forEach(function (userIndex) { - userIds.push(userModelData[userIndex].sessionId); + nearbyTable.selection.forEach(function (userIndex) { + userIds.push(nearbyUserModelData[userIndex].sessionId); }); pal.sendToScript({method: 'selected', params: userIds}); } Connections { - target: table.selection; + target: nearbyTable.selection; onSelectionChanged: pal.noticeSelection(); } } diff --git a/interface/resources/qml/styles-uit/HifiConstants.qml b/interface/resources/qml/styles-uit/HifiConstants.qml index 38534d4243..a9d5a0ed2f 100644 --- a/interface/resources/qml/styles-uit/HifiConstants.qml +++ b/interface/resources/qml/styles-uit/HifiConstants.qml @@ -172,7 +172,7 @@ Item { readonly property real textFieldInputLabel: dimensions.largeScreen ? 13 : 9 readonly property real textFieldSearchIcon: dimensions.largeScreen ? 30 : 24 readonly property real tableHeading: dimensions.largeScreen ? 12 : 10 - readonly property real tableHeadingIcon: dimensions.largeScreen ? 40 : 33 + readonly property real tableHeadingIcon: dimensions.largeScreen ? 60 : 33 readonly property real tableText: dimensions.largeScreen ? 15 : 12 readonly property real buttonLabel: dimensions.largeScreen ? 13 : 9 readonly property real iconButton: dimensions.largeScreen ? 13 : 9 diff --git a/interface/src/DiscoverabilityManager.cpp b/interface/src/DiscoverabilityManager.cpp index 8fcc1e5477..f042330a46 100644 --- a/interface/src/DiscoverabilityManager.cpp +++ b/interface/src/DiscoverabilityManager.cpp @@ -40,9 +40,10 @@ void DiscoverabilityManager::updateLocation() { auto accountManager = DependencyManager::get(); auto addressManager = DependencyManager::get(); auto& domainHandler = DependencyManager::get()->getDomainHandler(); + bool discoverable = (_mode.get() != Discoverability::None); - if (_mode.get() != Discoverability::None && accountManager->isLoggedIn()) { + if (accountManager->isLoggedIn()) { // construct a QJsonObject given the user's current address information QJsonObject rootObject; @@ -50,34 +51,40 @@ void DiscoverabilityManager::updateLocation() { QString pathString = addressManager->currentPath(); - const QString PATH_KEY_IN_LOCATION = "path"; - locationObject.insert(PATH_KEY_IN_LOCATION, pathString); - const QString CONNECTED_KEY_IN_LOCATION = "connected"; - locationObject.insert(CONNECTED_KEY_IN_LOCATION, domainHandler.isConnected()); + locationObject.insert(CONNECTED_KEY_IN_LOCATION, discoverable && domainHandler.isConnected()); - if (!addressManager->getRootPlaceID().isNull()) { - const QString PLACE_ID_KEY_IN_LOCATION = "place_id"; - locationObject.insert(PLACE_ID_KEY_IN_LOCATION, - uuidStringWithoutCurlyBraces(addressManager->getRootPlaceID())); + if (discoverable) { // Don't consider changes to these as update-worthy if we're not discoverable. + const QString PATH_KEY_IN_LOCATION = "path"; + locationObject.insert(PATH_KEY_IN_LOCATION, pathString); + + if (!addressManager->getRootPlaceID().isNull()) { + const QString PLACE_ID_KEY_IN_LOCATION = "place_id"; + locationObject.insert(PLACE_ID_KEY_IN_LOCATION, + uuidStringWithoutCurlyBraces(addressManager->getRootPlaceID())); + } + + if (!domainHandler.getUUID().isNull()) { + const QString DOMAIN_ID_KEY_IN_LOCATION = "domain_id"; + locationObject.insert(DOMAIN_ID_KEY_IN_LOCATION, + uuidStringWithoutCurlyBraces(domainHandler.getUUID())); + } + + // in case the place/domain isn't in the database, we send the network address and port + auto& domainSockAddr = domainHandler.getSockAddr(); + const QString NETWORK_ADDRESS_KEY_IN_LOCATION = "network_address"; + locationObject.insert(NETWORK_ADDRESS_KEY_IN_LOCATION, domainSockAddr.getAddress().toString()); + + const QString NETWORK_ADDRESS_PORT_IN_LOCATION = "network_port"; + locationObject.insert(NETWORK_ADDRESS_PORT_IN_LOCATION, domainSockAddr.getPort()); + + const QString NODE_ID_IN_LOCATION = "node_id"; + const int UUID_REAL_LENGTH = 36; + locationObject.insert(NODE_ID_IN_LOCATION, DependencyManager::get()->getSessionUUID().toString().mid(1, UUID_REAL_LENGTH)); } - if (!domainHandler.getUUID().isNull()) { - const QString DOMAIN_ID_KEY_IN_LOCATION = "domain_id"; - locationObject.insert(DOMAIN_ID_KEY_IN_LOCATION, - uuidStringWithoutCurlyBraces(domainHandler.getUUID())); - } - - // in case the place/domain isn't in the database, we send the network address and port - auto& domainSockAddr = domainHandler.getSockAddr(); - const QString NETWORK_ADRESS_KEY_IN_LOCATION = "network_address"; - locationObject.insert(NETWORK_ADRESS_KEY_IN_LOCATION, domainSockAddr.getAddress().toString()); - - const QString NETWORK_ADDRESS_PORT_IN_LOCATION = "network_port"; - locationObject.insert(NETWORK_ADDRESS_PORT_IN_LOCATION, domainSockAddr.getPort()); - - const QString FRIENDS_ONLY_KEY_IN_LOCATION = "friends_only"; - locationObject.insert(FRIENDS_ONLY_KEY_IN_LOCATION, (_mode.get() == Discoverability::Friends)); + const QString AVAILABILITY_KEY_IN_LOCATION = "availability"; + locationObject.insert(AVAILABILITY_KEY_IN_LOCATION, findableByString(static_cast(_mode.get()))); JSONCallbackParameters callbackParameters; callbackParameters.jsonCallbackReceiver = this; @@ -139,19 +146,29 @@ void DiscoverabilityManager::setDiscoverabilityMode(Discoverability::Mode discov // update the setting to the new value _mode.set(static_cast(discoverabilityMode)); - - if (static_cast(_mode.get()) == Discoverability::None) { - // if we just got set to no discoverability, make sure that we delete our location in DB - removeLocation(); - } else { - // we have a discoverability mode that says we should send a location, do that right away - updateLocation(); - } + updateLocation(); // update right away emit discoverabilityModeChanged(discoverabilityMode); } } + +QString DiscoverabilityManager::findableByString(Discoverability::Mode discoverabilityMode) { + if (discoverabilityMode == Discoverability::None) { + return "none"; + } else if (discoverabilityMode == Discoverability::Friends) { + return "friends"; + } else if (discoverabilityMode == Discoverability::Connections) { + return "connections"; + } else if (discoverabilityMode == Discoverability::All) { + return "all"; + } else { + qDebug() << "GlobalServices findableByString called with an unrecognized value."; + return ""; + } +} + + void DiscoverabilityManager::setVisibility() { Menu* menu = Menu::getInstance(); diff --git a/interface/src/DiscoverabilityManager.h b/interface/src/DiscoverabilityManager.h index 196b0cdf81..96190b25d9 100644 --- a/interface/src/DiscoverabilityManager.h +++ b/interface/src/DiscoverabilityManager.h @@ -19,6 +19,7 @@ namespace Discoverability { enum Mode { None, Friends, + Connections, All }; } @@ -42,6 +43,9 @@ public slots: signals: void discoverabilityModeChanged(Discoverability::Mode discoverabilityMode); +public: + static QString findableByString(Discoverability::Mode discoverabilityMode); + private slots: void handleHeartbeatResponse(QNetworkReply& requestReply); diff --git a/interface/src/networking/HFWebEngineRequestInterceptor.cpp b/interface/src/networking/HFWebEngineRequestInterceptor.cpp index 9c3f0b232e..f6b0914f08 100644 --- a/interface/src/networking/HFWebEngineRequestInterceptor.cpp +++ b/interface/src/networking/HFWebEngineRequestInterceptor.cpp @@ -10,6 +10,7 @@ // #include "HFWebEngineRequestInterceptor.h" +#include "NetworkingConstants.h" #include @@ -20,8 +21,11 @@ bool isAuthableHighFidelityURL(const QUrl& url) { "highfidelity.com", "highfidelity.io", "metaverse.highfidelity.com", "metaverse.highfidelity.io" }; + const auto& scheme = url.scheme(); + const auto& host = url.host(); - return url.scheme() == "https" && HF_HOSTS.contains(url.host()); + return (scheme == "https" && HF_HOSTS.contains(host)) || + ((scheme == NetworkingConstants::METAVERSE_SERVER_URL.scheme()) && (host == NetworkingConstants::METAVERSE_SERVER_URL.host())); } void HFWebEngineRequestInterceptor::interceptRequest(QWebEngineUrlRequestInfo& info) { diff --git a/interface/src/scripting/GlobalServicesScriptingInterface.cpp b/interface/src/scripting/GlobalServicesScriptingInterface.cpp index d7e5bae3f8..f4a5ffb39c 100644 --- a/interface/src/scripting/GlobalServicesScriptingInterface.cpp +++ b/interface/src/scripting/GlobalServicesScriptingInterface.cpp @@ -53,33 +53,19 @@ void GlobalServicesScriptingInterface::loggedOut() { emit GlobalServicesScriptingInterface::disconnected(QString("logout")); } - -QString GlobalServicesScriptingInterface::findableByString(Discoverability::Mode discoverabilityMode) const { - if (discoverabilityMode == Discoverability::None) { - return "none"; - } else if (discoverabilityMode == Discoverability::Friends) { - return "friends"; - } else if (discoverabilityMode == Discoverability::All) { - return "all"; - } else { - qDebug() << "GlobalServices findableByString called with an unrecognized value."; - return ""; - } -} - - QString GlobalServicesScriptingInterface::getFindableBy() const { auto discoverabilityManager = DependencyManager::get(); - return findableByString(discoverabilityManager->getDiscoverabilityMode()); + return DiscoverabilityManager::findableByString(discoverabilityManager->getDiscoverabilityMode()); } void GlobalServicesScriptingInterface::setFindableBy(const QString& discoverabilityMode) { auto discoverabilityManager = DependencyManager::get(); - if (discoverabilityMode.toLower() == "none") { discoverabilityManager->setDiscoverabilityMode(Discoverability::None); } else if (discoverabilityMode.toLower() == "friends") { discoverabilityManager->setDiscoverabilityMode(Discoverability::Friends); + } else if (discoverabilityMode.toLower() == "connections") { + discoverabilityManager->setDiscoverabilityMode(Discoverability::Connections); } else if (discoverabilityMode.toLower() == "all") { discoverabilityManager->setDiscoverabilityMode(Discoverability::All); } else { @@ -88,7 +74,7 @@ void GlobalServicesScriptingInterface::setFindableBy(const QString& discoverabil } void GlobalServicesScriptingInterface::discoverabilityModeChanged(Discoverability::Mode discoverabilityMode) { - emit findableByChanged(findableByString(discoverabilityMode)); + emit findableByChanged(DiscoverabilityManager::findableByString(discoverabilityMode)); } DownloadInfoResult::DownloadInfoResult() : diff --git a/interface/src/scripting/GlobalServicesScriptingInterface.h b/interface/src/scripting/GlobalServicesScriptingInterface.h index 11d8735187..63294fc656 100644 --- a/interface/src/scripting/GlobalServicesScriptingInterface.h +++ b/interface/src/scripting/GlobalServicesScriptingInterface.h @@ -18,6 +18,7 @@ #include #include #include +#include class DownloadInfoResult { public: @@ -35,7 +36,7 @@ class GlobalServicesScriptingInterface : public QObject { Q_OBJECT Q_PROPERTY(QString username READ getUsername) - Q_PROPERTY(QString findableBy READ getFindableBy WRITE setFindableBy) + Q_PROPERTY(QString findableBy READ getFindableBy WRITE setFindableBy NOTIFY findableByChanged) public: static GlobalServicesScriptingInterface* getInstance(); @@ -65,8 +66,6 @@ private: GlobalServicesScriptingInterface(); ~GlobalServicesScriptingInterface(); - QString findableByString(Discoverability::Mode discoverabilityMode) const; - bool _downloading; }; diff --git a/interface/src/scripting/HMDScriptingInterface.h b/interface/src/scripting/HMDScriptingInterface.h index 276e23d2d5..7ecafdcbcb 100644 --- a/interface/src/scripting/HMDScriptingInterface.h +++ b/interface/src/scripting/HMDScriptingInterface.h @@ -27,7 +27,7 @@ class HMDScriptingInterface : public AbstractHMDScriptingInterface, public Depen Q_OBJECT Q_PROPERTY(glm::vec3 position READ getPosition) Q_PROPERTY(glm::quat orientation READ getOrientation) - Q_PROPERTY(bool mounted READ isMounted) + Q_PROPERTY(bool mounted READ isMounted NOTIFY mountedChanged) Q_PROPERTY(bool showTablet READ getShouldShowTablet) Q_PROPERTY(QUuid tabletID READ getCurrentTabletFrameID WRITE setCurrentTabletFrameID) Q_PROPERTY(QUuid homeButtonID READ getCurrentHomeButtonID WRITE setCurrentHomeButtonID) @@ -80,6 +80,7 @@ public: signals: bool shouldShowHandControllersChanged(); + void mountedChanged(); public: HMDScriptingInterface(); diff --git a/interface/src/scripting/WindowScriptingInterface.cpp b/interface/src/scripting/WindowScriptingInterface.cpp index 9c1aedf7a0..39c2f2e402 100644 --- a/interface/src/scripting/WindowScriptingInterface.cpp +++ b/interface/src/scripting/WindowScriptingInterface.cpp @@ -235,6 +235,14 @@ void WindowScriptingInterface::shareSnapshot(const QString& path, const QUrl& hr qApp->shareSnapshot(path, href); } +void WindowScriptingInterface::makeConnection(bool success, const QString& userNameOrError) { + if (success) { + emit connectionAdded(userNameOrError); + } else { + emit connectionError(userNameOrError); + } +} + bool WindowScriptingInterface::isPhysicsEnabled() { return qApp->isPhysicsEnabled(); } @@ -255,7 +263,7 @@ int WindowScriptingInterface::openMessageBox(QString title, QString text, int bu } int WindowScriptingInterface::createMessageBox(QString title, QString text, int buttons, int defaultButton) { - auto messageBox = DependencyManager::get()->createMessageBox(OffscreenUi::ICON_INFORMATION, title, text, + auto messageBox = DependencyManager::get()->createMessageBox(OffscreenUi::ICON_INFORMATION, title, text, static_cast>(buttons), static_cast(defaultButton)); connect(messageBox, SIGNAL(selected(int)), this, SLOT(onMessageBoxSelected(int))); diff --git a/interface/src/scripting/WindowScriptingInterface.h b/interface/src/scripting/WindowScriptingInterface.h index 60d24d50df..b7bed7d85f 100644 --- a/interface/src/scripting/WindowScriptingInterface.h +++ b/interface/src/scripting/WindowScriptingInterface.h @@ -56,6 +56,7 @@ public slots: void showAssetServer(const QString& upload = ""); void copyToClipboard(const QString& text); void takeSnapshot(bool notify = true, bool includeAnimated = false, float aspectRatio = 0.0f); + void makeConnection(bool success, const QString& userNameOrError); void shareSnapshot(const QString& path, const QUrl& href = QUrl("")); bool isPhysicsEnabled(); @@ -74,6 +75,9 @@ signals: void snapshotShared(const QString& error); void processingGif(); + void connectionAdded(const QString& connectionName); + void connectionError(const QString& errorString); + void messageBoxClosed(int id, int button); // triggered when window size or position changes diff --git a/interface/src/ui/overlays/Web3DOverlay.cpp b/interface/src/ui/overlays/Web3DOverlay.cpp index e50cf3a671..99fbcc18da 100644 --- a/interface/src/ui/overlays/Web3DOverlay.cpp +++ b/interface/src/ui/overlays/Web3DOverlay.cpp @@ -49,6 +49,8 @@ #include "ui/DomainConnectionModel.h" #include "scripting/AudioDeviceScriptingInterface.h" #include "ui/AvatarInputs.h" +#include "avatar/AvatarManager.h" +#include "scripting/GlobalServicesScriptingInterface.h" static const float DPI = 30.47f; static const float INCHES_TO_METERS = 1.0f / 39.3701f; @@ -194,6 +196,8 @@ void Web3DOverlay::loadSourceURL() { _webSurface->getRootContext()->setContextProperty("DCModel", DependencyManager::get().data()); _webSurface->getRootContext()->setContextProperty("AudioDevice", AudioDeviceScriptingInterface::getInstance()); _webSurface->getRootContext()->setContextProperty("AvatarInputs", AvatarInputs::getInstance()); + _webSurface->getRootContext()->setContextProperty("GlobalServices", GlobalServicesScriptingInterface::getInstance()); + _webSurface->getRootContext()->setContextProperty("AvatarList", DependencyManager::get().data()); _webSurface->getRootContext()->setContextProperty("pathToFonts", "../../"); tabletScriptingInterface->setQmlTabletRoot("com.highfidelity.interface.tablet.system", _webSurface->getRootItem(), _webSurface.data()); diff --git a/libraries/avatars/src/AvatarData.h b/libraries/avatars/src/AvatarData.h index f0759aedbd..1327798a0a 100644 --- a/libraries/avatars/src/AvatarData.h +++ b/libraries/avatars/src/AvatarData.h @@ -340,7 +340,7 @@ class AvatarData : public QObject, public SpatiallyNestable { Q_PROPERTY(float audioLoudness READ getAudioLoudness WRITE setAudioLoudness) Q_PROPERTY(float audioAverageLoudness READ getAudioAverageLoudness WRITE setAudioAverageLoudness) - Q_PROPERTY(QString displayName READ getDisplayName WRITE setDisplayName) + Q_PROPERTY(QString displayName READ getDisplayName WRITE setDisplayName NOTIFY displayNameChanged) // sessionDisplayName is sanitized, defaulted version displayName that is defined by the AvatarMixer rather than by Interface clients. // The result is unique among all avatars present at the time. Q_PROPERTY(QString sessionDisplayName READ getSessionDisplayName WRITE setSessionDisplayName) @@ -614,6 +614,9 @@ public: +signals: + void displayNameChanged(); + public slots: void sendAvatarDataPacket(); void sendIdentityPacket(); diff --git a/libraries/networking/src/AddressManager.h b/libraries/networking/src/AddressManager.h index c7d283ad02..83eedfc82f 100644 --- a/libraries/networking/src/AddressManager.h +++ b/libraries/networking/src/AddressManager.h @@ -41,7 +41,7 @@ class AddressManager : public QObject, public Dependency { Q_PROPERTY(QString pathname READ currentPath) Q_PROPERTY(QString placename READ getPlaceName) Q_PROPERTY(QString domainId READ getDomainId) - Q_PROPERTY(QUrl metaverseServerUrl READ getMetaverseServerUrl) + Q_PROPERTY(QUrl metaverseServerUrl READ getMetaverseServerUrl NOTIFY metaverseServerUrlChanged) public: Q_INVOKABLE QString protocolVersion(); using PositionGetter = std::function; @@ -123,6 +123,8 @@ signals: void goBackPossible(bool isPossible); void goForwardPossible(bool isPossible); + void metaverseServerUrlChanged(); + protected: AddressManager(); private slots: diff --git a/libraries/script-engine/src/TabletScriptingInterface.h b/libraries/script-engine/src/TabletScriptingInterface.h index 2e7b91fa4c..2cb15a5087 100644 --- a/libraries/script-engine/src/TabletScriptingInterface.h +++ b/libraries/script-engine/src/TabletScriptingInterface.h @@ -85,6 +85,7 @@ class TabletProxy : public QObject { Q_OBJECT Q_PROPERTY(QString name READ getName) Q_PROPERTY(bool toolbarMode READ getToolbarMode WRITE setToolbarMode) + Q_PROPERTY(bool tabletShown MEMBER _tabletShown NOTIFY tabletShownChanged) public: TabletProxy(QString name); @@ -206,6 +207,13 @@ signals: */ void screenChanged(QVariant type, QVariant url); + /** jsdoc + * Signaled when the tablet becomes visible or becomes invisible + * @function TabletProxy#isTabletShownChanged + * @returns {Signal} + */ + void tabletShownChanged(); + protected slots: void addButtonsToHomeScreen(); void desktopWindowClosed(); @@ -224,6 +232,7 @@ protected: QObject* _qmlOffscreenSurface { nullptr }; QmlWindowClass* _desktopWindow { nullptr }; bool _toolbarMode { false }; + bool _tabletShown { false }; enum class State { Uninitialized, Home, Web, Menu, QML }; State _state { State::Uninitialized }; diff --git a/libraries/script-engine/src/XMLHttpRequestClass.cpp b/libraries/script-engine/src/XMLHttpRequestClass.cpp index 4e528ec52c..1d3c8fda32 100644 --- a/libraries/script-engine/src/XMLHttpRequestClass.cpp +++ b/libraries/script-engine/src/XMLHttpRequestClass.cpp @@ -143,7 +143,7 @@ void XMLHttpRequestClass::open(const QString& method, const QString& url, bool a if (url.toLower().left(METAVERSE_API_URL.length()) == METAVERSE_API_URL) { auto accountManager = DependencyManager::get(); - if (_url.scheme() == "https" && accountManager->hasValidAccessToken()) { + if (accountManager->hasValidAccessToken()) { static const QString HTTP_AUTHORIZATION_HEADER = "Authorization"; QString bearerString = "Bearer " + accountManager->getAccountInfo().getAccessToken().token; _request.setRawHeader(HTTP_AUTHORIZATION_HEADER.toLocal8Bit(), bearerString.toLocal8Bit()); diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js index 109b92d33a..81ce72d901 100644 --- a/scripts/defaultScripts.js +++ b/scripts/defaultScripts.js @@ -21,10 +21,10 @@ var DEFAULT_SCRIPTS_COMBINED = [ "system/snapshot.js", "system/help.js", "system/pal.js", // "system/mod.js", // older UX, if you prefer + "system/makeUserConnection.js", "system/tablet-goto.js", "system/marketplaces/marketplaces.js", "system/edit.js", - "system/tablet-users.js", "system/selectAudioDevice.js", "system/notifications.js", "system/dialTone.js", diff --git a/scripts/system/makeUserConnection.js b/scripts/system/makeUserConnection.js new file mode 100644 index 0000000000..e70e0a2ea2 --- /dev/null +++ b/scripts/system/makeUserConnection.js @@ -0,0 +1,848 @@ +"use strict"; +// +// makeUserConnetion.js +// scripts/system +// +// Created by David Kelly on 3/7/2017. +// Copyright 2017 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 +// +(function() { // BEGIN LOCAL_SCOPE + +const label = "makeUserConnection"; +const MAX_AVATAR_DISTANCE = 0.2; // m +const GRIP_MIN = 0.05; // goes from 0-1, so 5% pressed is pressed +const MESSAGE_CHANNEL = "io.highfidelity.makeUserConnection"; +const STATES = { + inactive : 0, + waiting: 1, + connecting: 2, + makingConnection: 3 +}; +const STATE_STRINGS = ["inactive", "waiting", "connecting", "makingConnection"]; +const WAITING_INTERVAL = 100; // ms +const CONNECTING_INTERVAL = 100; // ms +const MAKING_CONNECTION_TIMEOUT = 800; // ms +const CONNECTING_TIME = 1600; // ms +const PARTICLE_RADIUS = 0.15; // m +const PARTICLE_ANGLE_INCREMENT = 360/45; // 1hz +const HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/4beat_sweep.wav"; +const SUCCESSFUL_HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/3rdbeat_success_bell.wav"; +const HAPTIC_DATA = { + initial: { duration: 20, strength: 0.6}, // duration is in ms + background: { duration: 100, strength: 0.3 }, // duration is in ms + success: { duration: 60, strength: 1.0} // duration is in ms +}; +const PARTICLE_EFFECT_PROPS = { + "alpha": 0.8, + "azimuthFinish": Math.PI, + "azimuthStart": -1*Math.PI, + "emitRate": 500, + "emitSpeed": 0.0, + "emitterShouldTrail": 1, + "isEmitting": 1, + "lifespan": 3, + "maxParticles": 1000, + "particleRadius": 0.003, + "polarStart": 1, + "polarFinish": 1, + "radiusFinish": 0.008, + "radiusStart": 0.0025, + "speedSpread": 0.025, + "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png", + "color": {"red": 255, "green": 255, "blue": 255}, + "colorFinish": {"red": 0, "green": 164, "blue": 255}, + "colorStart": {"red": 255, "green": 255, "blue": 255}, + "emitOrientation": {"w": -0.71, "x":0.0, "y":0.0, "z": 0.71}, + "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0}, + "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0}, + "dimensions": {"x":0.05, "y": 0.05, "z": 0.05}, + "type": "ParticleEffect" +}; +const MAKING_CONNECTION_PARTICLE_PROPS = { + "alpha": 0.07, + "alphaStart":0.011, + "alphaSpread": 0, + "alphaFinish": 0, + "azimuthFinish": Math.PI, + "azimuthStart": -1*Math.PI, + "emitRate": 2000, + "emitSpeed": 0.0, + "emitterShouldTrail": 1, + "isEmitting": 1, + "lifespan": 3.6, + "maxParticles": 4000, + "particleRadius": 0.048, + "polarStart": 0, + "polarFinish": 1, + "radiusFinish": 0.3, + "radiusStart": 0.04, + "speedSpread": 0.01, + "radiusSpread": 0.9, + "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png", + "color": {"red": 200, "green": 170, "blue": 255}, + "colorFinish": {"red": 0, "green": 134, "blue": 255}, + "colorStart": {"red": 185, "green": 222, "blue": 255}, + "emitOrientation": {"w": -0.71, "x":0.0, "y":0.0, "z": 0.71}, + "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0}, + "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0}, + "dimensions": {"x":0.05, "y": 0.05, "z": 0.05}, + "type": "ParticleEffect" +}; + +var currentHand; +var state = STATES.inactive; +var connectingInterval; +var waitingInterval; +var makingConnectionTimeout; +var animHandlerId; +var connectingId; +var connectingHand; +var waitingList = {}; +var particleEffect; +var waitingBallScale; +var particleRotationAngle = 0.0; +var makingConnectionParticleEffect; +var makingConnectionEmitRate = 2000; +var particleEmitRate = 500; +var handshakeInjector; +var successfulHandshakeInjector; +var handshakeSound; +var successfulHandshakeSound; + +function debug() { + var stateString = "<" + STATE_STRINGS[state] + ">"; + var connecting = "[" + connectingId + "/" + connectingHand + "]"; + print.apply(null, [].concat.apply([label, stateString, JSON.stringify(waitingList), connecting], [].map.call(arguments, JSON.stringify))); +} + +function cleanId(guidWithCurlyBraces) { + return guidWithCurlyBraces.slice(1, -1); +} +function request(options, callback) { // cb(error, responseOfCorrectContentType) of url. A subset of npm request. + var httpRequest = new XMLHttpRequest(), key; + // QT bug: apparently doesn't handle onload. Workaround using readyState. + httpRequest.onreadystatechange = function () { + var READY_STATE_DONE = 4; + var HTTP_OK = 200; + if (httpRequest.readyState >= READY_STATE_DONE) { + var error = (httpRequest.status !== HTTP_OK) && httpRequest.status.toString() + ':' + httpRequest.statusText, + response = !error && httpRequest.responseText, + contentType = !error && httpRequest.getResponseHeader('content-type'); + debug('FIXME REMOVE: server response', options, error, response, contentType); + if (!error && contentType.indexOf('application/json') === 0) { // ignoring charset, etc. + try { + response = JSON.parse(response); + } catch (e) { + error = e; + } + } + callback(error, response); + } + }; + if (typeof options === 'string') { + options = {uri: options}; + } + if (options.url) { + options.uri = options.url; + } + if (!options.method) { + options.method = 'GET'; + } + if (options.body && (options.method === 'GET')) { // add query parameters + var params = [], appender = (-1 === options.uri.search('?')) ? '?' : '&'; + for (key in options.body) { + params.push(key + '=' + options.body[key]); + } + options.uri += appender + params.join('&'); + delete options.body; + } + if (options.json) { + options.headers = options.headers || {}; + options.headers["Content-type"] = "application/json"; + options.body = JSON.stringify(options.body); + } + debug("FIXME REMOVE: final options to send", options); + for (key in options.headers || {}) { + httpRequest.setRequestHeader(key, options.headers[key]); + } + httpRequest.open(options.method, options.uri, true); + httpRequest.send(options.body); +} + +function handToString(hand) { + if (hand === Controller.Standard.RightHand) { + return "RightHand"; + } else if (hand === Controller.Standard.LeftHand) { + return "LeftHand"; + } + debug("handToString called without valid hand!"); + return ""; +} + +function stringToHand(hand) { + if (hand == "RightHand") { + return Controller.Standard.RightHand; + } else if (hand == "LeftHand") { + return Controller.Standard.LeftHand; + } + debug("stringToHand called with bad hand string:", hand); + return 0; +} + +function handToHaptic(hand) { + if (hand === Controller.Standard.RightHand) { + return 1; + } else if (hand === Controller.Standard.LeftHand) { + return 0; + } + debug("handToHaptic called without a valid hand!"); + return -1; +} + +function stopWaiting() { + if (waitingInterval) { + waitingInterval = Script.clearInterval(waitingInterval); + } +} + +function stopConnecting() { + if (connectingInterval) { + connectingInterval = Script.clearInterval(connectingInterval); + } +} + +function stopMakingConnection() { + if (makingConnectionTimeout) { + makingConnectionTimeout = Script.clearTimeout(makingConnectionTimeout); + } +} + +// This returns the position of the palm, really. Which relies on the avatar +// having the expected middle1 joint. TODO: fallback for when this isn't part +// of the avatar? +function getHandPosition(avatar, hand) { + if (!hand) { + debug("calling getHandPosition with no hand! (returning avatar position but this is a BUG)"); + debug(new Error().stack); + return avatar.position; + } + var jointName = handToString(hand) + "Middle1"; + return avatar.getJointPosition(avatar.getJointIndex(jointName)); +} + +function shakeHandsAnimation(animationProperties) { + // all we are doing here is moving the right hand to a spot + // that is in front of and a bit above the hips. Basing how + // far in front as scaling with the avatar's height (say hips + // to head distance) + var headIndex = MyAvatar.getJointIndex("Head"); + var offset = 0.5; // default distance of hand in front of you + var result = {}; + if (headIndex) { + offset = 0.8 * MyAvatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y; + } + var handPos = Vec3.multiply(offset, {x: -0.25, y: 0.8, z: 1.3}); + result.rightHandPosition = handPos; + result.rightHandRotation = Quat.fromPitchYawRollDegrees(90, 0, 90); + return result; +} + +function positionFractionallyTowards(posA, posB, frac) { + return Vec3.sum(posA, Vec3.multiply(frac, Vec3.subtract(posB, posA))); +} + +function deleteParticleEffect() { + if (particleEffect) { + particleEffect = Entities.deleteEntity(particleEffect); + } +} + +function deleteMakeConnectionParticleEffect() { + if (makingConnectionParticleEffect) { + makingConnectionParticleEffect = Entities.deleteEntity(makingConnectionParticleEffect); + } +} + +function stopHandshakeSound() { + if (handshakeInjector) { + handshakeInjector.stop(); + handshakeInjector = null; + } +} + +function calcParticlePos(myHand, otherHand, otherOrientation, reset) { + if (reset) { + particleRotationAngle = 0.0; + } + var position = positionFractionallyTowards(myHand, otherHand, 0.5); + particleRotationAngle += PARTICLE_ANGLE_INCREMENT; // about 0.5 hz + var radius = Math.min(PARTICLE_RADIUS, PARTICLE_RADIUS * particleRotationAngle / 360); + var axis = Vec3.mix(Quat.getFront(MyAvatar.orientation), Quat.inverse(Quat.getFront(otherOrientation)), 0.5); + return Vec3.sum(position, Vec3.multiplyQbyV(Quat.angleAxis(particleRotationAngle, axis), {x: 0, y: radius, z: 0})); +} + +// this is called frequently, but usually does nothing +function updateVisualization() { + if (state == STATES.inactive) { + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); + // this should always be true if inactive, but just in case: + currentHand = undefined; + return; + } + + var myHandPosition = getHandPosition(MyAvatar, currentHand); + var otherHand; + var otherOrientation; + if (connectingId) { + var other = AvatarList.getAvatar(connectingId); + if (other) { + otherOrientation = other.orientation; + otherHand = getHandPosition(other, stringToHand(connectingHand)); + } + } + + var wrist = MyAvatar.getJointPosition(MyAvatar.getJointIndex(handToString(currentHand))); + var d = Math.min(MAX_AVATAR_DISTANCE, Vec3.distance(wrist, myHandPosition)); + switch (state) { + case STATES.waiting: + // no visualization while waiting + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); + stopHandshakeSound(); + break; + case STATES.connecting: + var particleProps = {}; + // put the position between the 2 hands, if we have a connectingId. This + // helps define the plane in which the particles move. + positionFractionallyTowards(myHandPosition, otherHand, 0.5); + // now manage the rest of the entity + if (!particleEffect) { + particleRotationAngle = 0.0; + particleEmitRate = 500; + particleProps = PARTICLE_EFFECT_PROPS; + particleProps.isEmitting = 0; + particleProps.position = calcParticlePos(myHandPosition, otherHand, otherOrientation); + particleProps.parentID = MyAvatar.sessionUUID; + particleEffect = Entities.addEntity(particleProps, true); + } else { + particleProps.position = calcParticlePos(myHandPosition, otherHand, otherOrientation); + particleProps.isEmitting = 1; + Entities.editEntity(particleEffect, particleProps); + } + if (!makingConnectionParticleEffect) { + var props = MAKING_CONNECTION_PARTICLE_PROPS; + props.parentID = MyAvatar.sessionUUID; + makingConnectionEmitRate = 2000; + props.emitRate = makingConnectionEmitRate; + props.position = myHandPosition; + makingConnectionParticleEffect = Entities.addEntity(props, true); + } else { + makingConnectionEmitRate *= 0.5; + Entities.editEntity(makingConnectionParticleEffect, {emitRate: makingConnectionEmitRate, position: myHandPosition, isEmitting: 1}); + } + break; + case STATES.makingConnection: + particleEmitRate = Math.max(50, particleEmitRate * 0.5); + Entities.editEntity(makingConnectionParticleEffect, {emitRate: 0, isEmitting: 0, position: myHandPosition}); + Entities.editEntity(particleEffect, {position: calcParticlePos(myHandPosition, otherHand, otherOrientation), emitRate: particleEmitRate}); + break; + default: + debug("unexpected state", state); + break; + } +} + +function isNearby(id, hand) { + if (currentHand) { + var handPos = getHandPosition(MyAvatar, currentHand); + var avatar = AvatarList.getAvatar(id); + if (avatar) { + var otherHand = stringToHand(hand); + var distance = Vec3.distance(getHandPosition(avatar, otherHand), handPos); + return (distance < MAX_AVATAR_DISTANCE); + } + } + return false; +} + +function findNearestWaitingAvatar() { + var handPos = getHandPosition(MyAvatar, currentHand); + var minDistance = MAX_AVATAR_DISTANCE; + var nearestAvatar = {}; + Object.keys(waitingList).forEach(function (identifier) { + var avatar = AvatarList.getAvatar(identifier); + if (avatar) { + var hand = stringToHand(waitingList[identifier]); + var distance = Vec3.distance(getHandPosition(avatar, hand), handPos); + if (distance < minDistance) { + minDistance = distance; + nearestAvatar = {avatar: identifier, hand: hand}; + } + } + }); + return nearestAvatar; +} + + +// As currently implemented, we select the closest waiting avatar (if close enough) and send +// them a connectionRequest. If nobody is close enough we send a waiting message, and wait for a +// connectionRequest. If the 2 people who want to connect are both somewhat out of range when they +// initiate the shake, they will race to see who sends the connectionRequest after noticing the +// waiting message. Either way, they will start connecting eachother at that point. +function startHandshake(fromKeyboard) { + if (fromKeyboard) { + debug("adding animation"); + // just in case order of press/unpress is broken + if (animHandlerId) { + animHandlerId = MyAvatar.removeAnimationStateHandler(animHandlerId); + } + animHandlerId = MyAvatar.addAnimationStateHandler(shakeHandsAnimation, []); + } + debug("starting handshake for", currentHand); + pollCount = 0; + state = STATES.waiting; + connectingId = undefined; + connectingHand = undefined; + // just in case + stopWaiting(); + stopConnecting(); + stopMakingConnection(); + + var nearestAvatar = findNearestWaitingAvatar(); + if (nearestAvatar.avatar) { + connectingId = nearestAvatar.avatar; + connectingHand = handToString(nearestAvatar.hand); + debug("sending connectionRequest to", connectingId); + messageSend({ + key: "connectionRequest", + id: connectingId, + hand: handToString(currentHand) + }); + } else { + // send waiting message + debug("sending waiting message"); + messageSend({ + key: "waiting", + hand: handToString(currentHand) + }); + lookForWaitingAvatar(); + } +} + +function endHandshake() { + debug("ending handshake for", currentHand); + + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); + currentHand = undefined; + // note that setting the state to inactive should really + // only be done here, unless we change how the triggering works, + // as we ignore the key release event when inactive. See updateTriggers + // below. + state = STATES.inactive; + connectingId = undefined; + connectingHand = undefined; + stopWaiting(); + stopConnecting(); + stopMakingConnection(); + stopHandshakeSound(); + // send done to let connection know you are not making connections now + messageSend({ + key: "done" + }); + + if (animHandlerId) { + debug("removing animation"); + MyAvatar.removeAnimationStateHandler(animHandlerId); + } + // No-op if we were successful, but this way we ensure that failures and abandoned handshakes don't leave us in a weird state. + request({uri: requestUrl, method: 'DELETE'}, debug); +} + +function updateTriggers(value, fromKeyboard, hand) { + if (currentHand && hand !== currentHand) { + debug("currentHand", currentHand, "ignoring messages from", hand); + return; + } + if (!currentHand) { + currentHand = hand; + } + // ok now, we are either initiating or quitting... + var isGripping = value > GRIP_MIN; + if (isGripping) { + debug("updateTriggers called - gripping", handToString(hand)); + if (state != STATES.inactive) { + return; + } else { + startHandshake(fromKeyboard); + } + } else { + // TODO: should we end handshake even when inactive? Ponder + debug("updateTriggers called -- no longer gripping", handToString(hand)); + if (state != STATES.inactive) { + endHandshake(); + } else { + return; + } + } +} + +function messageSend(message) { + Messages.sendMessage(MESSAGE_CHANNEL, JSON.stringify(message)); +} + +function lookForWaitingAvatar() { + // we started with nobody close enough, but maybe I've moved + // or they did. Note that 2 people doing this race, so stop + // as soon as you have a connectingId (which means you got their + // message before noticing they were in range in this loop) + + // just in case we reenter before stopping + stopWaiting(); + debug("started looking for waiting avatars"); + waitingInterval = Script.setInterval(function () { + if (state == STATES.waiting && !connectingId) { + // find the closest in-range avatar, and send connection request + var nearestAvatar = findNearestWaitingAvatar(); + if (nearestAvatar.avatar) { + connectingId = nearestAvatar.avatar; + connectingHand = handToString(nearestAvatar.hand); + debug("sending connectionRequest to", connectingId); + messageSend({ + key: "connectionRequest", + id: connectingId, + hand: handToString(currentHand) + }); + } + } else { + // something happened, stop looking for avatars to connect + stopWaiting(); + debug("stopped looking for waiting avatars"); + } + }, WAITING_INTERVAL); +} + +/* There is a mini-state machine after entering STATES.makingConnection. + We make a request (which might immediately succeed, fail, or neither. + If we immediately fail, we tell the user. + Otherwise, we wait MAKING_CONNECTION_TIMEOUT. At that time, we poll until success or fail. + */ +var result, requestBody, pollCount = 0, requestUrl = location.metaverseServerUrl + '/api/v1/user/connection_request'; +function connectionRequestCompleted() { // Final result is in. Do effects. + if (result.status === 'success') { // set earlier + if (!successfulHandshakeInjector) { + successfulHandshakeInjector = Audio.playSound(successfulHandshakeSound, {position: getHandPosition(MyAvatar, currentHand), volume: 0.5, localOnly: true}); + } else { + successfulHandshakeInjector.restart(); + } + Controller.triggerHapticPulse(HAPTIC_DATA.success.strength, HAPTIC_DATA.success.duration, handToHaptic(currentHand)); + // don't change state (so animation continues while gripped) + // but do send a notification, by calling the slot that emits the signal for it + Window.makeConnection(true, result.connection.new_connection ? "You and " + result.connection.username + " are now connected!" : result.connection.username); + return; + } // failed + endHandshake(); + debug("failing with result data", result); + // IWBNI we also did some fail sound/visual effect. + Window.makeConnection(false, result.connection); +} +var POLL_INTERVAL_MS = 200, POLL_LIMIT = 5; +function handleConnectionResponseAndMaybeRepeat(error, response) { + // If response is 'pending', set a short timeout to try again. + // If we fail other than pending, set result and immediately call connectionRequestCompleted. + // If we succceed, set result and call connectionRequestCompleted immediately (if we've been polling), and otherwise on a timeout. + if (response && (response.connection === 'pending')) { + debug(response, 'pollCount', pollCount); + if (pollCount++ >= POLL_LIMIT) { // server will expire, but let's not wait that long. + debug('POLL LIMIT REACHED; TIMEOUT: expired message generated by CLIENT'); + result = {status: 'error', connection: 'expired'}; + connectionRequestCompleted(); + } else { // poll + Script.setTimeout(function () { + request({ + uri: requestUrl, + // N.B.: server gives bad request if we specify json content type, so don't do that. + body: requestBody + }, handleConnectionResponseAndMaybeRepeat); + }, POLL_INTERVAL_MS); + } + } else if (error || (response.status !== 'success')) { + debug('server fail', error, response.status); + result = error ? {status: 'error', connection: error} : response; + connectionRequestCompleted(); + } else { + debug('server success', result); + result = response; + if (pollCount++) { + connectionRequestCompleted(); + } else { // Wait for other guy, so that final succcess is at roughly the same time. + Script.setTimeout(connectionRequestCompleted, MAKING_CONNECTION_TIMEOUT); + } + } +} + +// this should be where we make the appropriate connection call. For now just make the +// visualization change. +function makeConnection(id) { + // send done to let the connection know you have made connection. + messageSend({ + key: "done", + connectionId: id + }); + + state = STATES.makingConnection; + + // continue the haptic background until the timeout fires. When we make calls, we will have an interval + // probably, in which we do this. + Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, MAKING_CONNECTION_TIMEOUT, handToHaptic(currentHand)); + requestBody = {node_id: cleanId(MyAvatar.sessionUUID), proposed_node_id: cleanId(id)}; // for use when repeating + // This will immediately set response if successfull (e.g., the other guy got his request in first), or immediate failure, + // and will otherwise poll (using the requestBody we just set). + request({ // + uri: requestUrl, + method: 'POST', + json: true, + body: {user_connection_request: requestBody} + }, handleConnectionResponseAndMaybeRepeat); +} + +// we change states, start the connectionInterval where we check +// to be sure the hand is still close enough. If not, we terminate +// the interval, go back to the waiting state. If we make it +// the entire CONNECTING_TIME, we make the connection. +function startConnecting(id, hand) { + var count = 0; + debug("connecting", id, "hand", hand); + // do we need to do this? + connectingId = id; + connectingHand = hand; + state = STATES.connecting; + + // play sound + if (!handshakeInjector) { + handshakeInjector = Audio.playSound(handshakeSound, {position: getHandPosition(MyAvatar, currentHand), volume: 0.5, localOnly: true}); + } else { + handshakeInjector.restart(); + } + + // send message that we are connecting with them + messageSend({ + key: "connecting", + id: id, + hand: handToString(currentHand) + }); + Controller.triggerHapticPulse(HAPTIC_DATA.initial.strength, HAPTIC_DATA.initial.duration, handToHaptic(currentHand)); + + connectingInterval = Script.setInterval(function () { + count += 1; + Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, HAPTIC_DATA.background.duration, handToHaptic(currentHand)); + if (state != STATES.connecting) { + debug("stopping connecting interval, state changed"); + stopConnecting(); + } else if (!isNearby(id, hand)) { + // gotta go back to waiting + debug(id, "moved, back to waiting"); + stopConnecting(); + messageSend({ + key: "done" + }); + startHandshake(); + } else if (count > CONNECTING_TIME/CONNECTING_INTERVAL) { + debug("made connection with " + id); + makeConnection(id); + stopConnecting(); + } + }, CONNECTING_INTERVAL); +} +/* +A simple sequence diagram: NOTE that the ConnectionAck is somewhat +vestigial, and probably should be removed shortly. + + Avatar A Avatar B + | | + | <-----(waiting) ----- startHandshake +startHandshake - (connectionRequest) -> | + | | + | <----(connectionAck) -------- | + | <-----(connecting) -- startConnecting + startConnecting ---(connecting) ----> | + | | + | connected + connected | + | <--------- (done) ---------- | + | ---------- (done) ---------> | +*/ +function messageHandler(channel, messageString, senderID) { + if (channel !== MESSAGE_CHANNEL) { + return; + } + if (MyAvatar.sessionUUID === senderID) { // ignore my own + return; + } + var message = {}; + try { + message = JSON.parse(messageString); + } catch (e) { + debug(e); + } + switch (message.key) { + case "waiting": + // add this guy to waiting object. Any other message from this person will + // remove it from the list + waitingList[senderID] = message.hand; + break; + case "connectionRequest": + delete waitingList[senderID]; + if (state == STATES.waiting && message.id == MyAvatar.sessionUUID && (!connectingId || connectingId == senderID)) { + // you were waiting for a connection request, so send the ack. Or, you and the other + // guy raced and both send connectionRequests. Handle that too + connectingId = senderID; + connectingHand = message.hand; + messageSend({ + key: "connectionAck", + id: senderID, + hand: handToString(currentHand) + }); + } else { + if (state == STATES.waiting && connectingId == senderID) { + // the person you are trying to connect sent a request to someone else. See the + // if statement above. So, don't cry, just start the handshake over again + startHandshake(); + } + } + break; + case "connectionAck": + delete waitingList[senderID]; + if (state == STATES.waiting && (!connectingId || connectingId == senderID)) { + if (message.id == MyAvatar.sessionUUID) { + // start connecting... + connectingId = senderID; + connectingHand = message.hand; + stopWaiting(); + startConnecting(senderID, message.hand); + } else { + if (connectingId) { + // this is for someone else (we lost race in connectionRequest), + // so lets start over + startHandshake(); + } + } + } + // TODO: check to see if we are waiting for this but the person we are connecting sent it to + // someone else, and try again + break; + case "connecting": + delete waitingList[senderID]; + if (state == STATES.waiting && senderID == connectingId) { + // temporary logging + if (connectingHand != message.hand) { + debug("connecting hand", connectingHand, "not same as connecting hand in message", message.hand); + } + connectingHand = message.hand; + if (message.id != MyAvatar.sessionUUID) { + // the person we were trying to connect is connecting to someone else + // so try again + startHandshake(); + break; + } + startConnecting(senderID, message.hand); + } + break; + case "done": + delete waitingList[senderID]; + if (state == STATES.connecting && connectingId == senderID) { + // if they are done, and didn't connect us, terminate our + // connecting + if (message.connectionId !== MyAvatar.sessionUUID) { + stopConnecting(); + // now just call startHandshake. Should be ok to do so without a + // value for isKeyboard, as we should not change the animation + // state anyways (if any) + startHandshake(); + } + } else { + // if waiting or inactive, lets clear the connecting id. If in makingConnection, + // do nothing + if (state != STATES.makingConnection && connectingId == senderID) { + connectingId = undefined; + connectingHand = undefined; + if (state != STATES.inactive) { + startHandshake(); + } + } + } + break; + default: + debug("unknown message", message); + break; + } +} + +Messages.subscribe(MESSAGE_CHANNEL); +Messages.messageReceived.connect(messageHandler); + + +function makeGripHandler(hand, animate) { + // determine if we are gripping or un-gripping + if (animate) { + return function(value) { + updateTriggers(value, true, hand); + }; + + } else { + return function (value) { + updateTriggers(value, false, hand); + }; + } +} + +function keyPressEvent(event) { + if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && !event.isAlt) { + updateTriggers(1.0, true, Controller.Standard.RightHand); + } +} +function keyReleaseEvent(event) { + if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && !event.isAlt) { + updateTriggers(0.0, true, Controller.Standard.RightHand); + } +} +// map controller actions +var connectionMapping = Controller.newMapping(Script.resolvePath('') + '-grip'); +connectionMapping.from(Controller.Standard.LeftGrip).peek().to(makeGripHandler(Controller.Standard.LeftHand)); +connectionMapping.from(Controller.Standard.RightGrip).peek().to(makeGripHandler(Controller.Standard.RightHand)); + +// setup keyboard initiation +Controller.keyPressEvent.connect(keyPressEvent); +Controller.keyReleaseEvent.connect(keyReleaseEvent); + +// xbox controller cuz that's important +connectionMapping.from(Controller.Standard.RB).peek().to(makeGripHandler(Controller.Standard.RightHand, true)); + +// it is easy to forget this and waste a lot of time for nothing +connectionMapping.enable(); + +// connect updateVisualization to update frequently +Script.update.connect(updateVisualization); + +// load the sounds when the script loads +handshakeSound = SoundCache.getSound(HANDSHAKE_SOUND_URL); +successfulHandshakeSound = SoundCache.getSound(SUCCESSFUL_HANDSHAKE_SOUND_URL); + +Script.scriptEnding.connect(function () { + debug("removing controller mappings"); + connectionMapping.disable(); + debug("removing key mappings"); + Controller.keyPressEvent.disconnect(keyPressEvent); + Controller.keyReleaseEvent.disconnect(keyReleaseEvent); + debug("disconnecting updateVisualization"); + Script.update.disconnect(updateVisualization); + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); +}); + +}()); // END LOCAL_SCOPE + diff --git a/scripts/system/notifications.js b/scripts/system/notifications.js index b527348733..3989e8e372 100644 --- a/scripts/system/notifications.js +++ b/scripts/system/notifications.js @@ -95,12 +95,14 @@ var NotificationType = { CONNECTION_REFUSED: 3, EDIT_ERROR: 4, TABLET: 5, + CONNECTION: 6, properties: [ { text: "Snapshot" }, { text: "Level of Detail" }, { text: "Connection Refused" }, { text: "Edit error" }, - { text: "Tablet" } + { text: "Tablet" }, + { text: "Connection" } ], getTypeFromMenuItem: function(menuItemName) { if (menuItemName.substr(menuItemName.length - NOTIFICATION_MENU_ITEM_POST.length) !== NOTIFICATION_MENU_ITEM_POST) { @@ -545,6 +547,14 @@ function processingGif() { createNotification("Processing GIF snapshot...", NotificationType.SNAPSHOT); } +function connectionAdded(connectionName) { + createNotification(connectionName, NotificationType.CONNECTION); +} + +function connectionError(error) { + createNotification(wordWrap("Error trying to make connection: " + error), NotificationType.CONNECTION); +} + // handles mouse clicks on buttons function mousePressEvent(event) { var pickRay, @@ -645,6 +655,8 @@ Menu.menuItemEvent.connect(menuItemEvent); Window.domainConnectionRefused.connect(onDomainConnectionRefused); Window.snapshotTaken.connect(onSnapshotTaken); Window.processingGif.connect(processingGif); +Window.connectionAdded.connect(connectionAdded); +Window.connectionError.connect(connectionError); Window.notifyEditError = onEditError; Window.notify = onNotify; Tablet.tabletNotification.connect(tabletNotification); diff --git a/scripts/system/pal.js b/scripts/system/pal.js index d85fbce19a..b39c993894 100644 --- a/scripts/system/pal.js +++ b/scripts/system/pal.js @@ -1,6 +1,6 @@ "use strict"; -/* jslint vars: true, plusplus: true, forin: true*/ -/* globals Tablet, Script, AvatarList, Users, Entities, MyAvatar, Camera, Overlays, Vec3, Quat, Controller, print, getControllerWorldLocation */ +/*jslint vars:true, plusplus:true, forin:true*/ +/*global Tablet, Settings, Script, AvatarList, Users, Entities, MyAvatar, Camera, Overlays, Vec3, Quat, HMD, Controller, Account, UserActivityLogger, Messages, Window, XMLHttpRequest, print, location, getControllerWorldLocation*/ /* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */ // // pal.js @@ -14,6 +14,12 @@ (function() { // BEGIN LOCAL_SCOPE +var populateNearbyUserList, color, textures, removeOverlays, + controllerComputePickRay, onTabletButtonClicked, onTabletScreenChanged, + receiveMessage, avatarDisconnected, clearLocalQMLDataAndClosePAL, + createAudioInterval, tablet, CHANNEL, getConnectionData, findableByChanged, + avatarAdded, avatarRemoved, avatarSessionChanged; // forward references; + // hardcoding these as it appears we cannot traverse the originalTextures in overlays??? Maybe I've missed // something, will revisit as this is sorta horrible. var UNSELECTED_TEXTURES = { @@ -97,9 +103,8 @@ ExtendedOverlay.prototype.hover = function (hovering) { if (this.key === lastHoveringId) { if (hovering) { return; - } else { - lastHoveringId = 0; } + lastHoveringId = 0; } this.editOverlay({color: color(this.selected, hovering, this.audioLevel)}); if (this.model) { @@ -214,9 +219,8 @@ function convertDbToLinear(decibels) { // but, your perception is that something 2x as loud is +10db // so we go from -60 to +20 or 1/64x to 4x. For now, we can // maybe scale the signal this way?? - return Math.pow(2, decibels/10.0); + return Math.pow(2, decibels / 10.0); } - function fromQml(message) { // messages are {method, params}, like json-rpc. See also sendToQml. var data; switch (message.method) { @@ -247,7 +251,7 @@ function fromQml(message) { // messages are {method, params}, like json-rpc. See }); } break; - case 'refresh': + case 'refreshNearby': data = {}; ExtendedOverlay.some(function (overlay) { // capture the audio data data[overlay.key] = overlay; @@ -257,14 +261,45 @@ function fromQml(message) { // messages are {method, params}, like json-rpc. See if (message.params.filter !== undefined) { Settings.setValue('pal/filtered', !!message.params.filter); } - populateUserList(message.params.selected, data); - UserActivityLogger.palAction("refresh", ""); + populateNearbyUserList(message.params.selected, data); + UserActivityLogger.palAction("refresh_nearby", ""); break; - case 'displayNameUpdate': - if (MyAvatar.displayName !== message.params) { - MyAvatar.displayName = message.params; - UserActivityLogger.palAction("display_name_change", ""); - } + case 'refreshConnections': + print('Refreshing Connections...'); + getConnectionData(); + UserActivityLogger.palAction("refresh_connections", ""); + break; + case 'removeFriend': + friendUserName = message.params; + request({ + uri: METAVERSE_BASE + '/api/v1/user/friends/' + friendUserName, + method: 'DELETE' + }, function (error, response) { + print(JSON.stringify(response)); + if (error || (response.status !== 'success')) { + print("Error: unable to unfriend", friendUserName, error || response.status); + return; + } + getConnectionData(); + }); + break + case 'addFriend': + friendUserName = message.params; + request({ + uri: METAVERSE_BASE + '/api/v1/user/friends', + method: 'POST', + json: true, + body: { + username: friendUserName, + } + }, function (error, response) { + if (error || (response.status !== 'success')) { + print("Error: unable to friend " + friendUserName, error || response.status); + return; + } + getConnectionData(); // For now, just refresh all connection data. Later, just refresh the one friended row. + } + ); break; default: print('Unrecognized message from Pal.qml:', JSON.stringify(message)); @@ -274,6 +309,141 @@ function fromQml(message) { // messages are {method, params}, like json-rpc. See function sendToQml(message) { tablet.sendToQml(message); } +function updateUser(data) { + print('PAL update:', JSON.stringify(data)); + sendToQml({ method: 'updateUsername', params: data }); +} +// +// User management services +// +// These are prototype versions that will be changed when the back end changes. +var METAVERSE_BASE = location.metaverseServerUrl; +function request(options, callback) { // cb(error, responseOfCorrectContentType) of url. A subset of npm request. + var httpRequest = new XMLHttpRequest(), key; + // QT bug: apparently doesn't handle onload. Workaround using readyState. + httpRequest.onreadystatechange = function () { + var READY_STATE_DONE = 4; + var HTTP_OK = 200; + if (httpRequest.readyState >= READY_STATE_DONE) { + var error = (httpRequest.status !== HTTP_OK) && httpRequest.status.toString() + ':' + httpRequest.statusText, + response = !error && httpRequest.responseText, + contentType = !error && httpRequest.getResponseHeader('content-type'); + if (!error && contentType.indexOf('application/json') === 0) { // ignoring charset, etc. + try { + response = JSON.parse(response); + } catch (e) { + error = e; + } + } + callback(error, response); + } + }; + if (typeof options === 'string') { + options = {uri: options}; + } + if (options.url) { + options.uri = options.url; + } + if (!options.method) { + options.method = 'GET'; + } + if (options.body && (options.method === 'GET')) { // add query parameters + var params = [], appender = (-1 === options.uri.search('?')) ? '?' : '&'; + for (key in options.body) { + params.push(key + '=' + options.body[key]); + } + options.uri += appender + params.join('&'); + delete options.body; + } + if (options.json) { + options.headers = options.headers || {}; + options.headers["Content-type"] = "application/json"; + options.body = JSON.stringify(options.body); + } + for (key in options.headers || {}) { + httpRequest.setRequestHeader(key, options.headers[key]); + } + httpRequest.open(options.method, options.uri, true); + httpRequest.send(options.body); +} + + +function requestJSON(url, callback) { // callback(data) if successfull. Logs otherwise. + request({ + uri: url + }, function (error, response) { + if (error || (response.status !== 'success')) { + print("Error: unable to get", url, error || response.status); + return; + } + callback(response.data); + }); +} +function getProfilePicture(username, callback) { // callback(url) if successfull. (Logs otherwise) + // FIXME Prototype scrapes profile picture. We should include in user status, and also make available somewhere for myself + request({ + uri: METAVERSE_BASE + '/users/' + username + }, function (error, html) { + var matched = !error && html.match(/img class="users-img" src="([^"]*)"/); + if (!matched) { + print('Error: Unable to get profile picture for', username, error); + return; + } + callback(matched[1]); + }); +} +function getAvailableConnections(domain, callback) { // callback([{usename, location}...]) if successfull. (Logs otherwise) + // The back end doesn't do user connections yet. Fake it by getting all users that have made themselves accessible to us, + // and pretending that they are all connections. + url = METAVERSE_BASE + '/api/v1/users?' + if (domain) { + url += 'status=' + domain.slice(1, -1); // without curly braces + } else { + url += 'filter=connections'; // regardless of whether online + } + requestJSON(url, function (connectionsData) { + // The back end doesn't include the profile picture data, but we can add that here. + // For our current purposes, there's no need to be fancy and try to reduce latency by doing some number of requests in parallel, + // so these requests are all sequential. + var users = connectionsData.users; + function addPicture(index) { + if (index >= users.length) { + return callback(users); + } + var user = users[index]; + getProfilePicture(user.username, function (url) { + user.profileUrl = url; + addPicture(index + 1); + }); + } + addPicture(0); + }); +} + +function getConnectionData(domain) { // Update all the usernames that I am entitled to see, using my login but not dependent on canKick. + function frob(user) { // get into the right format + var formattedSessionId = user.location.node_id || ''; + if (formattedSessionId !== '' && formattedSessionId.indexOf("{") != 0) { + formattedSessionId = "{" + formattedSessionId + "}"; + } + return { + sessionId: formattedSessionId, + userName: user.username, + connection: user.connection, + profileUrl: user.profileUrl, + placeName: (user.location.root || user.location.domain || {}).name || '' + }; + } + getAvailableConnections(domain, function (users) { + if (domain) { + users.forEach(function (user) { + updateUser(frob(user)); + }); + } else { + sendToQml({ method: 'connections', params: users.map(frob) }); + } + }); +} // // Main operations. @@ -285,15 +455,16 @@ function addAvatarNode(id) { solid: true, alpha: 0.8, color: color(selected, false, 0.0), - ignoreRayIntersection: false}, selected, !conserveResources); + ignoreRayIntersection: false + }, selected, !conserveResources); } // Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter. var avatarsOfInterest = {}; -function populateUserList(selectData, oldAudioData) { - var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')}; - var data = [], avatars = AvatarList.getAvatarIdentifiers(); - avatarsOfInterest = {}; - var myPosition = filter && Camera.position, +function populateNearbyUserList(selectData, oldAudioData) { + var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')}, + data = [], + avatars = AvatarList.getAvatarIdentifiers(), + myPosition = filter && Camera.position, frustum = filter && Camera.frustum, verticalHalfAngle = filter && (frustum.fieldOfView / 2), horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio), @@ -301,7 +472,8 @@ function populateUserList(selectData, oldAudioData) { forward = filter && Quat.getForward(orientation), verticalAngleNormal = filter && Quat.getRight(orientation), horizontalAngleNormal = filter && Quat.getUp(orientation); - avatars.forEach(function (id) { // sorting the identifiers is just an aid for debugging + avatarsOfInterest = {}; + avatars.forEach(function (id) { var avatar = AvatarList.getAvatar(id); var name = avatar.sessionDisplayName; if (!name) { @@ -323,26 +495,33 @@ function populateUserList(selectData, oldAudioData) { } var oldAudio = oldAudioData && oldAudioData[id]; var avatarPalDatum = { + profileUrl: '', displayName: name, userName: '', + connection: '', sessionId: id || '', audioLevel: (oldAudio && oldAudio.audioLevel) || 0.0, avgAudioLevel: (oldAudio && oldAudio.avgAudioLevel) || 0.0, admin: false, personalMute: !!id && Users.getPersonalMuteStatus(id), // expects proper boolean, not null - ignore: !!id && Users.getIgnoreStatus(id) // ditto + ignore: !!id && Users.getIgnoreStatus(id), // ditto + isPresent: true }; if (id) { addAvatarNode(id); // No overlay for ourselves // Everyone needs to see admin status. Username and fingerprint returns default constructor output if the requesting user isn't an admin. Users.requestUsernameFromID(id); avatarsOfInterest[id] = true; + } else { + // Return our username from the Account API + avatarPalDatum.userName = Account.username; } data.push(avatarPalDatum); print('PAL data:', JSON.stringify(avatarPalDatum)); }); + getConnectionData(location.domainId); // Even admins don't get relationship data in requestUsernameFromID (which is still needed for admin status, which comes from domain). conserveResources = Object.keys(avatarsOfInterest).length > 20; - sendToQml({ method: 'users', params: data }); + sendToQml({ method: 'nearbyUsers', params: data }); if (selectData) { selectData[2] = true; sendToQml({ method: 'select', params: selectData }); @@ -351,15 +530,15 @@ function populateUserList(selectData, oldAudioData) { // The function that handles the reply from the server function usernameFromIDReply(id, username, machineFingerprint, isAdmin) { - var data = [ - (MyAvatar.sessionUUID === id) ? '' : id, // Pal.qml recognizes empty id specially. + var data = { + sessionId: (MyAvatar.sessionUUID === id) ? '' : id, // Pal.qml recognizes empty id specially. // If we get username (e.g., if in future we receive it when we're friends), use it. // Otherwise, use valid machineFingerprint (which is not valid when not an admin). - username || (Users.canKick && machineFingerprint) || '', - isAdmin - ]; + userName: username || (Users.canKick && machineFingerprint) || '', + admin: isAdmin + }; // Ship the data off to QML - sendToQml({ method: 'updateUsername', params: data }); + updateUser(data); } var pingPong = true; @@ -381,16 +560,12 @@ function updateOverlays() { var target = avatar.position; var distance = Vec3.distance(target, eye); var offset = 0.2; - - // base offset on 1/2 distance from hips to head if we can - var headIndex = avatar.getJointIndex("Head"); + var diff = Vec3.subtract(target, eye); // get diff between target and eye (a vector pointing to the eye from avatar position) + var headIndex = avatar.getJointIndex("Head"); // base offset on 1/2 distance from hips to head if we can if (headIndex > 0) { offset = avatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y / 2; } - // get diff between target and eye (a vector pointing to the eye from avatar position) - var diff = Vec3.subtract(target, eye); - // move a bit in front, towards the camera target = Vec3.subtract(target, Vec3.multiply(Vec3.normalize(diff), offset)); @@ -418,7 +593,7 @@ function updateOverlays() { overlay.deleteOverlay(); } }); - // We could re-populateUserList if anything added or removed, but not for now. + // We could re-populateNearbyUserList if anything added or removed, but not for now. HighlightedEntity.updateOverlays(); } function removeOverlays() { @@ -543,6 +718,9 @@ function startup() { Messages.subscribe(CHANNEL); Messages.messageReceived.connect(receiveMessage); Users.avatarDisconnected.connect(avatarDisconnected); + AvatarList.avatarAddedEvent.connect(avatarAdded); + AvatarList.avatarRemovedEvent.connect(avatarRemoved); + AvatarList.avatarSessionChangedEvent.connect(avatarSessionChanged); } startup(); @@ -556,6 +734,7 @@ function off() { Script.update.disconnect(updateOverlays); Controller.mousePressEvent.disconnect(handleMouseEvent); Controller.mouseMoveEvent.disconnect(handleMouseMoveEvent); + tablet.tabletShownChanged.disconnect(tabletVisibilityChanged); isWired = false; } if (audioTimer) { @@ -567,6 +746,12 @@ function off() { Users.requestsDomainListData = false; } +function tabletVisibilityChanged() { + if (!tablet.tabletShown) { + tablet.gotoHomeScreen(); + } +} + var onPalScreen = false; var shouldActivateButton = false; @@ -577,9 +762,10 @@ function onTabletButtonClicked() { } else { shouldActivateButton = true; tablet.loadQMLSource("../Pal.qml"); + tablet.tabletShownChanged.connect(tabletVisibilityChanged); onPalScreen = true; Users.requestsDomainListData = true; - populateUserList(); + populateNearbyUserList(); isWired = true; Script.update.connect(updateOverlays); Controller.mousePressEvent.connect(handleMouseEvent); @@ -607,8 +793,7 @@ function onTabletScreenChanged(type, url) { // var CHANNEL = 'com.highfidelity.pal'; function receiveMessage(channel, messageString, senderID) { - if ((channel !== CHANNEL) || - (senderID !== MyAvatar.sessionUUID)) { + if ((channel !== CHANNEL) || (senderID !== MyAvatar.sessionUUID)) { return; } var message = JSON.parse(messageString); @@ -633,7 +818,7 @@ function scaleAudio(val) { if (val <= LOUDNESS_FLOOR) { audioLevel = val / LOUDNESS_FLOOR * LOUDNESS_SCALE; } else { - audioLevel = (val -(LOUDNESS_FLOOR -1 )) * LOUDNESS_SCALE; + audioLevel = (val - (LOUDNESS_FLOOR - 1)) * LOUDNESS_SCALE; } if (audioLevel > 1.0) { audioLevel = 1; @@ -659,14 +844,14 @@ function getAudioLevel(id) { audioLevel = scaleAudio(Math.log(data.accumulatedLevel + 1) / LOG2); // decay avgAudioLevel - avgAudioLevel = Math.max((1-AUDIO_PEAK_DECAY) * (data.avgAudioLevel || 0), audioLevel); + avgAudioLevel = Math.max((1 - AUDIO_PEAK_DECAY) * (data.avgAudioLevel || 0), audioLevel); data.avgAudioLevel = avgAudioLevel; data.audioLevel = audioLevel; // now scale for the gain. Also, asked to boost the low end, so one simple way is // to take sqrt of the value. Lets try that, see how it feels. - avgAudioLevel = Math.min(1.0, Math.sqrt(avgAudioLevel *(sessionGains[id] || 0.75))); + avgAudioLevel = Math.min(1.0, Math.sqrt(avgAudioLevel * (sessionGains[id] || 0.75))); } return [audioLevel, avgAudioLevel]; } @@ -677,9 +862,8 @@ function createAudioInterval(interval) { return Script.setInterval(function () { var param = {}; AvatarList.getAvatarIdentifiers().forEach(function (id) { - var level = getAudioLevel(id); - // qml didn't like an object with null/empty string for a key, so... - var userId = id || 0; + var level = getAudioLevel(id), + userId = id || 0; // qml didn't like an object with null/empty string for a key, so... param[userId] = level; }); sendToQml({method: 'updateAudioLevel', params: param}); @@ -695,6 +879,18 @@ function clearLocalQMLDataAndClosePAL() { sendToQml({ method: 'clearLocalQMLData' }); } +function avatarAdded(avatarID) { + sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarAdded'] }); +} + +function avatarRemoved(avatarID) { + sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarRemoved'] }); +} + +function avatarSessionChanged(avatarID) { + sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarSessionChanged'] }); +} + function shutdown() { if (onPalScreen) { tablet.gotoHomeScreen(); @@ -708,6 +904,9 @@ function shutdown() { Messages.subscribe(CHANNEL); Messages.messageReceived.disconnect(receiveMessage); Users.avatarDisconnected.disconnect(avatarDisconnected); + AvatarList.avatarAddedEvent.disconnect(avatarAdded); + AvatarList.avatarRemovedEvent.disconnect(avatarRemoved); + AvatarList.avatarSessionChangedEvent.disconnect(avatarSessionChanged); off(); } diff --git a/scripts/system/tablet-ui/tabletUI.js b/scripts/system/tablet-ui/tabletUI.js index a010cb0a9c..a653d193bd 100644 --- a/scripts/system/tablet-ui/tabletUI.js +++ b/scripts/system/tablet-ui/tabletUI.js @@ -16,7 +16,6 @@ MyAvatar, Menu */ (function() { // BEGIN LOCAL_SCOPE - var tabletShown = false; var tabletRezzed = false; var activeHand = null; var DEFAULT_WIDTH = 0.4375; @@ -93,7 +92,7 @@ } function showTabletUI() { - tabletShown = true; + Tablet.getTablet("com.highfidelity.interface.tablet.system").tabletShown = true; if (!tabletRezzed || !tabletIsValid()) { closeTabletUI() @@ -117,7 +116,7 @@ } function hideTabletUI() { - tabletShown = false; + Tablet.getTablet("com.highfidelity.interface.tablet.system").tabletShown = false; if (!UIWebTablet) { return; } @@ -141,7 +140,7 @@ } function closeTabletUI() { - tabletShown = false; + Tablet.getTablet("com.highfidelity.interface.tablet.system").tabletShown = false; if (UIWebTablet) { if (UIWebTablet.onClose) { UIWebTablet.onClose(); @@ -168,6 +167,7 @@ var now = Date.now(); // close the WebTablet if it we go into toolbar mode. + var tabletShown = Tablet.getTablet("com.highfidelity.interface.tablet.system").tabletShown; var toolbarMode = Tablet.getTablet("com.highfidelity.interface.tablet.system").toolbarMode; var visibleToOthers = Settings.getValue("tabletVisibleToOthers");