// // AddressBarDialog.qml // // Created by Austin Davis on 2015/04/14 // Copyright 2015 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 Hifi 1.0 import QtQuick 2.4 import "controls" import "styles" import "windows" import "hifi" import "hifi/toolbars" import "controls-uit" as HifiControls Window { id: root HifiConstants { id: hifi } objectName: "AddressBarDialog" frame: HiddenFrame {} hideBackground: true shown: false destroyOnHidden: false resizable: false scale: 1.25 // Make this dialog a little larger than normal width: addressBarDialog.implicitWidth height: addressBarDialog.implicitHeight onShownChanged: addressBarDialog.observeShownChanged(shown); Component.onCompleted: { root.parentChanged.connect(center); center(); } Component.onDestruction: { root.parentChanged.disconnect(center); } function center() { // Explicitly center in order to avoid warnings at shutdown anchors.centerIn = parent; } function resetAfterTeleport() { storyCardFrame.shown = root.shown = false; } function goCard(card) { if (addressBarDialog.useFeed) { storyCardHTML.url = addressBarDialog.metaverseServerUrl + "/user_stories/" + card.storyId; storyCardFrame.shown = true; return; } addressLine.text = card.hifiUrl; toggleOrGo(true); } 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(); 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; horizontalCenter: backgroundImage.horizontalCenter } model: suggestions; orientation: ListView.Horizontal; delegate: Card { width: cardWidth; height: cardHeight; 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; } } 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" width: 576 * root.scale height: 80 * root.scale property int inputAreaHeight: 56.0 * root.scale // Height of the background's input area property int inputAreaStep: (height - inputAreaHeight) / 2 ToolbarButton { id: homeButton imageURL: "../images/home.svg" buttonState: 1 defaultState: 1 hoverState: 2 onClicked: addressBarDialog.loadHome(); anchors { left: parent.left leftMargin: homeButton.width / 2 verticalCenter: parent.verticalCenter } } 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 verticalCenter: parent.verticalCenter } } 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 verticalCenter: parent.verticalCenter } } // FIXME replace with TextField TextInput { id: addressLine focus: true anchors { 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: 750; height: 500; 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(); // QT bug: apparently doesn't handle onload. Workaround using readyState. request.onreadystatechange = function () { var READY_STATE_DONE = 4; var HTTP_OK = 200; if (request.readyState >= READY_STATE_DONE) { var error = (request.status !== HTTP_OK) && request.status.toString() + ':' + request.statusText, response = !error && request.responseText, contentType = !error && request.getResponseHeader('content-type'); if (!error && contentType.indexOf('application/json') === 0) { try { response = JSON.parse(response); } catch (e) { error = e; } } cb(error, response); } }; request.open("GET", url, true); request.send(); } 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(null, results); } 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 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 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; } var place = data.data.place, previews = place.previews; placeData.path = place.path; if (previews && previews.thumbnail) { placeData.thumbnail_url = previews.thumbnail; } 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(); }); }); } 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() { 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); }); } onVisibleChanged: { if (visible) { addressLine.forceActiveFocus() fillDestinations(); } else { addressLine.text = "" } } function toggleOrGo(fromSuggestions) { if (addressLine.text !== "") { addressBarDialog.loadAddress(addressLine.text, fromSuggestions) } root.shown = false; } Keys.onPressed: { switch (event.key) { case Qt.Key_Escape: case Qt.Key_Back: root.shown = false event.accepted = true break case Qt.Key_Enter: case Qt.Key_Return: toggleOrGo() event.accepted = true break } } }