diff --git a/interface/resources/images/Swipe-Icon-single.svg b/interface/resources/images/Swipe-Icon-single.svg new file mode 100644 index 0000000000..277a6050db --- /dev/null +++ b/interface/resources/images/Swipe-Icon-single.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/interface/resources/images/address-bar.svg b/interface/resources/images/address-bar.svg index 56dc4f028c..a8cb158492 100644 --- a/interface/resources/images/address-bar.svg +++ b/interface/resources/images/address-bar.svg @@ -1,81 +1,18 @@ - - - - - - image/svg+xml - - - - - - - - - - - + + + + + + + diff --git a/interface/resources/images/backward.svg b/interface/resources/images/backward.svg new file mode 100644 index 0000000000..e4502fa80e --- /dev/null +++ b/interface/resources/images/backward.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/interface/resources/images/forward.svg b/interface/resources/images/forward.svg new file mode 100644 index 0000000000..0c5cbe3d0c --- /dev/null +++ b/interface/resources/images/forward.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/interface/resources/images/home.svg b/interface/resources/images/home.svg new file mode 100644 index 0000000000..7740dc9568 --- /dev/null +++ b/interface/resources/images/home.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/resources/images/places.svg b/interface/resources/images/places.svg new file mode 100644 index 0000000000..f70695d606 --- /dev/null +++ b/interface/resources/images/places.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/interface/resources/images/snap-feed.svg b/interface/resources/images/snap-feed.svg new file mode 100644 index 0000000000..c2dede6e0f --- /dev/null +++ b/interface/resources/images/snap-feed.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/resources/qml/AddressBarDialog.qml b/interface/resources/qml/AddressBarDialog.qml index 792410c59d..2536fdade9 100644 --- a/interface/resources/qml/AddressBarDialog.qml +++ b/interface/resources/qml/AddressBarDialog.qml @@ -14,6 +14,8 @@ import "controls" import "styles" import "windows" import "hifi" +import "hifi/toolbars" +import "controls-uit" as HifiControls Window { id: root @@ -45,50 +47,80 @@ Window { anchors.centerIn = parent; } + function resetAfterTeleport() { + storyCardFrame.shown = root.shown = false; + } function goCard(card) { - addressLine.text = card.userStory.name; + if (addressBarDialog.useFeed) { + storyCardHTML.url = addressBarDialog.metaverseServerUrl + "/user_stories/" + card.storyId; + storyCardFrame.shown = true; + return; + } + addressLine.text = card.hifiUrl; toggleOrGo(true); } - property var allDomains: []; - property var suggestionChoices: []; - property var domainsBaseUrl: null; + property var allPlaces: []; + property var allStories: []; property int cardWidth: 200; property int cardHeight: 152; + property string metaverseBase: addressBarDialog.metaverseServerUrl + "/api/v1/"; AddressBarDialog { id: addressBarDialog implicitWidth: backgroundImage.width implicitHeight: backgroundImage.height + // The buttons have their button state changed on hover, so we have to manually fix them up here + onBackEnabledChanged: backArrow.buttonState = addressBarDialog.backEnabled ? 1 : 0; + onForwardEnabledChanged: forwardArrow.buttonState = addressBarDialog.forwardEnabled ? 1 : 0; + onUseFeedChanged: updateFeedState(); + onReceivedHifiSchemeURL: resetAfterTeleport(); - Row { + ListModel { id: suggestions } + + ListView { + id: scroll width: backgroundImage.width; + height: cardHeight; + spacing: hifi.layout.spacing; + clip: true; anchors { bottom: backgroundImage.top; bottomMargin: 2 * hifi.layout.spacing; - right: backgroundImage.right; - rightMargin: -104; // FIXME + horizontalCenter: backgroundImage.horizontalCenter } - spacing: hifi.layout.spacing; - Card { - id: s0; + model: suggestions; + orientation: ListView.Horizontal; + delegate: Card { width: cardWidth; height: cardHeight; - goFunction: goCard + goFunction: goCard; + userName: model.username; + placeName: model.place_name; + hifiUrl: model.place_name + model.path; + imageUrl: model.image_url; + thumbnail: model.thumbnail_url; + action: model.action; + timestamp: model.created_at; + onlineUsers: model.online_users; + storyId: model.metaverseId; + hoverThunk: function () { ListView.view.currentIndex = index; } + unhoverThunk: function () { ListView.view.currentIndex = -1; } } - Card { - id: s1; - width: cardWidth; - height: cardHeight; - goFunction: goCard - } - Card { - id: s2; - width: cardWidth; - height: cardHeight; - goFunction: goCard + highlightMoveDuration: -1; + highlightMoveVelocity: -1; + highlight: Rectangle { color: "transparent"; border.width: 4; border.color: "#1DB5ED"; z: 1; } + leftMargin: 50; // Start the first item over by about the same amount as the last item peeks through on the other side. + rightMargin: 50; + } + Image { // Just a visual indicator that the user can swipe the cards over to see more. + source: "../images/Swipe-Icon-single.svg" + width: 50; + visible: suggestions.count > 3; + anchors { + right: scroll.right; + verticalCenter: scroll.verticalCenter; } } - Image { id: backgroundImage source: "../images/address-bar.svg" @@ -97,64 +129,43 @@ Window { property int inputAreaHeight: 56.0 * root.scale // Height of the background's input area property int inputAreaStep: (height - inputAreaHeight) / 2 - Image { + ToolbarButton { id: homeButton - source: "../images/home-button.svg" - width: 29 - height: 26 + imageURL: "../images/home.svg" + buttonState: 1 + defaultState: 1 + hoverState: 2 + onClicked: addressBarDialog.loadHome(); anchors { left: parent.left - leftMargin: parent.height + 2 * hifi.layout.spacing + leftMargin: homeButton.width / 2 verticalCenter: parent.verticalCenter } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - onClicked: { - addressBarDialog.loadHome() - } - } } - Image { - id: backArrow - source: addressBarDialog.backEnabled ? "../images/left-arrow.svg" : "../images/left-arrow-disabled.svg" - width: 22 - height: 26 + ToolbarButton { + id: backArrow; + imageURL: "../images/backward.svg"; + hoverState: addressBarDialog.backEnabled ? 2 : 0; + defaultState: addressBarDialog.backEnabled ? 1 : 0; + buttonState: addressBarDialog.backEnabled ? 1 : 0; + onClicked: addressBarDialog.loadBack(); anchors { left: homeButton.right - leftMargin: 2 * hifi.layout.spacing verticalCenter: parent.verticalCenter } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - onClicked: { - addressBarDialog.loadBack() - } - } } - - Image { - id: forwardArrow - source: addressBarDialog.forwardEnabled ? "../images/right-arrow.svg" : "../images/right-arrow-disabled.svg" - width: 22 - height: 26 + ToolbarButton { + id: forwardArrow; + imageURL: "../images/forward.svg"; + hoverState: addressBarDialog.forwardEnabled ? 2 : 0; + defaultState: addressBarDialog.forwardEnabled ? 1 : 0; + buttonState: addressBarDialog.forwardEnabled ? 1 : 0; + onClicked: addressBarDialog.loadForward(); anchors { left: backArrow.right - leftMargin: 2 * hifi.layout.spacing verticalCenter: parent.verticalCenter } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - onClicked: { - addressBarDialog.loadForward() - } - } } // FIXME replace with TextField @@ -162,20 +173,80 @@ Window { id: addressLine focus: true anchors { - fill: parent - leftMargin: parent.height + parent.height + hifi.layout.spacing * 7 - rightMargin: hifi.layout.spacing * 2 + top: parent.top + bottom: parent.bottom + left: forwardArrow.right + right: placesButton.left + leftMargin: forwardArrow.width + rightMargin: placesButton.width topMargin: parent.inputAreaStep + hifi.layout.spacing bottomMargin: parent.inputAreaStep + hifi.layout.spacing } font.pixelSize: hifi.fonts.pixelSize * root.scale * 0.75 helperText: "Go to: place, @user, /path, network address" + helperPixelSize: font.pixelSize * 0.75 + helperItalic: true onTextChanged: filterChoicesByText() } + // These two are radio buttons. + ToolbarButton { + id: placesButton + imageURL: "../images/places.svg" + buttonState: 1 + defaultState: addressBarDialog.useFeed ? 0 : 1; + hoverState: addressBarDialog.useFeed ? 2 : -1; + onClicked: addressBarDialog.useFeed ? toggleFeed() : identity() + anchors { + right: feedButton.left; + bottom: addressLine.bottom; + } + } + ToolbarButton { + id: feedButton; + imageURL: "../images/snap-feed.svg"; + buttonState: 0 + defaultState: addressBarDialog.useFeed ? 1 : 0; + hoverState: addressBarDialog.useFeed ? -1 : 2; + onClicked: addressBarDialog.useFeed ? identity() : toggleFeed(); + anchors { + right: parent.right; + bottom: addressLine.bottom; + rightMargin: feedButton.width / 2 + } + } + } + Window { + width: 938; + height: 625; + scale: 0.8 // Reset scale of Window to 1.0 (counteract address bar's scale value of 1.25) + HifiControls.WebView { + anchors.fill: parent; + id: storyCardHTML; + } + id: storyCardFrame; + + shown: false; + destroyOnCloseButton: false; + pinnable: false; + + anchors { + verticalCenter: backgroundImage.verticalCenter; + horizontalCenter: scroll.horizontalCenter; + } } } + + function toggleFeed() { + addressBarDialog.useFeed = !addressBarDialog.useFeed; + updateFeedState(); + } + function updateFeedState() { + placesButton.buttonState = addressBarDialog.useFeed ? 0 : 1; + feedButton.buttonState = addressBarDialog.useFeed ? 1 : 0; + filterChoicesByText(); + } function getRequest(url, cb) { // cb(error, responseOfCorrectContentType) of url. General for 'get' text/html/json, but without redirects. // TODO: make available to other .qml. var request = new XMLHttpRequest(); @@ -200,133 +271,217 @@ Window { request.open("GET", url, true); request.send(); } - // call iterator(element, icb) once for each element of array, and then cb(error) when icb(error) has been called by each iterator. - // short-circuits if error. Note that iterator MUST be an asynchronous function. (Use setTimeout if necessary.) - function asyncEach(array, iterator, cb) { - var count = array.length; - function icb(error) { - if (!--count || error) { - count = -1; // don't cb multiple times (e.g., if error) - cb(error); - } - } + function asyncMap(array, iterator, cb) { + // call iterator(element, icb) once for each element of array, and then cb(error, mappedResult) + // when icb(error, mappedElement) has been called by each iterator. + // Calls to iterator are overlapped and may call icb in any order, but the mappedResults are collected in the same + // order as the elements of the array. + // Short-circuits if error. Note that iterator MUST be an asynchronous function. (Use setTimeout if necessary.) + var count = array.length, results = []; if (!count) { - return cb(); + return cb(null, results); } - array.forEach(function (element) { - iterator(element, icb); + array.forEach(function (element, index) { + if (count < 0) { // don't keep iterating after we short-circuit + return; + } + iterator(element, function (error, mapped) { + results[index] = mapped; + if (error || !--count) { + count = 0; // don't cb multiple times if error + cb(error, results); + } + }); }); } + // Example: + /*asyncMap([0, 1, 2, 3, 4, 5, 6], function (elt, icb) { + console.log('called', elt); + setTimeout(function () { + console.log('answering', elt); + icb(null, elt); + }, Math.random() * 1000); + }, console.log); */ function identity(x) { return x; } - function addPictureToDomain(domainInfo, cb) { // asynchronously add thumbnail and lobby to domainInfo, if available, and cb(error) - // This requests data for all the names at once, and just uses the first one to come back. - // We might change this to check one at a time, which would be less requests and more latency. - asyncEach([domainInfo.name].concat(domainInfo.names || null).filter(identity), function (name, icb) { - var url = "https://metaverse.highfidelity.com/api/v1/places/" + name; - getRequest(url, function (error, json) { - var previews = !error && json.data.place.previews; - if (previews) { - if (!domainInfo.thumbnail) { // just grab the first one - domainInfo.thumbnail = previews.thumbnail; - } - if (!domainInfo.lobby) { - domainInfo.lobby = previews.lobby; - } - } - icb(error); - }); - }, cb); + function handleError(url, error, data, cb) { // cb(error) and answer truthy if needed, else falsey + if (!error && (data.status === 'success')) { + return; + } + if (!error) { // Create a message from the data + error = data.status + ': ' + data.error; + } + if (typeof(error) === 'string') { // Make a proper Error object + error = new Error(error); + } + error.message += ' in ' + url; // Include the url. + cb(error); + return true; } - function getDomains(options, cb) { // cb(error, arrayOfData) - if (!options.page) { - options.page = 1; - } - if (!domainsBaseUrl) { - var domainsOptions = [ - 'open', // published hours handle now - 'active', // has at least one person connected. FIXME: really want any place that is verified accessible. - // FIXME: really want places I'm allowed in, not just open ones. - 'restriction=open', // Not by whitelist, etc. FIXME: If logged in, add hifi to the restriction options, in order to include places that require login. - // FIXME add maturity - 'protocol=' + encodeURIComponent(AddressManager.protocolVersion()), - 'sort_by=users', - 'sort_order=desc', - ]; - domainsBaseUrl = "https://metaverse.highfidelity.com/api/v1/domains/all?" + domainsOptions.join('&'); - } - var url = domainsBaseUrl + "&page=" + options.page + "&users=" + options.minUsers + "-" + options.maxUsers; - getRequest(url, function (error, json) { - if (!error && (json.status !== 'success')) { - error = new Error("Bad response: " + JSON.stringify(json)); - } - if (error) { - error.message += ' for ' + url; - return cb(error); - } - var domains = json.data.domains; - if (json.current_page < json.total_pages) { - options.page++; - return getDomains(options, function (error, others) { - cb(error, domains.concat(others)); - }); - } - cb(null, domains); - }); - } - - function filterChoicesByText() { - function fill1(target, data) { - if (!data) { - target.visible = false; + function getPlace(placeData, cb) { // cb(error, side-effected-placeData), after adding path, thumbnails, and description + var url = metaverseBase + 'places/' + placeData.place_name; + getRequest(url, function (error, data) { + if (handleError(url, error, data, cb)) { return; } - console.log('suggestion:', JSON.stringify(data)); - target.userStory = data; - target.image.source = data.lobby || target.defaultPicture; - target.placeText = data.name; - target.usersText = data.online_users + ((data.online_users === 1) ? ' user' : ' users'); - target.visible = true; - } - var words = addressLine.text.toUpperCase().split(/\s+/).filter(identity); - var filtered = !words.length ? suggestionChoices : allDomains.filter(function (domain) { - var text = domain.names.concat(domain.tags).join(' '); - if (domain.description) { - text += domain.description; + var place = data.data.place, previews = place.previews; + placeData.path = place.path; + if (previews && previews.thumbnail) { + placeData.thumbnail_url = previews.thumbnail; } - text = text.toUpperCase(); - return words.every(function (word) { - return text.indexOf(word) >= 0; + if (place.description) { + placeData.description = place.description; + placeData.searchText += ' ' + place.description.toUpperCase(); + } + cb(error, placeData); + }); + } + function makeModelData(data, optionalPlaceName) { // create a new obj from data + // ListModel elements will only ever have those properties that are defined by the first obj that is added. + // So here we make sure that we have all the properties we need, regardless of whether it is a place data or user story. + var name = optionalPlaceName || data.place_name, + tags = data.tags || [data.action, data.username], + description = data.description || "", + thumbnail_url = data.thumbnail_url || "", + image_url = thumbnail_url; + if (data.details) { + try { + image_url = JSON.parse(data.details).image_url || thumbnail_url; + } catch (e) { + console.log(name, "has bad details", data.details); + } + } + return { + place_name: name, + username: data.username || "", + path: data.path || "", + created_at: data.created_at || "", + action: data.action || "", + thumbnail_url: thumbnail_url, + image_url: image_url, + + metaverseId: (data.id || "").toString(), // Some are strings from server while others are numbers. Model objects require uniformity. + + tags: tags, + description: description, + online_users: data.online_users || 0, + + searchText: [name].concat(tags, description || []).join(' ').toUpperCase() + } + } + function mapDomainPlaces(domain, cb) { // cb(error, arrayOfDomainPlaceData) + function addPlace(name, icb) { + getPlace(makeModelData(domain, name), icb); + } + // IWBNI we could get these results in order with most-recent-entered first. + // In any case, we don't really need to preserve the domain.names order in the results. + asyncMap(domain.names || [], addPlace, cb); + } + + function suggestable(place) { + if (addressBarDialog.useFeed) { + return true; + } + return (place.place_name !== AddressManager.hostname) // Not our entry, but do show other entry points to current domain. + && place.thumbnail_url + && place.online_users // at least one present means it's actually online + && place.online_users <= 20; + } + function getDomainPage(pageNumber, cb) { // cb(error) after all pages of domain data have been added to model + // Each page of results is processed completely before we start on the next page. + // For each page of domains, we process each domain in parallel, and for each domain, process each place name in parallel. + // This gives us minimum latency within the page, but we do preserve the order within the page by using asyncMap and + // only appending the collected results. + var params = [ + 'open', // published hours handle now + // TBD: should determine if place is actually running? + 'restriction=open', // Not by whitelist, etc. TBD: If logged in, add hifi to the restriction options, in order to include places that require login? + // TBD: add maturity? + 'protocol=' + encodeURIComponent(AddressManager.protocolVersion()), + 'sort_by=users', + 'sort_order=desc', + 'page=' + pageNumber + ]; + var url = metaverseBase + 'domains/all?' + params.join('&'); + getRequest(url, function (error, data) { + if (handleError(url, error, data, cb)) { + return; + } + asyncMap(data.data.domains, mapDomainPlaces, function (error, pageResults) { + if (error) { + return cb(error); + } + // pageResults is now [ [ placeDataOneForDomainOne, placeDataTwoForDomainOne, ...], [ placeDataTwoForDomainTwo...] ] + pageResults.forEach(function (domainResults) { + allPlaces = allPlaces.concat(domainResults); + if (!addressLine.text && !addressBarDialog.useFeed) { // Don't add if the user is already filtering + domainResults.forEach(function (place) { + if (suggestable(place)) { + suggestions.append(place); + } + }); + } + }); + if (data.current_page < data.total_pages) { + return getDomainPage(pageNumber + 1, cb); + } + cb(); }); }); - fill1(s0, filtered[0]); - fill1(s1, filtered[1]); - fill1(s2, filtered[2]); + } + function getUserStoryPage(pageNumber, cb) { // cb(error) after all pages of domain data have been added to model + var url = metaverseBase + 'user_stories?page=' + pageNumber; + getRequest(url, function (error, data) { + if (handleError(url, error, data, cb)) { + return; + } + var stories = data.user_stories.map(function (story) { // explicit single-argument function + return makeModelData(story); + }); + allStories = allStories.concat(stories); + if (!addressLine.text && addressBarDialog.useFeed) { // Don't add if the user is already filtering + stories.forEach(function (story) { + suggestions.append(story); + }); + } + if ((data.current_page < data.total_pages) && (data.current_page <= 10)) { // just 10 pages = 100 stories for now + return getUserStoryPage(pageNumber + 1, cb); + } + cb(); + }); + } + function filterChoicesByText() { + suggestions.clear(); + var words = addressLine.text.toUpperCase().split(/\s+/).filter(identity), + data = addressBarDialog.useFeed ? allStories : allPlaces; + function matches(place) { + if (!words.length) { + return suggestable(place); + } + return words.every(function (word) { + return place.searchText.indexOf(word) >= 0; + }); + } + data.forEach(function (place) { + if (matches(place)) { + suggestions.append(place); + } + }); } function fillDestinations() { - allDomains = suggestionChoices = []; - getDomains({minUsers: 0, maxUsers: 20}, function (error, domains) { - if (error) { - console.log('domain query failed:', error); - return filterChoicesByText(); - } - var here = AddressManager.hostname; // don't show where we are now. - allDomains = domains.filter(function (domain) { return domain.name !== here; }); - // Whittle down suggestions to those that have at least one user, and try to get pictures. - suggestionChoices = allDomains.filter(function (domain) { return domain.online_users; }); - asyncEach(domains, addPictureToDomain, function (error) { - if (error) { - console.log('place picture query failed:', error); - } - // Whittle down more by requiring a picture. - suggestionChoices = suggestionChoices.filter(function (domain) { return domain.lobby; }); - filterChoicesByText(); - }); + allPlaces = []; + allStories = []; + suggestions.clear(); + getDomainPage(1, function (error) { + console.log('domain query', error || 'ok', allPlaces.length); + }); + getUserStoryPage(1, function (error) { + console.log('user stories query', error || 'ok', allStories.length); }); } diff --git a/interface/resources/qml/controls-uit/Button.qml b/interface/resources/qml/controls-uit/Button.qml index 298943b551..59f8a63238 100644 --- a/interface/resources/qml/controls-uit/Button.qml +++ b/interface/resources/qml/controls-uit/Button.qml @@ -21,6 +21,8 @@ Original.Button { width: 120 height: hifi.dimensions.controlLineHeight + HifiConstants { id: hifi } + style: ButtonStyle { background: Rectangle { diff --git a/interface/resources/qml/controls/TextInput.qml b/interface/resources/qml/controls/TextInput.qml index b7ca6d2c1b..77e11177e1 100644 --- a/interface/resources/qml/controls/TextInput.qml +++ b/interface/resources/qml/controls/TextInput.qml @@ -12,6 +12,8 @@ Original.TextInput { verticalAlignment: Original.TextInput.AlignVCenter font.family: hifi.fonts.fontFamily font.pixelSize: hifi.fonts.pixelSize + property int helperPixelSize: font.pixelSize + property bool helperItalic: false /* Original.Rectangle { @@ -23,7 +25,8 @@ Original.TextInput { */ Text { anchors.fill: parent - font.pixelSize: parent.font.pixelSize + font.pixelSize: helperPixelSize + font.italic: helperItalic font.family: parent.font.family verticalAlignment: parent.verticalAlignment horizontalAlignment: parent.horizontalAlignment diff --git a/interface/resources/qml/hifi/Card.qml b/interface/resources/qml/hifi/Card.qml index 7758c5800a..53829eed9e 100644 --- a/interface/resources/qml/hifi/Card.qml +++ b/interface/resources/qml/hifi/Card.qml @@ -17,28 +17,69 @@ import QtGraphicalEffects 1.0 import "../styles-uit" Rectangle { + property string userName: ""; + property string placeName: ""; + property string action: ""; + property string timestamp: ""; + property string hifiUrl: ""; + property string thumbnail: defaultThumbnail; + property string imageUrl: ""; property var goFunction: null; - property var userStory: null; - property alias image: lobby; - property alias placeText: place.text; - property alias usersText: users.text; + property string storyId: ""; + + property string timePhrase: pastTime(timestamp); + property string actionPhrase: makeActionPhrase(action); + property int onlineUsers: 0; + property bool isUserStory: userName && !onlineUsers; + property int textPadding: 20; property int textSize: 24; - property string defaultPicture: "../../images/default-domain.gif"; + property int textSizeSmall: 18; + property string defaultThumbnail: Qt.resolvedUrl("../../images/default-domain.gif"); HifiConstants { id: hifi } + + function pastTime(timestamp) { // Answer a descriptive string + timestamp = new Date(timestamp); + var then = timestamp.getTime(), + now = Date.now(), + since = now - then, + ONE_MINUTE = 1000 * 60, + ONE_HOUR = ONE_MINUTE * 60, + hours = since / ONE_HOUR, + minutes = (hours % 1) * 60; + if (hours > 24) { + return timestamp.toDateString(); + } + if (hours > 1) { + return Math.floor(hours).toString() + ' hr ' + Math.floor(minutes) + ' min ago'; + } + if (minutes >= 2) { + return Math.floor(minutes).toString() + ' min ago'; + } + return 'about a minute ago'; + } + function makeActionPhrase(actionLabel) { + switch (actionLabel) { + case "snapshot": + return "took a snapshot"; + default: + return "unknown" + } + } + Image { id: lobby; width: parent.width; height: parent.height; - source: defaultPicture; + source: thumbnail || defaultThumbnail; fillMode: Image.PreserveAspectCrop; // source gets filled in later anchors.verticalCenter: parent.verticalCenter; anchors.left: parent.left; onStatusChanged: { if (status == Image.Error) { - console.log("source: " + source + ": failed to load " + JSON.stringify(userStory)); - source = defaultPicture; + console.log("source: " + source + ": failed to load " + hifiUrl); + source = defaultThumbnail; } } } @@ -69,6 +110,7 @@ Rectangle { } RalewaySemiBold { id: place; + text: isUserStory ? "" : placeName; color: hifi.colors.white; size: textSize; anchors { @@ -79,7 +121,8 @@ Rectangle { } RalewayRegular { id: users; - size: textSize; + text: isUserStory ? timePhrase : (onlineUsers + ((onlineUsers === 1) ? ' person' : ' people')); + size: textSizeSmall; color: hifi.colors.white; anchors { bottom: parent.bottom; @@ -87,10 +130,18 @@ Rectangle { margins: textPadding; } } + // These two can be supplied to provide hover behavior. + // For example, AddressBarDialog provides functions that set the current list view item + // to that which is being hovered over. + property var hoverThunk: function () { }; + property var unhoverThunk: function () { }; MouseArea { + id: zmouseArea; anchors.fill: parent; acceptedButtons: Qt.LeftButton; onClicked: goFunction(parent); hoverEnabled: true; + onEntered: hoverThunk(); + onExited: unhoverThunk(); } } diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 4ba223eee5..ef27f4b9b0 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -2296,7 +2296,7 @@ void Application::keyPressEvent(QKeyEvent* event) { } else if (isOption && !isShifted && !isMeta) { Menu::getInstance()->triggerOption(MenuOption::ScriptEditor); } else if (!isOption && !isShifted && isMeta) { - takeSnapshot(); + takeSnapshot(true); } break; @@ -4913,6 +4913,7 @@ bool Application::canAcceptURL(const QString& urlString) const { bool Application::acceptURL(const QString& urlString, bool defaultUpload) { if (urlString.startsWith(HIFI_URL_SCHEME)) { // this is a hifi URL - have the AddressManager handle it + emit receivedHifiSchemeURL(urlString); QMetaObject::invokeMethod(DependencyManager::get().data(), "handleLookupString", Qt::AutoConnection, Q_ARG(const QString&, urlString)); return true; @@ -5189,15 +5190,24 @@ void Application::toggleLogDialog() { } } -void Application::takeSnapshot() { - QMediaPlayer* player = new QMediaPlayer(); - QFileInfo inf = QFileInfo(PathUtils::resourcesPath() + "sounds/snap.wav"); - player->setMedia(QUrl::fromLocalFile(inf.absoluteFilePath())); - player->play(); +void Application::takeSnapshot(bool notify, float aspectRatio) { + postLambdaEvent([notify, aspectRatio, this] { + QMediaPlayer* player = new QMediaPlayer(); + QFileInfo inf = QFileInfo(PathUtils::resourcesPath() + "sounds/snap.wav"); + player->setMedia(QUrl::fromLocalFile(inf.absoluteFilePath())); + player->play(); - QString path = Snapshot::saveSnapshot(getActiveDisplayPlugin()->getScreenshot()); + QString path = Snapshot::saveSnapshot(getActiveDisplayPlugin()->getScreenshot(aspectRatio)); - emit DependencyManager::get()->snapshotTaken(path); + emit DependencyManager::get()->snapshotTaken(path, notify); + }); +} + +void Application::shareSnapshot(const QString& path) { + postLambdaEvent([path] { + // not much to do here, everything is done in snapshot code... + Snapshot::uploadSnapshot(path); + }); } float Application::getRenderResolutionScale() const { diff --git a/interface/src/Application.h b/interface/src/Application.h index a6090328a4..a0c67a9e73 100644 --- a/interface/src/Application.h +++ b/interface/src/Application.h @@ -250,6 +250,9 @@ public: float getAvatarSimrate() const { return _avatarSimCounter.rate(); } float getAverageSimsPerSecond() const { return _simCounter.rate(); } + + void takeSnapshot(bool notify, float aspectRatio = 0.0f); + void shareSnapshot(const QString& filename); model::SkyboxPointer getDefaultSkybox() const { return _defaultSkybox; } gpu::TexturePointer getDefaultSkyboxTexture() const { return _defaultSkyboxTexture; } @@ -276,6 +279,7 @@ signals: void activeDisplayPluginChanged(); void uploadRequest(QString path); + void receivedHifiSchemeURL(const QString& url); public slots: QVector pasteEntities(float x, float y, float z); @@ -396,8 +400,6 @@ private: int sendNackPackets(); - void takeSnapshot(); - MyAvatar* getMyAvatar() const; void checkSkeleton() const; diff --git a/interface/src/scripting/AccountScriptingInterface.cpp b/interface/src/scripting/AccountScriptingInterface.cpp index 4090c99ac8..d8533bb769 100644 --- a/interface/src/scripting/AccountScriptingInterface.cpp +++ b/interface/src/scripting/AccountScriptingInterface.cpp @@ -26,6 +26,11 @@ bool AccountScriptingInterface::isLoggedIn() { return accountManager->isLoggedIn(); } +bool AccountScriptingInterface::checkAndSignalForAccessToken() { + auto accountManager = DependencyManager::get(); + return accountManager->checkAndSignalForAccessToken(); +} + QString AccountScriptingInterface::getUsername() { auto accountManager = DependencyManager::get(); if (accountManager->isLoggedIn()) { diff --git a/interface/src/scripting/AccountScriptingInterface.h b/interface/src/scripting/AccountScriptingInterface.h index 49648781ce..0f958f2286 100644 --- a/interface/src/scripting/AccountScriptingInterface.h +++ b/interface/src/scripting/AccountScriptingInterface.h @@ -26,6 +26,7 @@ public slots: static AccountScriptingInterface* getInstance(); QString getUsername(); bool isLoggedIn(); + bool checkAndSignalForAccessToken(); }; #endif // hifi_AccountScriptingInterface_h diff --git a/interface/src/scripting/DesktopScriptingInterface.cpp b/interface/src/scripting/DesktopScriptingInterface.cpp index f7bc8afe36..efab178798 100644 --- a/interface/src/scripting/DesktopScriptingInterface.cpp +++ b/interface/src/scripting/DesktopScriptingInterface.cpp @@ -17,6 +17,8 @@ #include "Application.h" #include "MainWindow.h" #include +#include +#include int DesktopScriptingInterface::getWidth() { QSize size = qApp->getWindow()->windowHandle()->screen()->virtualSize(); @@ -31,3 +33,11 @@ void DesktopScriptingInterface::setOverlayAlpha(float alpha) { qApp->getApplicationCompositor().setAlpha(alpha); } +void DesktopScriptingInterface::show(const QString& path, const QString& title) { + if (QThread::currentThread() != thread()) { + QMetaObject::invokeMethod(this, "show", Qt::QueuedConnection, Q_ARG(QString, path), Q_ARG(QString, title)); + return; + } + DependencyManager::get()->show(path, title); +} + diff --git a/interface/src/scripting/DesktopScriptingInterface.h b/interface/src/scripting/DesktopScriptingInterface.h index 8da502cb11..2825065e90 100644 --- a/interface/src/scripting/DesktopScriptingInterface.h +++ b/interface/src/scripting/DesktopScriptingInterface.h @@ -23,6 +23,7 @@ class DesktopScriptingInterface : public QObject, public Dependency { public: Q_INVOKABLE void setOverlayAlpha(float alpha); + Q_INVOKABLE void show(const QString& path, const QString& title); int getWidth(); int getHeight(); diff --git a/interface/src/scripting/DialogsManagerScriptingInterface.cpp b/interface/src/scripting/DialogsManagerScriptingInterface.cpp index cbca7ff4ff..1604c45593 100644 --- a/interface/src/scripting/DialogsManagerScriptingInterface.cpp +++ b/interface/src/scripting/DialogsManagerScriptingInterface.cpp @@ -26,3 +26,8 @@ void DialogsManagerScriptingInterface::toggleAddressBar() { QMetaObject::invokeMethod(DependencyManager::get().data(), "toggleAddressBar", Qt::QueuedConnection); } + +void DialogsManagerScriptingInterface::showFeed() { + QMetaObject::invokeMethod(DependencyManager::get().data(), + "showFeed", Qt::QueuedConnection); +} diff --git a/interface/src/scripting/DialogsManagerScriptingInterface.h b/interface/src/scripting/DialogsManagerScriptingInterface.h index 075b89f0e5..86cd22af1c 100644 --- a/interface/src/scripting/DialogsManagerScriptingInterface.h +++ b/interface/src/scripting/DialogsManagerScriptingInterface.h @@ -18,6 +18,7 @@ class DialogsManagerScriptingInterface : public QObject { Q_OBJECT public: DialogsManagerScriptingInterface(); + Q_INVOKABLE void showFeed(); public slots: void toggleAddressBar(); diff --git a/interface/src/scripting/WindowScriptingInterface.cpp b/interface/src/scripting/WindowScriptingInterface.cpp index 9d7eee0f8c..4eb8c67250 100644 --- a/interface/src/scripting/WindowScriptingInterface.cpp +++ b/interface/src/scripting/WindowScriptingInterface.cpp @@ -96,7 +96,7 @@ void WindowScriptingInterface::alert(const QString& message) { /// \param const QString& message message to display /// \return QScriptValue `true` if 'Yes' was clicked, `false` otherwise QScriptValue WindowScriptingInterface::confirm(const QString& message) { - return QScriptValue((QMessageBox::Yes == OffscreenUi::question("", message))); + return QScriptValue((QMessageBox::Yes == OffscreenUi::question("", message, QMessageBox::Yes | QMessageBox::No))); } /// Display a prompt with a text box @@ -203,3 +203,11 @@ void WindowScriptingInterface::copyToClipboard(const QString& text) { qDebug() << "Copying"; QApplication::clipboard()->setText(text); } + +void WindowScriptingInterface::takeSnapshot(bool notify, float aspectRatio) { + qApp->takeSnapshot(notify, aspectRatio); +} + +void WindowScriptingInterface::shareSnapshot(const QString& path) { + qApp->shareSnapshot(path); +} diff --git a/interface/src/scripting/WindowScriptingInterface.h b/interface/src/scripting/WindowScriptingInterface.h index 9f1d2bddf5..7a01be7fac 100644 --- a/interface/src/scripting/WindowScriptingInterface.h +++ b/interface/src/scripting/WindowScriptingInterface.h @@ -55,12 +55,15 @@ public slots: QScriptValue save(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); void showAssetServer(const QString& upload = ""); void copyToClipboard(const QString& text); + void takeSnapshot(bool notify = true, float aspectRatio = 0.0f); + void shareSnapshot(const QString& path); signals: void domainChanged(const QString& domainHostname); void svoImportRequested(const QString& url); void domainConnectionRefused(const QString& reasonMessage, int reasonCode); - void snapshotTaken(const QString& path); + void snapshotTaken(const QString& path, bool notify); + void snapshotShared(const QString& error); private slots: WebWindowClass* doCreateWebWindow(const QString& title, const QString& url, int width, int height); diff --git a/interface/src/ui/AddressBarDialog.cpp b/interface/src/ui/AddressBarDialog.cpp index a4ef8a913f..3e84c4c3c5 100644 --- a/interface/src/ui/AddressBarDialog.cpp +++ b/interface/src/ui/AddressBarDialog.cpp @@ -38,6 +38,8 @@ AddressBarDialog::AddressBarDialog(QQuickItem* parent) : OffscreenQmlDialog(pare }); _backEnabled = !(DependencyManager::get()->getBackStack().isEmpty()); _forwardEnabled = !(DependencyManager::get()->getForwardStack().isEmpty()); + connect(DependencyManager::get().data(), &DialogsManager::setUseFeed, this, &AddressBarDialog::setUseFeed); + connect(qApp, &Application::receivedHifiSchemeURL, this, &AddressBarDialog::receivedHifiSchemeURL); } void AddressBarDialog::loadAddress(const QString& address, bool fromSuggestions) { diff --git a/interface/src/ui/AddressBarDialog.h b/interface/src/ui/AddressBarDialog.h index 6c7620164b..3197770433 100644 --- a/interface/src/ui/AddressBarDialog.h +++ b/interface/src/ui/AddressBarDialog.h @@ -14,21 +14,29 @@ #define hifi_AddressBarDialog_h #include +#include class AddressBarDialog : public OffscreenQmlDialog { Q_OBJECT HIFI_QML_DECL Q_PROPERTY(bool backEnabled READ backEnabled NOTIFY backEnabledChanged) Q_PROPERTY(bool forwardEnabled READ forwardEnabled NOTIFY forwardEnabledChanged) + Q_PROPERTY(bool useFeed READ useFeed WRITE setUseFeed NOTIFY useFeedChanged) + Q_PROPERTY(QString metaverseServerUrl READ metaverseServerUrl) public: AddressBarDialog(QQuickItem* parent = nullptr); bool backEnabled() { return _backEnabled; } bool forwardEnabled() { return _forwardEnabled; } + bool useFeed() { return _useFeed; } + void setUseFeed(bool useFeed) { if (_useFeed != useFeed) { _useFeed = useFeed; emit useFeedChanged(); } } + QString metaverseServerUrl() { return NetworkingConstants::METAVERSE_SERVER_URL.toString(); } signals: void backEnabledChanged(); void forwardEnabledChanged(); + void useFeedChanged(); + void receivedHifiSchemeURL(const QString& url); protected: void displayAddressOfflineMessage(); @@ -42,6 +50,7 @@ protected: bool _backEnabled; bool _forwardEnabled; + bool _useFeed { false }; }; #endif diff --git a/interface/src/ui/DialogsManager.cpp b/interface/src/ui/DialogsManager.cpp index dc06c50626..1b868f4154 100644 --- a/interface/src/ui/DialogsManager.cpp +++ b/interface/src/ui/DialogsManager.cpp @@ -54,6 +54,11 @@ void DialogsManager::showAddressBar() { AddressBarDialog::show(); } +void DialogsManager::showFeed() { + AddressBarDialog::show(); + emit setUseFeed(true); +} + void DialogsManager::toggleDiskCacheEditor() { maybeCreateDialog(_diskCacheEditor); _diskCacheEditor->toggle(); diff --git a/interface/src/ui/DialogsManager.h b/interface/src/ui/DialogsManager.h index 5b4995029f..5e25afd130 100644 --- a/interface/src/ui/DialogsManager.h +++ b/interface/src/ui/DialogsManager.h @@ -45,6 +45,7 @@ public: public slots: void toggleAddressBar(); void showAddressBar(); + void showFeed(); void toggleDiskCacheEditor(); void toggleLoginDialog(); void showLoginDialog(); @@ -63,6 +64,7 @@ public slots: signals: void addressBarToggled(); void addressBarShown(bool visible); + void setUseFeed(bool useFeed); private slots: void hmdToolsClosed(); diff --git a/interface/src/ui/Snapshot.cpp b/interface/src/ui/Snapshot.cpp index aaf11d14a4..c2fcafb2f3 100644 --- a/interface/src/ui/Snapshot.cpp +++ b/interface/src/ui/Snapshot.cpp @@ -32,6 +32,7 @@ #include "Application.h" #include "Snapshot.h" +#include "SnapshotUploader.h" // filename format: hifi-snap-by-%username%-on-%date%_%time%_@-%location%.jpg // %1 <= username, %2 <= date and time, %3 <= current location @@ -141,3 +142,34 @@ QFile* Snapshot::savedFileForSnapshot(QImage & shot, bool isTemporary) { return imageTempFile; } } + +void Snapshot::uploadSnapshot(const QString& filename) { + + const QString SNAPSHOT_UPLOAD_URL = "/api/v1/snapshots"; + static SnapshotUploader uploader; + + QFile* file = new QFile(filename); + Q_ASSERT(file->exists()); + file->open(QIODevice::ReadOnly); + + QHttpPart imagePart; + imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/jpeg")); + imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, + QVariant("form-data; name=\"image\"; filename=\"" + file->fileName() + "\"")); + imagePart.setBodyDevice(file); + + QHttpMultiPart* multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + file->setParent(multiPart); // we cannot delete the file now, so delete it with the multiPart + multiPart->append(imagePart); + + auto accountManager = DependencyManager::get(); + JSONCallbackParameters callbackParams(&uploader, "uploadSuccess", &uploader, "uploadFailure"); + + accountManager->sendRequest(SNAPSHOT_UPLOAD_URL, + AccountManagerAuth::Required, + QNetworkAccessManager::PostOperation, + callbackParams, + nullptr, + multiPart); +} + diff --git a/interface/src/ui/Snapshot.h b/interface/src/ui/Snapshot.h index 2e7986a5c0..90138d450d 100644 --- a/interface/src/ui/Snapshot.h +++ b/interface/src/ui/Snapshot.h @@ -40,6 +40,7 @@ public: static Setting::Handle snapshotsLocation; static Setting::Handle hasSetSnapshotsLocation; + static void uploadSnapshot(const QString& filename); private: static QFile* savedFileForSnapshot(QImage & image, bool isTemporary); }; diff --git a/interface/src/ui/SnapshotUploader.cpp b/interface/src/ui/SnapshotUploader.cpp new file mode 100644 index 0000000000..5bc9bb386c --- /dev/null +++ b/interface/src/ui/SnapshotUploader.cpp @@ -0,0 +1,75 @@ +// +// SnapshotUploader.cpp +// interface/src/ui +// +// Created by Howard Stearns on 8/22/2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include +#include +#include +#include "scripting/WindowScriptingInterface.h" +#include "SnapshotUploader.h" + +void SnapshotUploader::uploadSuccess(QNetworkReply& reply) { + const QString STORY_UPLOAD_URL = "/api/v1/user_stories"; + static SnapshotUploader uploader; + + // parse the reply for the thumbnail_url + QByteArray contents = reply.readAll(); + QJsonParseError jsonError; + auto doc = QJsonDocument::fromJson(contents, &jsonError); + if (jsonError.error == QJsonParseError::NoError) { + auto dataObject = doc.object().value("data").toObject(); + QString thumbnailUrl = dataObject.value("thumbnail_url").toString(); + QString imageUrl = dataObject.value("image_url").toString(); + auto addressManager = DependencyManager::get(); + QString placeName = addressManager->getPlaceName(); + if (placeName.isEmpty()) { + placeName = addressManager->getHost(); + } + QString currentPath = addressManager->currentPath(true); + + // create json post data + QJsonObject rootObject; + QJsonObject userStoryObject; + QJsonObject detailsObject; + detailsObject.insert("image_url", imageUrl); + QString pickledDetails = QJsonDocument(detailsObject).toJson(); + userStoryObject.insert("details", pickledDetails); + userStoryObject.insert("thumbnail_url", thumbnailUrl); + userStoryObject.insert("place_name", placeName); + userStoryObject.insert("path", currentPath); + userStoryObject.insert("action", "snapshot"); + rootObject.insert("user_story", userStoryObject); + + auto accountManager = DependencyManager::get(); + JSONCallbackParameters callbackParams(&uploader, "createStorySuccess", &uploader, "createStoryFailure"); + + accountManager->sendRequest(STORY_UPLOAD_URL, + AccountManagerAuth::Required, + QNetworkAccessManager::PostOperation, + callbackParams, + QJsonDocument(rootObject).toJson()); + + } + else { + emit DependencyManager::get()->snapshotShared(contents); + } +} + +void SnapshotUploader::uploadFailure(QNetworkReply& reply) { + emit DependencyManager::get()->snapshotShared(reply.readAll()); +} + +void SnapshotUploader::createStorySuccess(QNetworkReply& reply) { + emit DependencyManager::get()->snapshotShared(QString()); +} + +void SnapshotUploader::createStoryFailure(QNetworkReply& reply) { + emit DependencyManager::get()->snapshotShared(reply.readAll()); +} \ No newline at end of file diff --git a/interface/src/ui/SnapshotUploader.h b/interface/src/ui/SnapshotUploader.h new file mode 100644 index 0000000000..d4a5f86431 --- /dev/null +++ b/interface/src/ui/SnapshotUploader.h @@ -0,0 +1,26 @@ +// +// SnapshotUploader.h +// interface/src/ui +// +// Created by Howard Stearns on 8/22/2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#ifndef hifi_SnapshotUploader_h +#define hifi_SnapshotUploader_h + +#include +#include + +class SnapshotUploader : public QObject { + Q_OBJECT + public slots: + void uploadSuccess(QNetworkReply& reply); + void uploadFailure(QNetworkReply& reply); + void createStorySuccess(QNetworkReply& reply); + void createStoryFailure(QNetworkReply& reply); +}; +#endif // hifi_SnapshotUploader_h \ No newline at end of file diff --git a/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.cpp b/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.cpp index 5ee05fa2e3..a4777b087b 100644 --- a/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.cpp +++ b/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.cpp @@ -31,6 +31,6 @@ void NullDisplayPlugin::submitFrame(const gpu::FramePointer& frame) { } } -QImage NullDisplayPlugin::getScreenshot() const { +QImage NullDisplayPlugin::getScreenshot(float aspectRatio) const { return QImage(); } diff --git a/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.h b/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.h index 198c89ae78..1852ed53ee 100644 --- a/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.h +++ b/libraries/display-plugins/src/display-plugins/NullDisplayPlugin.h @@ -18,7 +18,7 @@ public: glm::uvec2 getRecommendedRenderSize() const override; bool hasFocus() const override; void submitFrame(const gpu::FramePointer& newFrame) override; - QImage getScreenshot() const override; + QImage getScreenshot(float aspectRatio = 0.0f) const override; private: static const QString NAME; }; diff --git a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp index 7a57e1d0f2..b304b3802e 100644 --- a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp +++ b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp @@ -659,12 +659,26 @@ void OpenGLDisplayPlugin::withMainThreadContext(std::function f) const { _container->makeRenderingContextCurrent(); } -QImage OpenGLDisplayPlugin::getScreenshot() const { +QImage OpenGLDisplayPlugin::getScreenshot(float aspectRatio) const { auto size = _compositeFramebuffer->getSize(); + if (isHmd()) { + size.x /= 2; + } + auto bestSize = size; + uvec2 corner(0); + if (aspectRatio != 0.0f) { // Pick out the largest piece of the center that produces the requested width/height aspectRatio + if (ceil(size.y * aspectRatio) < size.x) { + bestSize.x = round(size.y * aspectRatio); + } else { + bestSize.y = round(size.x / aspectRatio); + } + corner.x = round((size.x - bestSize.x) / 2.0f); + corner.y = round((size.y - bestSize.y) / 2.0f); + } auto glBackend = const_cast(*this).getGLBackend(); - QImage screenshot(size.x, size.y, QImage::Format_ARGB32); + QImage screenshot(bestSize.x, bestSize.y, QImage::Format_ARGB32); withMainThreadContext([&] { - glBackend->downloadFramebuffer(_compositeFramebuffer, ivec4(uvec2(0), size), screenshot); + glBackend->downloadFramebuffer(_compositeFramebuffer, ivec4(corner, bestSize), screenshot); }); return screenshot.mirrored(false, true); } diff --git a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h index 48f9a78eda..51b33c9bcd 100644 --- a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h +++ b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h @@ -54,7 +54,7 @@ public: return getSurfaceSize(); } - QImage getScreenshot() const override; + QImage getScreenshot(float aspectRatio = 0.0f) const override; float presentRate() const override; diff --git a/libraries/networking/src/AddressManager.h b/libraries/networking/src/AddressManager.h index 8ccddc5975..23df176d8b 100644 --- a/libraries/networking/src/AddressManager.h +++ b/libraries/networking/src/AddressManager.h @@ -38,6 +38,7 @@ class AddressManager : public QObject, public Dependency { Q_PROPERTY(QString protocol READ getProtocol) Q_PROPERTY(QString hostname READ getHost) Q_PROPERTY(QString pathname READ currentPath) + Q_PROPERTY(QString placename READ getPlaceName) public: Q_INVOKABLE QString protocolVersion(); using PositionGetter = std::function; diff --git a/libraries/plugins/src/plugins/DisplayPlugin.h b/libraries/plugins/src/plugins/DisplayPlugin.h index 49c341cdcb..288cee3223 100644 --- a/libraries/plugins/src/plugins/DisplayPlugin.h +++ b/libraries/plugins/src/plugins/DisplayPlugin.h @@ -172,7 +172,7 @@ public: } // Fetch the most recently displayed image as a QImage - virtual QImage getScreenshot() const = 0; + virtual QImage getScreenshot(float aspectRatio = 0.0f) const = 0; // will query the underlying hmd api to compute the most recent head pose virtual bool beginFrameRender(uint32_t frameIndex) { return true; } diff --git a/plugins/openvr/src/OpenVrHelpers.cpp b/plugins/openvr/src/OpenVrHelpers.cpp index 20a43d98d4..820476191a 100644 --- a/plugins/openvr/src/OpenVrHelpers.cpp +++ b/plugins/openvr/src/OpenVrHelpers.cpp @@ -176,8 +176,7 @@ void showOpenVrKeyboard(bool show = true) { } } -void finishOpenVrKeyboardInput() { - auto offscreenUi = DependencyManager::get(); +void updateFromOpenVrKeyboardInput() { auto chars = _overlay->GetKeyboardText(textArray, 8192); auto newText = QString(QByteArray(textArray, chars)); _keyboardFocusObject->setProperty("text", newText); @@ -187,6 +186,11 @@ void finishOpenVrKeyboardInput() { //QInputMethodEvent event(_existingText, QList()); //event.setCommitString(newText, 0, _existingText.size()); //qApp->sendEvent(_keyboardFocusObject, &event); +} + +void finishOpenVrKeyboardInput() { + auto offscreenUi = DependencyManager::get(); + updateFromOpenVrKeyboardInput(); // Simulate an enter press on the top level window to trigger the action if (0 == (_currentHints & Qt::ImhMultiLine)) { qApp->sendEvent(offscreenUi->getWindow(), &QKeyEvent(QEvent::KeyPress, Qt::Key_Return, Qt::KeyboardModifiers(), QString("\n"))); @@ -267,6 +271,11 @@ void handleOpenVrEvents() { activeHmd->AcknowledgeQuit_Exiting(); break; + case vr::VREvent_KeyboardCharInput: + // Make the focused field match the keyboard results, inclusive of combining characters and such. + updateFromOpenVrKeyboardInput(); + break; + case vr::VREvent_KeyboardDone: finishOpenVrKeyboardInput(); diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js index dc252afcf1..153d39f1f4 100644 --- a/scripts/defaultScripts.js +++ b/scripts/defaultScripts.js @@ -27,4 +27,6 @@ Script.load("system/controllers/grab.js"); Script.load("system/controllers/teleport.js"); Script.load("system/controllers/toggleAdvancedMovementForHandControllers.js") Script.load("system/dialTone.js"); -Script.load("system/firstPersonHMD.js"); \ No newline at end of file +Script.load("system/firstPersonHMD.js"); +Script.load("system/snapshot.js"); + diff --git a/scripts/system/assets/images/tools/snap.svg b/scripts/system/assets/images/tools/snap.svg new file mode 100755 index 0000000000..c540f307ae --- /dev/null +++ b/scripts/system/assets/images/tools/snap.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/system/html/SnapshotReview.html b/scripts/system/html/SnapshotReview.html new file mode 100644 index 0000000000..db70a1910b --- /dev/null +++ b/scripts/system/html/SnapshotReview.html @@ -0,0 +1,48 @@ + + + Share + + + + + + + + +
+
+
+ +
+
+
+
+
Would you like to share your pic in the Snapshots feed?
+
+ + + + +
+
+
+ +
+
+
+
+ + + + + + + + +
+
+
+
+
+ + diff --git a/scripts/system/html/css/SnapshotReview.css b/scripts/system/html/css/SnapshotReview.css new file mode 100644 index 0000000000..c2965f92e1 --- /dev/null +++ b/scripts/system/html/css/SnapshotReview.css @@ -0,0 +1,131 @@ +/* +// SnapshotReview.css +// +// Created by Howard Stearns for David Rowe 8/22/2016. +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +*/ + + +.snapshot-container { + width: 100%; + padding-top: 3px; +} + +.snapshot-column-left { + width: 320px; + position: absolute; + padding-top: 8px; +} + +.snapshot-column-right { + margin-left: 342px; +} + +.snapshot-column-right > div > img { + width: 100%; +} + +@media (max-width: 768px) { + .snapshot-column-left { + position: initial; + width: 100%; + } + .snapshot-column-right { + margin-left: 0; + width: 100%; + } + .snapshot-column-right > div > img { + margin-top: 18px !important; + } +} + +.snapshot-column-right > div { + position: relative; + padding: 2px; +} + +.snapshot-column-right > div > img { + border: 2px solid #575757; + margin: -2px; +} + +hr { + padding-left: 0; + padding-right: 0; + margin: 21px 0; +} + +.snapsection { + text-align: center; +} + +.title { + text-transform: uppercase; + font-size: 12px; +} + +.prompt { + font-family: Raleway-SemiBold; + font-size: 14px; +} + +div.button { + padding-top: 21px; +} + +.compound-button { + position: relative; + height: auto; +} + +.compound-button input { + padding-left: 40px; +} + +.compound-button .glyph { + display: inline-block; + position: absolute; + left: 12px; + top: 16px; + width: 23px; + height: 23px; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgaGVpZ2h0PSI0MCIKICAgd2lkdGg9IjQwIgogICBpZD0ic3ZnMiIKICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgdmlld0JveD0iMCAwIDQwIDQwIgogICB5PSIwcHgiCiAgIHg9IjBweCIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGEzNCI+PHJkZjpSREY+PGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PjxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz48ZGM6dGl0bGU+PC9kYzp0aXRsZT48L2NjOldvcms+PC9yZGY6UkRGPjwvbWV0YWRhdGE+PGRlZnMKICAgICBpZD0iZGVmczMyIiAvPjxzdHlsZQogICAgIGlkPSJzdHlsZTQiCiAgICAgdHlwZT0idGV4dC9jc3MiPgoJLnN0MHtmaWxsOiM0MTQwNDI7fQoJLnN0MXtmaWxsOiNDQ0NDQ0M7fQoJLnN0MntmaWxsOiMxMzk4QkI7fQoJLnN0M3tmaWxsOiMzMUQ4RkY7fQo8L3N0eWxlPjxnCiAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwtMTEwKSIKICAgICBpZD0iTGF5ZXJfMSI+PGNpcmNsZQogICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MSIKICAgICAgIGlkPSJjaXJjbGUxMyIKICAgICAgIHI9IjQuNDQwMDAwMSIKICAgICAgIGN5PSIxMjYuMTciCiAgICAgICBjeD0iMjAuNTQwMDAxIgogICAgICAgY2xhc3M9InN0MSIgLz48cGF0aAogICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MSIKICAgICAgIGlkPSJwYXRoMTUiCiAgICAgICBkPSJtIDI4Ljg3LDEzOS4yNiBjIDAuMDEsLTAuMDEgMC4wMiwtMC4wMiAwLjAzLC0wLjAzIGwgMCwtMS44NiBjIDAsLTIuNjggLTIuMzMsLTQuNzcgLTUsLTQuNzcgbCAtNi40MiwwIGMgLTIuNjgsMCAtNC44NSwyLjA5IC00Ljg1LDQuNzcgbCAwLDEuODggMTYuMjQsMCB6IgogICAgICAgY2xhc3M9InN0MSIgLz48cGF0aAogICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MSIKICAgICAgIGlkPSJwYXRoMTciCiAgICAgICBkPSJtIDM4LjE3LDEyMy40MiBjIDAsLTMuOTcgLTMuMjIsLTcuMTkgLTcuMTksLTcuMTkgbCAtMjAuMzEsMCBjIC0zLjk3LDAgLTcuMTksMy4yMiAtNy4xOSw3LjE5IGwgMCwxNC4xOCBjIDAsMy45NyAzLjIyLDcuMTkgNy4xOSw3LjE5IGwgMjAuMzEsMCBjIDMuOTcsMCA3LjE5LC0zLjIyIDcuMTksLTcuMTkgbCAwLC0xNC4xOCB6IG0gLTEuNzgsMTQuMjcgYyAwLDMuMDMgLTIuNDYsNS40OSAtNS40OSw1LjQ5IGwgLTIwLjMyLDAgYyAtMy4wMywwIC01LjQ5LC0yLjQ2IC01LjQ5LC01LjQ5IGwgMCwtMTQuMTkgYyAwLC0zLjAzIDIuNDYsLTUuNDkgNS40OSwtNS40OSBsIDIwLjMzLDAgYyAzLjAzLDAgNS40OSwyLjQ2IDUuNDksNS40OSBsIDAsMTQuMTkgeiIKICAgICAgIGNsYXNzPSJzdDEiIC8+PC9nPjxnCiAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwtMTEwKSIKICAgICBpZD0iTGF5ZXJfMiIgLz48L3N2Zz4=); + background-repeat: no-repeat; + background-size: 23px 23px; +} + +.setting { + display: inline-table; + height: 28px; +} + +.setting label { + display: table-cell; + vertical-align: middle; + font-family: Raleway-SemiBold; + font-size: 14px; +} + +.setting + .setting { + margin-left: 18px; +} + +input[type=button].naked { + font-size: 40px; + line-height: 40px; + width: 30px; + padding: 0; + margin: 0 0 -6px 0; + position: relative; + top: -6px; + left: -8px; + background: none; +} + +input[type=button].naked:hover { + color: #00b4ef; + background: none; +} diff --git a/scripts/system/html/js/SnapshotReview.js b/scripts/system/html/js/SnapshotReview.js new file mode 100644 index 0000000000..a6515df825 --- /dev/null +++ b/scripts/system/html/js/SnapshotReview.js @@ -0,0 +1,77 @@ +"use strict"; +// +// SnapshotReview.js +// scripts/system/html/js/ +// +// Created by Howard Stearns 8/22/2016 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +var paths = [], idCounter = 0, useCheckboxes; +function addImage(data) { + var div = document.createElement("DIV"), + input = document.createElement("INPUT"), + label = document.createElement("LABEL"), + img = document.createElement("IMG"), + id = "p" + idCounter++; + function toggle() { data.share = input.checked; } + img.src = data.localPath; + div.appendChild(img); + data.share = true; + if (useCheckboxes) { // I'd rather use css, but the included stylesheet is quite particular. + // Our stylesheet(?) requires input.id to match label.for. Otherwise input doesn't display the check state. + label.setAttribute('for', id); // cannot do label.for = + input.id = id; + input.type = "checkbox"; + input.checked = true; + input.addEventListener('change', toggle); + div.class = "property checkbox"; + div.appendChild(input); + div.appendChild(label); + } + document.getElementById("snapshot-images").appendChild(div); + paths.push(data); + +} +function handleShareButtons(shareMsg) { + var openFeed = document.getElementById('openFeed'); + openFeed.checked = shareMsg.openFeedAfterShare; + openFeed.onchange = function () { EventBridge.emitWebEvent(openFeed.checked ? 'setOpenFeedTrue' : 'setOpenFeedFalse'); }; + if (!shareMsg.canShare) { + // this means you may or may not be logged in, but can't share + // because you are not in a public place. + document.getElementById("sharing").innerHTML = "

Snapshots can be shared when they're taken in shareable places."; + } +} +window.onload = function () { + // Something like the following will allow testing in a browser. + //addImage({localPath: 'c:/Users/howar/OneDrive/Pictures/hifi-snap-by--on-2016-07-27_12-58-43.jpg'}); + //addImage({localPath: 'http://lorempixel.com/1512/1680'}); + openEventBridge(function () { + // Set up a handler for receiving the data, and tell the .js we are ready to receive it. + EventBridge.scriptEventReceived.connect(function (message) { + // last element of list contains a bool for whether or not we can share stuff + var shareMsg = message.pop(); + handleShareButtons(shareMsg); + + // rest are image paths which we add + useCheckboxes = message.length > 1; + message.forEach(addImage); + }); + EventBridge.emitWebEvent('ready'); + }); + +}; +// beware of bug: Cannot send objects at top level. (Nested in arrays is fine.) +function shareSelected() { + EventBridge.emitWebEvent(paths); +}; +function doNotShare() { + EventBridge.emitWebEvent([]); +}; +function snapshotSettings() { + EventBridge.emitWebEvent("openSettings"); +}; diff --git a/scripts/system/notifications.js b/scripts/system/notifications.js index 98a31d708c..34f9814d8a 100644 --- a/scripts/system/notifications.js +++ b/scripts/system/notifications.js @@ -527,12 +527,14 @@ function onDomainConnectionRefused(reason) { createNotification("Connection refused: " + reason, NotificationType.CONNECTION_REFUSED); } -function onSnapshotTaken(path) { - var imageProperties = { - path: Script.resolvePath("file:///" + path), - aspectRatio: Window.innerWidth / Window.innerHeight +function onSnapshotTaken(path, notify) { + if (notify) { + var imageProperties = { + path: Script.resolvePath("file:///" + path), + aspectRatio: Window.innerWidth / Window.innerHeight + } + createNotification(wordWrap("Snapshot saved to " + path), NotificationType.SNAPSHOT, imageProperties); } - createNotification(wordWrap("Snapshot saved to " + path), NotificationType.SNAPSHOT, imageProperties); } // handles mouse clicks on buttons diff --git a/scripts/system/snapshot.js b/scripts/system/snapshot.js new file mode 100644 index 0000000000..2f3b8862c2 --- /dev/null +++ b/scripts/system/snapshot.js @@ -0,0 +1,154 @@ +// +// snapshot.js +// +// Created by David Kelly on 1 August 2016 +// Copyright 2016 High Fidelity, Inc +// +// Distributed under the Apache License, Version 2.0 +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// +var SNAPSHOT_DELAY = 500; // 500ms +var toolBar = Toolbars.getToolbar("com.highfidelity.interface.toolbar.system"); +var resetOverlays; +var reticleVisible; +var button = toolBar.addButton({ + objectName: "snapshot", + imageURL: Script.resolvePath("assets/images/tools/snap.svg"), + visible: true, + buttonState: 1, + defaultState: 1, + hoverState: 2, + alpha: 0.9, +}); + +function shouldOpenFeedAfterShare() { + var persisted = Settings.getValue('openFeedAfterShare', true); // might answer true, false, "true", or "false" + return persisted && (persisted !== 'false'); +} +function showFeedWindow() { + DialogsManager.showFeed(); +} + +var outstanding; +function confirmShare(data) { + var dialog = new OverlayWebWindow('Snapshot Review', Script.resolvePath("html/SnapshotReview.html"), 800, 320); + function onMessage(message) { + // Receives message from the html dialog via the qwebchannel EventBridge. This is complicated by the following: + // 1. Although we can send POJOs, we cannot receive a toplevel object. (Arrays of POJOs are fine, though.) + // 2. Although we currently use a single image, we would like to take snapshot, a selfie, a 360 etc. all at the + // same time, show the user all of them, and have the user deselect any that they do not want to share. + // So we'll ultimately be receiving a set of objects, perhaps with different post processing for each. + var isLoggedIn; + var needsLogin = false; + switch (message) { + case 'ready': + dialog.emitScriptEvent(data); // Send it. + outstanding = 0; + break; + case 'openSettings': + Desktop.show("hifi/dialogs/GeneralPreferencesDialog.qml", "GeneralPreferencesDialog"); + break; + case 'setOpenFeedFalse': + Settings.setValue('openFeedAfterShare', false) + break; + case 'setOpenFeedTrue': + Settings.setValue('openFeedAfterShare', true) + break; + default: + dialog.webEventReceived.disconnect(onMessage); + dialog.close(); + isLoggedIn = Account.isLoggedIn(); + message.forEach(function (submessage) { + if (submessage.share && !isLoggedIn) { + needsLogin = true; + submessage.share = false; + } + if (submessage.share) { + print('sharing', submessage.localPath); + outstanding++; + Window.shareSnapshot(submessage.localPath); + } else { + print('not sharing', submessage.localPath); + } + }); + if (!outstanding && shouldOpenFeedAfterShare()) { + showFeedWindow(); + } + if (needsLogin) { // after the possible feed, so that the login is on top + Account.checkAndSignalForAccessToken(); + } + } + } + dialog.webEventReceived.connect(onMessage); + dialog.raise(); +} + +function snapshotShared(errorMessage) { + if (!errorMessage) { + print('snapshot uploaded and shared'); + } else { + print(errorMessage); + } + if ((--outstanding <= 0) && shouldOpenFeedAfterShare()) { + showFeedWindow(); + } +} + +function onClicked() { + // update button states + resetOverlays = Menu.isOptionChecked("Overlays"); + reticleVisible = Reticle.visible; + Reticle.visible = false; + Window.snapshotTaken.connect(resetButtons); + + button.writeProperty("buttonState", 0); + button.writeProperty("defaultState", 0); + button.writeProperty("hoverState", 2); + + // hide overlays if they are on + if (resetOverlays) { + Menu.setIsOptionChecked("Overlays", false); + } + + // hide hud + toolBar.writeProperty("visible", false); + + // take snapshot (with no notification) + Script.setTimeout(function () { + Window.takeSnapshot(false, 1.91); + }, SNAPSHOT_DELAY); +} + +function resetButtons(path, notify) { + // show overlays if they were on + if (resetOverlays) { + Menu.setIsOptionChecked("Overlays", true); + } + // show hud + toolBar.writeProperty("visible", true); + Reticle.visible = reticleVisible; + + // update button states + button.writeProperty("buttonState", 1); + button.writeProperty("defaultState", 1); + button.writeProperty("hoverState", 3); + Window.snapshotTaken.disconnect(resetButtons); + + // last element in data array tells dialog whether we can share or not + confirmShare([ + { localPath: path }, + { + canShare: !!location.placename, + openFeedAfterShare: shouldOpenFeedAfterShare() + } + ]); + } + +button.clicked.connect(onClicked); +Window.snapshotShared.connect(snapshotShared); + +Script.scriptEnding.connect(function () { + toolBar.removeButton("snapshot"); + button.clicked.disconnect(onClicked); + Window.snapshotShared.disconnect(snapshotShared); +});