diff --git a/interface/resources/qml/hifi/Card.qml b/interface/resources/qml/hifi/Card.qml index b72901fbdf..59fa66af0b 100644 --- a/interface/resources/qml/hifi/Card.qml +++ b/interface/resources/qml/hifi/Card.qml @@ -35,6 +35,7 @@ Rectangle { property string timePhrase: pastTime(timestamp); property int onlineUsers: 0; property bool isConcurrency: action === 'concurrency'; + property bool isAnnouncement: action === 'announcement'; property bool isStacked: !isConcurrency && drillDownToPlace; property int textPadding: 10; @@ -80,7 +81,7 @@ Rectangle { id: lobby; visible: !hasGif || (animation.status !== Image.Ready); width: parent.width - (isConcurrency ? 0 : (2 * smallMargin)); - height: parent.height - (isConcurrency ? 0 : smallMargin); + height: parent.height - messageHeight - (isConcurrency ? 0 : smallMargin); source: thumbnail || defaultThumbnail; fillMode: Image.PreserveAspectCrop; anchors { @@ -129,7 +130,7 @@ Rectangle { property int dropSamples: 9; property int dropSpread: 0; DropShadow { - visible: true; + visible: showPlace; // Do we have to check for whatever the modern equivalent is for desktop.gradientsSupported? source: place; anchors.fill: place; horizontalOffset: dropHorizontalOffset; @@ -139,12 +140,12 @@ Rectangle { color: hifi.colors.black; spread: dropSpread; } - RalewayLight { + RalewaySemiBold { id: place; visible: showPlace; text: placeName; color: hifi.colors.white; - size: 38; + size: textSize; elide: Text.ElideRight; // requires constrained width anchors { top: parent.top; @@ -153,57 +154,44 @@ Rectangle { margins: textPadding; } } - Rectangle { - id: rectRow - z: 1 - width: message.width + (users.visible ? users.width + bottomRow.spacing : 0) - + (icon.visible ? icon.width + bottomRow.spacing: 0) + bottomRow.spacing; - height: messageHeight + 1; - radius: 25 - - anchors { - bottom: parent.bottom - left: parent.left - leftMargin: textPadding - bottomMargin: textPadding + Row { + FiraSansRegular { + id: users; + visible: isConcurrency || isAnnouncement; + text: onlineUsers; + size: textSize; + color: messageColor; + anchors.verticalCenter: message.verticalCenter; } - - Row { - id: bottomRow - FiraSansRegular { - id: users; - visible: isConcurrency; - text: onlineUsers; - size: textSize; - color: messageColor; - anchors.verticalCenter: message.verticalCenter; - } - Image { - id: icon; - source: "../../images/snap-icon.svg" - width: 40; - height: 40; - visible: action === 'snapshot'; - } - RalewayRegular { - id: message; - text: isConcurrency ? ((onlineUsers === 1) ? "person" : "people") : (drillDownToPlace ? "snapshots" : ("by " + userName)); - size: textSizeSmall; - color: messageColor; - elide: Text.ElideRight; // requires a width to be specified` - anchors { - bottom: parent.bottom; - bottomMargin: parent.spacing; - } - } - spacing: textPadding; - height: messageHeight; + Image { + id: icon; + source: "../../images/snap-icon.svg" + width: 40; + height: 40; + visible: action === 'snapshot'; + } + RalewayRegular { + id: message; + text: isConcurrency ? ((onlineUsers === 1) ? "person" : "people") : (isAnnouncement ? "connections" : (drillDownToPlace ? "snapshots" : ("by " + userName))); + size: textSizeSmall; + color: messageColor; + elide: Text.ElideRight; // requires a width to be specified` + width: root.width - textPadding + - (users.visible ? users.width + parent.spacing : 0) + - (icon.visible ? icon.width + parent.spacing : 0) + - (actionIcon.width + (2 * smallMargin)); anchors { bottom: parent.bottom; - left: parent.left; - leftMargin: 4 + bottomMargin: parent.spacing; } } + spacing: textPadding; + height: messageHeight; + anchors { + bottom: parent.bottom; + left: parent.left; + leftMargin: textPadding; + } } // These two can be supplied to provide hover behavior. // For example, AddressBarDialog provides functions that set the current list view item @@ -218,37 +206,22 @@ Rectangle { onEntered: hoverThunk(); onExited: unhoverThunk(); } - Rectangle { - id: rectIcon - z: 1 - width: 32 - height: 32 - radius: 15 + StateImage { + id: actionIcon; + imageURL: "../../images/info-icon-2-state.svg"; + size: 32; + buttonState: messageArea.containsMouse ? 1 : 0; anchors { bottom: parent.bottom; right: parent.right; - bottomMargin: textPadding; - rightMargin: textPadding; - } - - StateImage { - id: actionIcon; - imageURL: "../../images/info-icon-2-state.svg"; - size: 32; - buttonState: messageArea.containsMouse ? 1 : 0; - anchors { - bottom: parent.bottom; - right: parent.right; - //margins: smallMargin; - } + margins: smallMargin; } } - MouseArea { id: messageArea; - width: rectIcon.width; - height: rectIcon.height; - anchors.fill: rectIcon + width: parent.width; + height: messageHeight; + anchors.top: lobby.bottom; acceptedButtons: Qt.LeftButton; onClicked: goFunction(drillDownToPlace ? ("/places/" + placeName) : ("/user_stories/" + storyId)); hoverEnabled: true; diff --git a/interface/resources/qml/hifi/Feed.qml b/interface/resources/qml/hifi/Feed.qml new file mode 100644 index 0000000000..d95518b891 --- /dev/null +++ b/interface/resources/qml/hifi/Feed.qml @@ -0,0 +1,201 @@ +// +// Feed.qml +// qml/hifi +// +// Displays a particular type of feed +// +// Created by Howard Stearns on 4/18/2017 +// Copyright 2016 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +import Hifi 1.0 +import QtQuick 2.5 +import QtGraphicalEffects 1.0 +import "toolbars" +import "../styles-uit" + +Column { + id: root; + visible: false; + + property int cardWidth: 212; + property int cardHeight: 152; + property int stackedCardShadowHeight: 10; + property string metaverseServerUrl: ''; + property string actions: 'snapshot'; + onActionsChanged: fillDestinations(); + Component.onCompleted: fillDestinations(); + property string labelText: actions; + property string filter: ''; + onFilterChanged: filterChoicesByText(); + property var goFunction: null; + + HifiConstants { id: hifi } + ListModel { id: suggestions; } + + function resolveUrl(url) { + return (url.indexOf('/') === 0) ? (metaverseServerUrl + url) : url; + } + function makeModelData(data) { // 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 = data.place_name, + tags = data.tags || [data.action, data.username], + description = data.description || "", + thumbnail_url = data.thumbnail_url || ""; + if (actions === 'concurrency,snapshot') { + // A temporary hack for simulating announcements. We won't use this in production, but if requested, we'll use this data like announcements. + data.details.connections = 4; + data.action = 'announcement'; + } + return { + place_name: name, + username: data.username || "", + path: data.path || "", + created_at: data.created_at || "", + action: data.action || "", + thumbnail_url: resolveUrl(thumbnail_url), + image_url: resolveUrl(data.details && data.details.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.details.connections || data.details.concurrency || 0, + drillDownToPlace: false, + + searchText: [name].concat(tags, description || []).join(' ').toUpperCase() + } + } + property var allStories: []; + property var placeMap: ({}); // Used for making stacks. + property int requestId: 0; + function getUserStoryPage(pageNumber, cb, cb1) { // cb(error) after all pages of domain data have been added to model + // If supplied, cb1 will be run after the first page IFF it is not the last, for responsiveness. + var options = [ + 'now=' + new Date().toISOString(), + 'include_actions=' + actions, + 'restriction=' + (Account.isLoggedIn() ? 'open,hifi' : 'open'), + 'require_online=true', + 'protocol=' + encodeURIComponent(AddressManager.protocolVersion()), + 'page=' + pageNumber + ]; + var url = metaverseBase + 'user_stories?' + options.join('&'); + var thisRequestId = ++requestId; + getRequest(url, function (error, data) { + if ((thisRequestId !== requestId) || handleError(url, error, data, cb)) { + return; // abandon stale requests + } + allStories = allStories.concat(data.user_stories.map(makeModelData)); + if ((data.current_page < data.total_pages) && (data.current_page <= 10)) { // just 10 pages = 100 stories for now + if ((pageNumber === 1) && cb1) { + cb1(); + } + return getUserStoryPage(pageNumber + 1, cb); + } + cb(); + }); + } + function fillDestinations() { // Public + var filter = makeFilteredStoryProcessor(), counter = 0; + allStories = []; + suggestions.clear(); + placeMap = {}; + getUserStoryPage(1, function (error) { + allStories.slice(counter).forEach(filter); + console.log('user stories query', actions, error || 'ok', allStories.length, 'filtered to', suggestions.count); + root.visible = !!suggestions.count; + }, function () { // If there's more than a page, put what we have in the model right away, keeping track of how many are processed. + allStories.forEach(function (story) { + counter++; + filter(story); + root.visible = !!suggestions.count; + }); + }); + } + function makeFilteredStoryProcessor() { // answer a function(storyData) that adds it to suggestions if it matches + var words = filter.toUpperCase().split(/\s+/).filter(identity); + function suggestable(story) { // fixme add to makeFilteredStoryProcessor + if (story.action === 'snapshot') { + return true; + } + return (story.place_name !== AddressManager.placename); // Not our entry, but do show other entry points to current domain. + } + function matches(story) { + if (!words.length) { + return suggestable(story); + } + return words.every(function (word) { + return story.searchText.indexOf(word) >= 0; + }); + } + function addToSuggestions(place) { + var collapse = ((actions === 'concurrency,snapshot') && (place.action !== 'concurrency')) || (place.action === 'announcement'); + if (collapse) { + var existing = placeMap[place.place_name]; + if (existing) { + existing.drillDownToPlace = true; + return; + } + } + suggestions.append(place); + if (collapse) { + placeMap[place.place_name] = suggestions.get(suggestions.count - 1); + } else if (place.action === 'concurrency') { + suggestions.get(suggestions.count - 1).drillDownToPlace = true; // Don't change raw place object (in allStories). + } + } + return function (story) { + if (matches(story)) { + addToSuggestions(story); + } + }; + } + function filterChoicesByText() { + suggestions.clear(); + placeMap = {}; + allStories.forEach(makeFilteredStoryProcessor()); + root.visible = !!suggestions.count; + } + + RalewayLight { + id: label; + text: labelText; + color: hifi.colors.white; + size: 28; + } + ListView { + id: scroll; + clip: true; + model: suggestions; + orientation: ListView.Horizontal; + highlightMoveDuration: -1; + highlightMoveVelocity: -1; + highlight: Rectangle { color: "transparent"; border.width: 4; border.color: hifiStyleConstants.colors.blueHighlight; z: 1; } + + spacing: 14; + width: parent.width; + height: cardHeight + stackedCardShadowHeight; + delegate: Card { + width: cardWidth; + height: cardHeight; + goFunction: root.goFunction; + userName: model.username; + placeName: model.place_name; + hifiUrl: model.place_name + model.path; + thumbnail: model.thumbnail_url; + imageUrl: model.image_url; + action: model.action; + timestamp: model.created_at; + onlineUsers: model.online_users; + storyId: model.metaverseId; + drillDownToPlace: model.drillDownToPlace; + shadowHeight: stackedCardShadowHeight; + hoverThunk: function () { scroll.currentIndex = index; } + unhoverThunk: function () { scroll.currentIndex = -1; } + } + } +} diff --git a/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml b/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml index b78254a4a0..5578b94168 100644 --- a/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml +++ b/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml @@ -30,18 +30,15 @@ StackView { width: parent !== null ? parent.width : undefined height: parent !== null ? parent.height : undefined property var eventBridge; - property var allStories: []; - property int cardWidth: 460; - property int cardHeight: 320; + property int cardWidth: 212; + property int cardHeight: 152; property string metaverseBase: addressBarDialog.metaverseServerUrl + "/api/v1/"; property var tablet: null; Component { id: tabletWebView; TabletWebView {} } Component.onCompleted: { - fillDestinations(); updateLocationText(false); - fillDestinations(); addressLine.focus = !HMD.active; root.parentChanged.connect(center); center(); @@ -190,7 +187,6 @@ StackView { } font.pixelSize: hifi.fonts.pixelSize * 0.75 onTextChanged: { - filterChoicesByText(); updateLocationText(text.length > 0); } onAccepted: { @@ -225,109 +221,68 @@ StackView { } } } + Rectangle { - id: topBar - height: 37 - color: hifiStyleConstants.colors.white - - anchors.right: parent.right - anchors.rightMargin: 0 - anchors.left: parent.left - anchors.leftMargin: 0 - anchors.topMargin: 0 - anchors.top: addressBar.bottom - - Row { - id: thing - spacing: 5 * hifi.layout.spacing - - anchors { - top: parent.top; - left: parent.left - leftMargin: 25 - } - - TabletTextButton { - id: allTab; - text: "ALL"; - property string includeActions: 'snapshot,concurrency'; - selected: allTab === selectedTab; - action: tabSelect; - } - - TabletTextButton { - id: placeTab; - text: "PLACES"; - property string includeActions: 'concurrency'; - selected: placeTab === selectedTab; - action: tabSelect; - - } - - TabletTextButton { - id: snapTab; - text: "SNAP"; - property string includeActions: 'snapshot'; - selected: snapTab === selectedTab; - action: tabSelect; - } + id: bgMain; + color: hifiStyleConstants.colors.faintGray50; + anchors { + top: addressBar.bottom; + bottom: parent.keyboardEnabled ? keyboard.top : parent.bottom; + left: parent.left; + right: parent.right; } - - } - - Rectangle { - id: bgMain - color: hifiStyleConstants.colors.white - anchors.bottom: parent.keyboardEnabled ? keyboard.top : parent.bottom - anchors.bottomMargin: 0 - anchors.right: parent.right - anchors.rightMargin: 0 - anchors.left: parent.left - anchors.leftMargin: 0 - anchors.top: topBar.bottom - anchors.topMargin: 0 - - ListModel { id: suggestions } - - ListView { - id: scroll - - property int stackedCardShadowHeight: 0; - clip: true - spacing: 14 - anchors { - bottom: parent.bottom - top: parent.top - left: parent.left - right: parent.right - leftMargin: 10 + ScrollView { + anchors.fill: bgMain; + horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff; + verticalScrollBarPolicy: Qt.ScrollBarAsNeeded; + Rectangle { // Column margins require QtQuick 2.7, which we don't use yet. + id: column; + property real pad: 10; + width: bgMain.width - column.pad; + height: stack.height; + color: "transparent"; + anchors { + left: parent.left; + leftMargin: column.pad; + topMargin: column.pad; + } + Column { + id: stack; + width: column.width; + spacing: column.pad; + Feed { + id: happeningNow; + width: parent.width; + property real cardScale: 1.5; + cardWidth: places.cardWidth * happeningNow.cardScale; + cardHeight: places.cardHeight * happeningNow.cardScale; + metaverseServerUrl: addressBarDialog.metaverseServerUrl; + labelText: 'Happening Now'; + //actions: 'concurrency,snapshot'; // uncomment this line instead of next to produce fake announcement data for testing. + actions: 'announcement'; + filter: addressLine.text; + goFunction: goCard; + } + Feed { + id: places; + width: parent.width; + metaverseServerUrl: addressBarDialog.metaverseServerUrl; + labelText: 'Places'; + actions: 'concurrency'; + filter: addressLine.text; + goFunction: goCard; + } + Feed { + id: snapshots; + width: parent.width; + metaverseServerUrl: addressBarDialog.metaverseServerUrl; + labelText: 'Recent Activity'; + actions: 'snapshot'; + filter: addressLine.text; + goFunction: goCard; + } + } } - - model: suggestions - orientation: ListView.Vertical - - delegate: Card { - width: cardWidth; - height: cardHeight; - goFunction: goCard; - userName: model.username; - placeName: model.place_name; - hifiUrl: model.place_name + model.path; - thumbnail: model.thumbnail_url; - imageUrl: model.image_url; - action: model.action; - timestamp: model.created_at; - onlineUsers: model.online_users; - storyId: model.metaverseId; - drillDownToPlace: model.drillDownToPlace; - shadowHeight: scroll.stackedCardShadowHeight; - hoverThunk: function () { scroll.currentIndex = index; } - unhoverThunk: function () { scroll.currentIndex = -1; } - } - - highlightMoveDuration: -1; - highlightMoveVelocity: -1; - highlight: Rectangle { color: "transparent"; border.width: 4; border.color: hifiStyleConstants.colors.blueHighlight; z: 1; } } } @@ -409,124 +364,6 @@ StackView { return true; } - - function resolveUrl(url) { - return (url.indexOf('/') === 0) ? (addressBarDialog.metaverseServerUrl + url) : url; - } - - function makeModelData(data) { // 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 = data.place_name, - tags = data.tags || [data.action, data.username], - description = data.description || "", - thumbnail_url = data.thumbnail_url || ""; - return { - place_name: name, - username: data.username || "", - path: data.path || "", - created_at: data.created_at || "", - action: data.action || "", - thumbnail_url: resolveUrl(thumbnail_url), - image_url: resolveUrl(data.details.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.details.concurrency || 0, - drillDownToPlace: false, - - searchText: [name].concat(tags, description || []).join(' ').toUpperCase() - } - } - function suggestable(place) { - if (place.action === 'snapshot') { - return true; - } - return (place.place_name !== AddressManager.placename); // Not our entry, but do show other entry points to current domain. - } - property var selectedTab: allTab; - function tabSelect(textButton) { - selectedTab = textButton; - fillDestinations(); - } - property var placeMap: ({}); - function addToSuggestions(place) { - var collapse = allTab.selected && (place.action !== 'concurrency'); - if (collapse) { - var existing = placeMap[place.place_name]; - if (existing) { - existing.drillDownToPlace = true; - return; - } - } - suggestions.append(place); - if (collapse) { - placeMap[place.place_name] = suggestions.get(suggestions.count - 1); - } else if (place.action === 'concurrency') { - suggestions.get(suggestions.count - 1).drillDownToPlace = true; // Don't change raw place object (in allStories). - } - } - property int requestId: 0; - function getUserStoryPage(pageNumber, cb) { // cb(error) after all pages of domain data have been added to model - var options = [ - 'now=' + new Date().toISOString(), - 'include_actions=' + selectedTab.includeActions, - 'restriction=' + (Account.isLoggedIn() ? 'open,hifi' : 'open'), - 'require_online=true', - 'protocol=' + encodeURIComponent(AddressManager.protocolVersion()), - 'page=' + pageNumber - ]; - var url = metaverseBase + 'user_stories?' + options.join('&'); - var thisRequestId = ++requestId; - getRequest(url, function (error, data) { - if ((thisRequestId !== requestId) || handleError(url, error, data, cb)) { - return; - } - var stories = data.user_stories.map(function (story) { // explicit single-argument function - return makeModelData(story, url); - }); - allStories = allStories.concat(stories); - stories.forEach(makeFilteredPlaceProcessor()); - 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 makeFilteredPlaceProcessor() { // answer a function(placeData) that adds it to suggestions if it matches - var words = addressLine.text.toUpperCase().split(/\s+/).filter(identity), - data = allStories; - function matches(place) { - if (!words.length) { - return suggestable(place); - } - return words.every(function (word) { - return place.searchText.indexOf(word) >= 0; - }); - } - return function (place) { - if (matches(place)) { - addToSuggestions(place); - } - }; - } - function filterChoicesByText() { - suggestions.clear(); - placeMap = {}; - allStories.forEach(makeFilteredPlaceProcessor()); - } - - function fillDestinations() { - allStories = []; - suggestions.clear(); - placeMap = {}; - getUserStoryPage(1, function (error) { - console.log('user stories query', error || 'ok', allStories.length); - }); - } - function updateLocationText(enteringAddress) { if (enteringAddress) { notice.text = "Go To a place, @user, path, or network address:";