diff --git a/interface/resources/qml/AddressBarDialog.qml b/interface/resources/qml/AddressBarDialog.qml index 792410c59d..b9e7941b76 100644 --- a/interface/resources/qml/AddressBarDialog.qml +++ b/interface/resources/qml/AddressBarDialog.qml @@ -46,12 +46,10 @@ Window { } function goCard(card) { - addressLine.text = card.userStory.name; + addressLine.text = card.path; toggleOrGo(true); } - property var allDomains: []; - property var suggestionChoices: []; - property var domainsBaseUrl: null; + property var allPlaces: []; property int cardWidth: 200; property int cardHeight: 152; @@ -60,33 +58,34 @@ Window { implicitWidth: backgroundImage.width implicitHeight: backgroundImage.height - Row { - width: backgroundImage.width; + ListModel { id: suggestions } + + ListView { + width: (3 * cardWidth) + (2 * hifi.layout.spacing); + height: cardHeight; + spacing: hifi.layout.spacing; + clip: true; anchors { bottom: backgroundImage.top; bottomMargin: 2 * hifi.layout.spacing; right: backgroundImage.right; - rightMargin: -104; // FIXME } - spacing: hifi.layout.spacing; - Card { - id: s0; + model: suggestions; + orientation: ListView.Horizontal; + delegate: Card { width: cardWidth; height: cardHeight; - goFunction: goCard - } - Card { - id: s1; - width: cardWidth; - height: cardHeight; - goFunction: goCard - } - Card { - id: s2; - width: cardWidth; - height: cardHeight; - goFunction: goCard + goFunction: goCard; + path: model.name + model.path; + thumbnail: model.thumbnail; + placeText: model.name; + usersText: model.online_users + ((model.online_users === 1) ? ' person' : ' people'); + hoverThunk: function () { ListView.view.currentIndex = index; } + unhoverThunk: function () { ListView.view.currentIndex = -1; } } + highlightMoveDuration: -1; + highlightMoveVelocity: -1; + highlight: Rectangle { color: "transparent"; border.width: 2; border.color: "#1FA5E8"; z: 1; } } Image { @@ -200,133 +199,157 @@ 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 map 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 = 1; // 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(error, data, cb) { // cb(error) and answer truthy if needed, else falsey + if (!error && (data.status === 'success')) { + return; + } + cb(error || new Error(data.status + ': ' + data.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 + getRequest('https://metaverse.highfidelity.com/api/v1/places/' + placeData.name, function (error, data) { + if (handleError(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 = 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 mapDomainPlaces(domain, cb) { // cb(error, arrayOfDomainPlaceData) + function addPlace(name, icb) { + getPlace({ + name: name, + tags: domain.tags, + thumbnail: "", + description: "", + path: "", + searchText: [name].concat(domain.tags).join(' ').toUpperCase(), + online_users: domain.online_users + }, 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) { + return (place.name !== AddressManager.hostname) // Not our entry, but do show other entry points to current domain. + && place.thumbnail + && 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 + // FIXME: should determine if place is actually running + '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', + 'page=' + pageNumber + ]; + getRequest('https://metaverse.highfidelity.com/api/v1/domains/all?' + params.join('&'), function (error, data) { + if (handleError(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) { // 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 filterChoicesByText() { + suggestions.clear(); + var words = addressLine.text.toUpperCase().split(/\s+/).filter(identity); + function matches(place) { + if (!words.length) { + return suggestable(place); + } + return words.every(function (word) { + return place.searchText.indexOf(word) >= 0; + }); + } + allPlaces.forEach(function (place) { + if (matches(place)) { + suggestions.append(place); + } + }); } function fillDestinations() { - allDomains = suggestionChoices = []; - getDomains({minUsers: 0, maxUsers: 20}, function (error, domains) { + allPlaces = []; + suggestions.clear(); + getDomainPage(1, function (error) { 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(); - }); + console.log('domain query finished', allPlaces.length); }); } diff --git a/interface/resources/qml/hifi/Card.qml b/interface/resources/qml/hifi/Card.qml index 7758c5800a..5fae628283 100644 --- a/interface/resources/qml/hifi/Card.qml +++ b/interface/resources/qml/hifi/Card.qml @@ -18,27 +18,29 @@ import "../styles-uit" Rectangle { property var goFunction: null; - property var userStory: null; property alias image: lobby; property alias placeText: place.text; property alias usersText: users.text; 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"); + property string thumbnail: defaultThumbnail; + property string path: ""; HifiConstants { id: hifi } 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 " + path); + source = defaultThumbnail; } } } @@ -79,7 +81,7 @@ Rectangle { } RalewayRegular { id: users; - size: textSize; + size: textSizeSmall; color: hifi.colors.white; anchors { bottom: parent.bottom; @@ -87,10 +89,15 @@ Rectangle { margins: textPadding; } } + 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(); } }