Merge pull request #13307 from howard-stearns/infinite-scroll

Infinite scroll
This commit is contained in:
Howard Stearns 2018-06-11 10:03:52 -07:00 committed by GitHub
commit 5ce122f148
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 546 additions and 730 deletions

View file

@ -165,11 +165,11 @@ TextField {
anchors.left: parent.left
Binding on anchors.right {
when: parent.right
value: parent.right
when: textField.right
value: textField.right
}
Binding on wrapMode {
when: parent.right
when: textField.right
value: Text.WordWrap
}

View file

@ -16,10 +16,11 @@ import QtQuick 2.5
import QtGraphicalEffects 1.0
import "toolbars"
import "../styles-uit"
import "qrc:////qml//hifi//models" as HifiModels // Absolute path so the same code works everywhere.
Column {
id: root;
visible: false;
visible: !!suggestions.count;
property int cardWidth: 212;
property int cardHeight: 152;
@ -32,21 +33,37 @@ Column {
property int stackedCardShadowHeight: 4;
property int labelSize: 20;
property string metaverseServerUrl: '';
property string protocol: '';
property string actions: 'snapshot';
// sendToScript doesn't get wired until after everything gets created. So we have to queue fillDestinations on nextTick.
property string labelText: actions;
property string filter: '';
onFilterChanged: filterChoicesByText();
property var goFunction: null;
property var rpc: null;
property var http: null;
HifiConstants { id: hifi }
ListModel { id: suggestions; }
Component.onCompleted: suggestions.getFirstPage();
HifiModels.PSFListModel {
id: suggestions;
http: root.http;
property var options: [
'include_actions=' + actions,
'restriction=' + (Account.isLoggedIn() ? 'open,hifi' : 'open'),
'require_online=true',
'protocol=' + encodeURIComponent(Window.protocolSignature())
];
endpoint: '/api/v1/user_stories?' + options.join('&');
itemsPerPage: 3;
processPage: function (data) {
return data.user_stories.map(makeModelData);
};
listModelName: actions;
listView: scroll;
searchFilter: filter;
}
function resolveUrl(url) {
return (url.indexOf('/') === 0) ? (metaverseServerUrl + url) : url;
return (url.indexOf('/') === 0) ? (Account.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.
@ -55,16 +72,11 @@ Column {
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 || "",
created_at: data.created_at || data.updated_at || "", // FIXME why aren't we getting created_at?
action: data.action || "",
thumbnail_url: resolveUrl(thumbnail_url),
image_url: resolveUrl(data.details && data.details.image_url),
@ -74,125 +86,9 @@ Column {
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 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 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=' + protocol,
'page=' + pageNumber
];
var url = metaverseBase + 'user_stories?' + options.join('&');
var thisRequestId = ++requestId;
rpc('request', url, function (error, data) {
if (thisRequestId !== requestId) {
error = 'stale';
}
if (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
console.debug('Feed::fillDestinations()')
function report(label, error) {
console.log(label, actions, error || 'ok', allStories.length, 'filtered to', suggestions.count);
}
var filter = makeFilteredStoryProcessor(), counter = 0;
allStories = [];
suggestions.clear();
placeMap = {};
getUserStoryPage(1, function (error) {
allStories.slice(counter).forEach(filter);
report('user stories update', error);
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;
});
report('user stories');
});
}
function identity(x) {
return x;
}
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) {
// We could filter out places we don't want to suggest, such as those where (story.place_name === AddressManager.placename) or (story.username === Account.username).
return true;
}
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);
}
drillDownToPlace: false
};
}
function filterChoicesByText() {
suggestions.clear();
placeMap = {};
allStories.forEach(makeFilteredStoryProcessor());
root.visible = !!suggestions.count;
}
RalewayBold {
id: label;
@ -208,6 +104,7 @@ Column {
highlightMoveDuration: -1;
highlightMoveVelocity: -1;
currentIndex: -1;
onAtXEndChanged: { if (scroll.atXEnd && !scroll.atXBeginning) { suggestions.getNextPage(); } }
spacing: 12;
width: parent.width;
@ -239,21 +136,4 @@ Column {
unhoverThunk: function () { hovered = false }
}
}
NumberAnimation {
id: anim;
target: scroll;
property: "contentX";
duration: 250;
}
function scrollToIndex(index) {
anim.running = false;
var pos = scroll.contentX;
var destPos;
scroll.positionViewAtIndex(index, ListView.Contain);
destPos = scroll.contentX;
anim.from = pos;
anim.to = destPos;
scroll.currentIndex = index;
anim.running = true;
}
}

View file

@ -18,6 +18,7 @@ import Qt.labs.settings 1.0
import "../styles-uit"
import "../controls-uit" as HifiControlsUit
import "../controls" as HifiControls
import "qrc:////qml//hifi//models" as HifiModels // Absolute path so the same code works everywhere.
// references HMD, Users, UserActivityLogger from root context
@ -37,13 +38,42 @@ Rectangle {
property var myData: ({profileUrl: "", displayName: "", userName: "", audioLevel: 0.0, avgAudioLevel: 0.0, admin: true, placeName: "", connection: "", isPresent: true}); // valid dummy until set
property var ignored: ({}); // Keep a local list of ignored avatars & their data. Necessary because HashMap is slow to respond after ignoring.
property var nearbyUserModelData: []; // This simple list is essentially a mirror of the nearbyUserModel listModel without all the extra complexities.
property var connectionsUserModelData: []; // This simple list is essentially a mirror of the connectionsUserModel listModel without all the extra complexities.
property bool iAmAdmin: false;
property var activeTab: "nearbyTab";
property bool currentlyEditingDisplayName: false
property bool punctuationMode: false;
HifiConstants { id: hifi; }
RootHttpRequest { id: http; }
HifiModels.PSFListModel {
id: connectionsUserModel;
http: http;
endpoint: "/api/v1/users?filter=connections";
property var sortColumn: connectionsTable.getColumn(connectionsTable.sortIndicatorColumn);
sortProperty: switch (sortColumn && sortColumn.role) {
case 'placeName':
'location';
break;
case 'connection':
'is_friend';
break;
default:
'username';
}
sortAscending: connectionsTable.sortIndicatorOrder === Qt.AscendingOrder;
itemsPerPage: 9;
listView: connectionsTable;
processPage: function (data) {
return data.users.map(function (user) {
return {
userName: user.username,
connection: user.connection,
profileUrl: user.images.thumbnail,
placeName: (user.location.root || user.location.domain || {}).name || ''
};
});
};
}
// The letterbox used for popup messages
LetterboxMessage {
@ -106,16 +136,6 @@ Rectangle {
});
return sessionIDs;
}
function getSelectedConnectionsUserNames() {
var userNames = [];
connectionsTable.selection.forEach(function (userIndex) {
var datum = connectionsUserModelData[userIndex];
if (datum) {
userNames.push(datum.userName);
}
});
return userNames;
}
function refreshNearbyWithFilter() {
// We should just be able to set settings.filtered to inViewCheckbox.checked, but see #3249, so send to .js for saving.
var userIds = getSelectedNearbySessionIDs();
@ -232,9 +252,7 @@ Rectangle {
anchors.fill: parent;
onClicked: {
if (activeTab != "connectionsTab") {
connectionsLoading.visible = false;
connectionsLoading.visible = true;
pal.sendToScript({method: 'refreshConnections'});
connectionsUserModel.getFirstPage();
}
activeTab = "connectionsTab";
connectionsHelpText.color = hifi.colors.blueAccent;
@ -258,11 +276,7 @@ Rectangle {
id: reloadConnections;
width: reloadConnections.height;
glyph: hifi.glyphs.reload;
onClicked: {
connectionsLoading.visible = false;
connectionsLoading.visible = true;
pal.sendToScript({method: 'refreshConnections'});
}
onClicked: connectionsUserModel.getFirstPage('delayRefresh');
}
}
// "CONNECTIONS" text
@ -472,7 +486,7 @@ Rectangle {
visible: !isCheckBox && !isButton && !isAvgAudio;
uuid: model ? model.sessionId : "";
selected: styleData.selected;
isReplicated: model.isReplicated;
isReplicated: model && model.isReplicated;
isAdmin: model && model.admin;
isPresent: model && model.isPresent;
// Size
@ -702,7 +716,7 @@ Rectangle {
anchors.top: parent.top;
anchors.topMargin: 185;
anchors.horizontalCenter: parent.horizontalCenter;
visible: true;
visible: !connectionsUserModel.retrievedAtLeastOnePage;
onVisibleChanged: {
if (visible) {
connectionsTimeoutTimer.start();
@ -747,14 +761,6 @@ Rectangle {
headerVisible: true;
sortIndicatorColumn: settings.connectionsSortIndicatorColumn;
sortIndicatorOrder: settings.connectionsSortIndicatorOrder;
onSortIndicatorColumnChanged: {
settings.connectionsSortIndicatorColumn = sortIndicatorColumn;
sortConnectionsModel();
}
onSortIndicatorOrderChanged: {
settings.connectionsSortIndicatorOrder = sortIndicatorOrder;
sortConnectionsModel();
}
TableViewColumn {
id: connectionsUserNameHeader;
@ -779,8 +785,14 @@ Rectangle {
resizable: false;
}
model: ListModel {
id: connectionsUserModel;
model: connectionsUserModel;
Connections {
target: connectionsTable.flickableItem;
onAtYEndChanged: {
if (connectionsTable.flickableItem.atYEnd && !connectionsTable.flickableItem.atYBeginning) {
connectionsUserModel.getNextPage();
}
}
}
// This Rectangle refers to each Row in the connectionsTable.
@ -859,12 +871,9 @@ Rectangle {
checked: model && (model.connection === "friend");
boxSize: 24;
onClicked: {
var newValue = model.connection !== "friend";
connectionsUserModel.setProperty(model.userIndex, styleData.role, (newValue ? "friend" : "connection"));
connectionsUserModelData[model.userIndex][styleData.role] = newValue; // Defensive programming
pal.sendToScript({method: newValue ? 'addFriend' : 'removeFriend', params: model.userName});
pal.sendToScript({method: checked ? 'addFriend' : 'removeFriend', params: model.userName});
UserActivityLogger["palAction"](newValue ? styleData.role : "un-" + styleData.role, model.sessionId);
UserActivityLogger["palAction"](checked ? styleData.role : "un-" + styleData.role, model.sessionId);
}
}
}
@ -1130,16 +1139,6 @@ Rectangle {
sortModel();
reloadNearby.color = 0;
break;
case 'connections':
var data = message.params;
if (pal.debug) {
console.log('Got connection data: ', JSON.stringify(data));
}
connectionsUserModelData = data;
sortConnectionsModel();
connectionsLoading.visible = false;
connectionsRefreshProblemText.visible = false;
break;
case 'select':
var sessionIds = message.params[0];
var selected = message.params[1];
@ -1239,6 +1238,14 @@ Rectangle {
reloadNearby.color = 2;
}
break;
case 'inspectionCertificate_resetCert':
// marketplaces.js sends out a signal to QML with that method when the tablet screen changes and it's not changed to a commerce-related screen.
// We want it to only be handled by the InspectionCertificate.qml, but there's not an easy way of doing that.
// As a part of a "cleanup inspectionCertificate_resetCert" ticket, we'll have to figure out less logspammy way of doing what has to be done.
break;
case 'http.response':
http.handleHttpResponse(message);
break;
default:
console.log('Unrecognized message:', JSON.stringify(message));
}
@ -1287,45 +1294,6 @@ Rectangle {
nearbyTable.positionViewAtRow(newSelectedIndexes[0], ListView.Beginning);
}
}
function sortConnectionsModel() {
var column = connectionsTable.getColumn(connectionsTable.sortIndicatorColumn);
var sortProperty = column ? column.role : "userName";
var before = (connectionsTable.sortIndicatorOrder === Qt.AscendingOrder) ? -1 : 1;
var after = -1 * before;
// get selection(s) before sorting
var selectedIDs = getSelectedConnectionsUserNames();
connectionsUserModelData.sort(function (a, b) {
var aValue = a[sortProperty].toString().toLowerCase(), bValue = b[sortProperty].toString().toLowerCase();
if (!aValue && !bValue) {
return 0;
} else if (!aValue) {
return after;
} else if (!bValue) {
return before;
}
switch (true) {
case (aValue < bValue): return before;
case (aValue > bValue): return after;
default: return 0;
}
});
connectionsTable.selection.clear();
connectionsUserModel.clear();
var userIndex = 0;
var newSelectedIndexes = [];
connectionsUserModelData.forEach(function (datum) {
datum.userIndex = userIndex++;
connectionsUserModel.append(datum);
if (selectedIDs.indexOf(datum.sessionId) != -1) {
newSelectedIndexes.push(datum.userIndex);
}
});
if (newSelectedIndexes.length > 0) {
connectionsTable.selection.select(newSelectedIndexes);
connectionsTable.positionViewAtRow(newSelectedIndexes[0], ListView.Beginning);
}
}
signal sendToScript(var message);
function noticeSelection() {
var userIds = [];

View file

@ -0,0 +1,39 @@
//
// RootHttpRequest.qml
// qml/hifi
//
// Create an item of this in the ROOT qml to be able to make http requests.
// Used by PSFListModel.qml
//
// Created by Howard Stearns on 5/29/2018
// Copyright 2018 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
import QtQuick 2.5
Item {
property var httpCalls: ({});
property var httpCounter: 0;
// Public function for initiating an http request.
// REQUIRES parent to be root to have sendToScript!
function request(options, callback) {
console.debug('HttpRequest', JSON.stringify(options));
httpCalls[httpCounter] = callback;
var message = {method: 'http.request', params: options, id: httpCounter++, jsonrpc: "2.0"};
parent.sendToScript(message);
}
// REQUIRES that parent/root handle http.response message.method in fromScript, by calling this function.
function handleHttpResponse(message) {
var callback = httpCalls[message.id]; // FIXME: as different top level tablet apps gets loaded, the id repeats. We should drop old app callbacks without warning.
if (!callback) {
console.warn('No callback for', JSON.stringify(message));
return;
}
delete httpCalls[message.id];
console.debug('HttpRequest response', JSON.stringify(message));
callback(message.error, message.response);
}
}

View file

@ -42,7 +42,7 @@ Rectangle {
property bool alreadyOwned: false;
property int itemPrice: -1;
property bool isCertified;
property string itemType;
property string itemType: "unknown";
property var itemTypesArray: ["entity", "wearable", "contentSet", "app", "avatar", "unknown"];
property var itemTypesText: ["entity", "wearable", "content set", "app", "avatar", "item"];
property var buttonTextNormal: ["REZ", "WEAR", "REPLACE CONTENT SET", "INSTALL", "WEAR", "REZ"];
@ -98,9 +98,6 @@ Rectangle {
} else {
root.certificateId = result.data.certificate_id;
root.itemHref = result.data.download_url;
if (result.data.categories.indexOf("Wearables") > -1) {
root.itemType = "wearable";
}
root.activeView = "checkoutSuccess";
UserActivityLogger.commercePurchaseSuccess(root.itemId, root.itemAuthor, root.itemPrice, !root.alreadyOwned);
}
@ -170,9 +167,6 @@ Rectangle {
root.activeView = "checkoutFailure";
} else {
root.itemHref = result.data.download_url;
if (result.data.categories.indexOf("Wearables") > -1) {
root.itemType = "wearable";
}
root.activeView = "checkoutSuccess";
}
}
@ -186,20 +180,6 @@ Rectangle {
itemPreviewImage.source = "https://hifi-metaverse.s3-us-west-1.amazonaws.com/marketplace/previews/" + itemId + "/thumbnail/hifi-mp-" + itemId + ".jpg";
}
onItemHrefChanged: {
if (root.itemHref.indexOf(".fst") > -1) {
root.itemType = "avatar";
} else if (root.itemHref.indexOf('.json.gz') > -1 || root.itemHref.indexOf('.content.zip') > -1) {
root.itemType = "contentSet";
} else if (root.itemHref.indexOf('.app.json') > -1) {
root.itemType = "app";
} else if (root.itemHref.indexOf('.json') > -1) {
root.itemType = "entity"; // "wearable" type handled later
} else {
root.itemType = "unknown";
}
}
onItemTypeChanged: {
if (root.itemType === "entity" || root.itemType === "wearable" ||
root.itemType === "contentSet" || root.itemType === "avatar" || root.itemType === "app") {
@ -1102,6 +1082,7 @@ Rectangle {
root.referrer = message.params.referrer;
root.itemAuthor = message.params.itemAuthor;
root.itemEdition = message.params.itemEdition || -1;
root.itemType = message.params.itemType || "unknown";
refreshBuyUI();
break;
default:

View file

@ -44,7 +44,7 @@ Item {
Item {
id: avatarImage;
visible: profileUrl !== "" && userName !== "";
visible: profilePicUrl !== "" && userName !== "";
// Size
anchors.verticalCenter: parent.verticalCenter;
anchors.left: parent.left;

View file

@ -19,6 +19,7 @@ import "../../../../styles-uit"
import "../../../../controls-uit" as HifiControlsUit
import "../../../../controls" as HifiControls
import "../" as HifiCommerceCommon
import "qrc:////qml//hifi//models" as HifiModels // Absolute path so the same code works everywhere.
Item {
HifiConstants { id: hifi; }
@ -36,6 +37,8 @@ Item {
property string assetName: "";
property string assetCertID: "";
property string sendingPubliclyEffectImage;
property var http;
property var listModelName;
// This object is always used in a popup or full-screen Wallet section.
// This MouseArea is used to prevent a user from being
@ -118,9 +121,7 @@ Item {
if (root.currentActiveView === 'chooseRecipientConnection') {
// Refresh connections model
connectionsLoading.visible = false;
connectionsLoading.visible = true;
sendSignalToParent({method: 'refreshConnections'});
connectionsModel.getFirstPage();
} else if (root.currentActiveView === 'sendAssetHome') {
Commerce.balance();
} else if (root.currentActiveView === 'chooseRecipientNearby') {
@ -392,11 +393,17 @@ Item {
hoverEnabled: true;
}
ListModel {
HifiModels.PSFListModel {
id: connectionsModel;
}
ListModel {
id: filteredConnectionsModel;
http: root.http;
listModelName: root.listModelName;
endpoint: "/api/v1/users?filter=connections";
itemsPerPage: 8;
listView: connectionsList;
processPage: function (data) {
return data.users;
};
searchFilter: filterBar.text;
}
Rectangle {
@ -472,10 +479,6 @@ Item {
anchors.fill: parent;
centerPlaceholderGlyph: hifi.glyphs.search;
onTextChanged: {
buildFilteredConnectionsModel();
}
onAccepted: {
focus = false;
}
@ -495,6 +498,7 @@ Item {
AnimatedImage {
id: connectionsLoading;
visible: !connectionsModel.retrievedAtLeastOnePage;
source: "../../../../../icons/profilePicLoading.gif"
width: 120;
height: width;
@ -515,14 +519,15 @@ Item {
}
visible: !connectionsLoading.visible;
clip: true;
model: filteredConnectionsModel;
model: connectionsModel;
onAtYEndChanged: if (connectionsList.atYEnd && !connectionsList.atYBeginning) { connectionsModel.getNextPage(); }
snapMode: ListView.SnapToItem;
// Anchors
anchors.fill: parent;
delegate: ConnectionItem {
isSelected: connectionsList.currentIndex === index;
userName: model.userName;
profilePicUrl: model.profileUrl;
userName: model.username;
profilePicUrl: model.images.thumbnail;
anchors.topMargin: 6;
anchors.bottomMargin: 6;
@ -553,7 +558,7 @@ Item {
// "Make a Connection" instructions
Rectangle {
id: connectionInstructions;
visible: connectionsModel.count === 0 && !connectionsLoading.visible;
visible: connectionsModel.count === 0 && !connectionsModel.searchFilter && !connectionsLoading.visible;
anchors.fill: parent;
color: "white";
@ -1806,22 +1811,6 @@ Item {
// FUNCTION DEFINITIONS START
//
function updateConnections(connections) {
connectionsModel.clear();
connectionsModel.append(connections);
buildFilteredConnectionsModel();
connectionsLoading.visible = false;
}
function buildFilteredConnectionsModel() {
filteredConnectionsModel.clear();
for (var i = 0; i < connectionsModel.count; i++) {
if (connectionsModel.get(i).userName.toLowerCase().indexOf(filterBar.text.toLowerCase()) !== -1) {
filteredConnectionsModel.append(connectionsModel.get(i));
}
}
}
function resetSendAssetData() {
amountTextField.focus = false;
optionalMessage.focus = false;

View file

@ -16,10 +16,12 @@ import QtQuick 2.5
import "../../../styles-uit"
import "../../../controls-uit" as HifiControlsUit
import "../../../controls" as HifiControls
import "qrc:////qml//hifi//models" as HifiModels // Absolute path so the same code works everywhere.
import "../wallet" as HifiWallet
import "../common" as HifiCommerceCommon
import "../inspectionCertificate" as HifiInspectionCertificate
import "../common/sendAsset" as HifiSendAsset
import "../.." as HifiCommon
// references XXX from root context
@ -34,12 +36,17 @@ Rectangle {
property bool punctuationMode: false;
property bool isShowingMyItems: false;
property bool isDebuggingFirstUseTutorial: false;
property int pendingItemCount: 0;
property string installedApps;
property bool keyboardRaised: false;
property int numUpdatesAvailable: 0;
// Style
color: hifi.colors.white;
function getPurchases() {
root.activeView = "purchasesMain";
root.installedApps = Commerce.getInstalledApps();
purchasesModel.getFirstPage();
Commerce.getAvailableUpdates();
}
Connections {
target: Commerce;
@ -62,10 +69,7 @@ Rectangle {
if ((Settings.getValue("isFirstUseOfPurchases", true) || root.isDebuggingFirstUseTutorial) && root.activeView !== "firstUseTutorial") {
root.activeView = "firstUseTutorial";
} else if (!Settings.getValue("isFirstUseOfPurchases", true) && root.activeView === "initialize") {
root.activeView = "purchasesMain";
root.installedApps = Commerce.getInstalledApps();
Commerce.inventory();
Commerce.getAvailableUpdates();
getPurchases();
}
} else {
console.log("ERROR in Purchases.qml: Unknown wallet status: " + walletStatus);
@ -81,39 +85,7 @@ Rectangle {
}
onInventoryResult: {
purchasesReceived = true;
if (result.status !== 'success') {
console.log("Failed to get purchases", result.message);
} else if (!purchasesContentsList.dragging) { // Don't modify the view if the user's scrolling
var inventoryResult = processInventoryResult(result.data.assets);
var currentIndex = purchasesContentsList.currentIndex === -1 ? 0 : purchasesContentsList.currentIndex;
purchasesModel.clear();
purchasesModel.append(inventoryResult);
root.pendingItemCount = 0;
for (var i = 0; i < purchasesModel.count; i++) {
if (purchasesModel.get(i).status === "pending") {
root.pendingItemCount++;
}
}
if (previousPurchasesModel.count !== 0) {
checkIfAnyItemStatusChanged();
} else {
// Fill statusChanged default value
// Not doing this results in the default being true...
for (var i = 0; i < purchasesModel.count; i++) {
purchasesModel.setProperty(i, "statusChanged", false);
}
}
previousPurchasesModel.append(inventoryResult);
buildFilteredPurchasesModel();
purchasesContentsList.positionViewAtIndex(currentIndex, ListView.Beginning);
}
purchasesModel.handlePage(result.status !== "success" && result.message, result);
}
onAvailableUpdatesResult: {
@ -134,6 +106,10 @@ Rectangle {
}
}
onIsShowingMyItemsChanged: {
getPurchases();
}
Timer {
id: notSetUpTimer;
interval: 200;
@ -172,8 +148,14 @@ Rectangle {
}
}
HifiCommon.RootHttpRequest {
id: http;
}
HifiSendAsset.SendAsset {
id: sendAsset;
http: http;
listModelName: "Gift Connections";
z: 998;
visible: root.activeView === "giftAsset";
anchors.fill: parent;
@ -183,9 +165,7 @@ Rectangle {
Connections {
onSendSignalToParent: {
if (msg.method === 'sendAssetHome_back' || msg.method === 'closeSendAsset') {
root.activeView = "purchasesMain";
Commerce.inventory();
Commerce.getAvailableUpdates();
getPurchases();
} else {
sendToScript(msg);
}
@ -449,10 +429,7 @@ Rectangle {
case 'tutorial_skipClicked':
case 'tutorial_finished':
Settings.setValue("isFirstUseOfPurchases", false);
root.activeView = "purchasesMain";
root.installedApps = Commerce.getInstalledApps();
Commerce.inventory();
Commerce.getAvailableUpdates();
getPurchases();
break;
}
}
@ -528,7 +505,7 @@ Rectangle {
},
{
"displayName": "Content Set",
"filterName": "contentSet"
"filterName": "content_set"
},
{
"displayName": "Entity",
@ -540,7 +517,7 @@ Rectangle {
},
{
"displayName": "Updatable",
"filterName": "updatable"
"filterName": "updated"
}
]
filterBar.primaryFilterChoices.clear();
@ -548,14 +525,12 @@ Rectangle {
}
onPrimaryFilter_displayNameChanged: {
buildFilteredPurchasesModel();
purchasesContentsList.positionViewAtIndex(0, ListView.Beginning)
purchasesModel.tagsFilter = filterBar.primaryFilter_filterName;
filterBar.previousPrimaryFilter = filterBar.primaryFilter_displayName;
}
onTextChanged: {
buildFilteredPurchasesModel();
purchasesContentsList.positionViewAtIndex(0, ListView.Beginning)
purchasesModel.searchFilter = filterBar.text;
filterBar.previousText = filterBar.text;
}
}
@ -574,24 +549,41 @@ Rectangle {
anchors.topMargin: 16;
}
ListModel {
HifiModels.PSFListModel {
id: purchasesModel;
}
ListModel {
id: previousPurchasesModel;
}
HifiCommerceCommon.SortableListModel {
id: tempPurchasesModel;
}
HifiCommerceCommon.SortableListModel {
id: filteredPurchasesModel;
itemsPerPage: 6;
listModelName: 'purchases';
getPage: function () {
console.debug('getPage', purchasesModel.listModelName, root.isShowingMyItems, filterBar.primaryFilter_filterName, purchasesModel.currentPageToRetrieve, purchasesModel.itemsPerPage);
Commerce.inventory(
root.isShowingMyItems ? "proofs" : "purchased",
filterBar.primaryFilter_filterName,
filterBar.text,
purchasesModel.currentPageToRetrieve,
purchasesModel.itemsPerPage
);
}
processPage: function(data) {
purchasesReceived = true; // HRS FIXME?
data.assets.forEach(function (item) {
if (item.status.length > 1) { console.warn("Unrecognized inventory status", item); }
item.status = item.status[0];
item.categories = item.categories.join(';');
item.cardBackVisible = false;
item.isInstalled = root.installedApps.indexOf(item.id) > -1;
item.wornEntityID = '';
});
sendToScript({ method: 'purchases_updateWearables' });
return data.assets;
}
}
ListView {
id: purchasesContentsList;
visible: (root.isShowingMyItems && filteredPurchasesModel.count !== 0) || (!root.isShowingMyItems && filteredPurchasesModel.count !== 0);
visible: purchasesModel.count !== 0;
clip: true;
model: filteredPurchasesModel;
model: purchasesModel;
snapMode: ListView.SnapToItem;
// Anchors
anchors.top: separator.bottom;
@ -608,13 +600,13 @@ Rectangle {
itemEdition: model.edition_number;
numberSold: model.number_sold;
limitedRun: model.limited_run;
displayedItemCount: model.displayedItemCount;
cardBackVisible: model.cardBackVisible;
isInstalled: model.isInstalled;
displayedItemCount: 999; // For now (and maybe longer), we're going to display all the edition numbers.
cardBackVisible: model.cardBackVisible || false;
isInstalled: model.isInstalled || false;
wornEntityID: model.wornEntityID;
upgradeUrl: model.upgrade_url;
upgradeTitle: model.upgrade_title;
itemType: model.itemType;
itemType: model.item_type;
isShowingMyItems: root.isShowingMyItems;
valid: model.valid;
anchors.topMargin: 10;
@ -706,11 +698,11 @@ Rectangle {
} else if (msg.method === "setFilterText") {
filterBar.text = msg.filterText;
} else if (msg.method === "flipCard") {
for (var i = 0; i < filteredPurchasesModel.count; i++) {
for (var i = 0; i < purchasesModel.count; i++) {
if (i !== index || msg.closeAll) {
filteredPurchasesModel.setProperty(i, "cardBackVisible", false);
purchasesModel.setProperty(i, "cardBackVisible", false);
} else {
filteredPurchasesModel.setProperty(i, "cardBackVisible", true);
purchasesModel.setProperty(i, "cardBackVisible", true);
}
}
} else if (msg.method === "updateItemClicked") {
@ -761,7 +753,7 @@ Rectangle {
lightboxPopup.button2text = "CONFIRM";
lightboxPopup.button2method = function() {
Entities.deleteEntity(msg.wornEntityID);
filteredPurchasesModel.setProperty(index, 'wornEntityID', '');
purchasesModel.setProperty(index, 'wornEntityID', '');
root.activeView = "giftAsset";
lightboxPopup.visible = false;
};
@ -773,6 +765,14 @@ Rectangle {
}
}
}
onAtYEndChanged: {
if (purchasesContentsList.atYEnd && !purchasesContentsList.atYBeginning) {
console.log("User scrolled to the bottom of 'Purchases'.");
purchasesModel.getNextPage();
}
}
}
Rectangle {
@ -953,146 +953,14 @@ Rectangle {
//
// FUNCTION DEFINITIONS START
//
function processInventoryResult(inventory) {
for (var i = 0; i < inventory.length; i++) {
if (inventory[i].status.length > 1) {
console.log("WARNING: Inventory result index " + i + " has a status of length >1!")
}
inventory[i].status = inventory[i].status[0];
inventory[i].categories = inventory[i].categories.join(';');
}
return inventory;
}
function populateDisplayedItemCounts() {
var itemCountDictionary = {};
var currentItemId;
for (var i = 0; i < filteredPurchasesModel.count; i++) {
currentItemId = filteredPurchasesModel.get(i).id;
if (itemCountDictionary[currentItemId] === undefined) {
itemCountDictionary[currentItemId] = 1;
} else {
itemCountDictionary[currentItemId]++;
}
}
for (var i = 0; i < filteredPurchasesModel.count; i++) {
filteredPurchasesModel.setProperty(i, "displayedItemCount", itemCountDictionary[filteredPurchasesModel.get(i).id]);
}
}
function sortByDate() {
filteredPurchasesModel.sortColumnName = "purchase_date";
filteredPurchasesModel.isSortingDescending = true;
filteredPurchasesModel.valuesAreNumerical = true;
filteredPurchasesModel.quickSort();
}
function buildFilteredPurchasesModel() {
var sameItemCount = 0;
tempPurchasesModel.clear();
for (var i = 0; i < purchasesModel.count; i++) {
if (purchasesModel.get(i).title.toLowerCase().indexOf(filterBar.text.toLowerCase()) !== -1) {
if (purchasesModel.get(i).status !== "confirmed" && !root.isShowingMyItems) {
tempPurchasesModel.insert(0, purchasesModel.get(i));
} else if ((root.isShowingMyItems && purchasesModel.get(i).edition_number === "0") ||
(!root.isShowingMyItems && purchasesModel.get(i).edition_number !== "0")) {
tempPurchasesModel.append(purchasesModel.get(i));
}
}
}
// primaryFilter filtering and adding of itemType property to model
var currentItemType, currentRootFileUrl, currentCategories;
for (var i = 0; i < tempPurchasesModel.count; i++) {
currentRootFileUrl = tempPurchasesModel.get(i).root_file_url;
currentCategories = tempPurchasesModel.get(i).categories;
if (currentRootFileUrl.indexOf(".fst") > -1) {
currentItemType = "avatar";
} else if (currentCategories.indexOf("Wearables") > -1) {
currentItemType = "wearable";
} else if (currentRootFileUrl.endsWith('.json.gz') || currentRootFileUrl.endsWith('.content.zip')) {
currentItemType = "contentSet";
} else if (currentRootFileUrl.endsWith('.app.json')) {
currentItemType = "app";
} else if (currentRootFileUrl.endsWith('.json')) {
currentItemType = "entity";
} else {
currentItemType = "unknown";
}
if (filterBar.primaryFilter_displayName !== "" &&
((filterBar.primaryFilter_displayName === "Updatable" && tempPurchasesModel.get(i).upgrade_url === "") ||
(filterBar.primaryFilter_displayName !== "Updatable" && filterBar.primaryFilter_filterName.toLowerCase() !== currentItemType.toLowerCase()))) {
tempPurchasesModel.remove(i);
i--;
} else {
tempPurchasesModel.setProperty(i, 'itemType', currentItemType);
}
}
for (var i = 0; i < tempPurchasesModel.count; i++) {
if (!filteredPurchasesModel.get(i)) {
sameItemCount = -1;
break;
} else if (tempPurchasesModel.get(i).itemId === filteredPurchasesModel.get(i).itemId &&
tempPurchasesModel.get(i).edition_number === filteredPurchasesModel.get(i).edition_number &&
tempPurchasesModel.get(i).status === filteredPurchasesModel.get(i).status) {
sameItemCount++;
}
}
if (sameItemCount !== tempPurchasesModel.count ||
filterBar.text !== filterBar.previousText ||
filterBar.primaryFilter !== filterBar.previousPrimaryFilter) {
filteredPurchasesModel.clear();
var currentId;
for (var i = 0; i < tempPurchasesModel.count; i++) {
currentId = tempPurchasesModel.get(i).id;
filteredPurchasesModel.append(tempPurchasesModel.get(i));
filteredPurchasesModel.setProperty(i, 'cardBackVisible', false);
filteredPurchasesModel.setProperty(i, 'isInstalled', ((root.installedApps).indexOf(currentId) > -1));
filteredPurchasesModel.setProperty(i, 'wornEntityID', '');
}
sendToScript({ method: 'purchases_updateWearables' });
populateDisplayedItemCounts();
sortByDate();
}
}
function checkIfAnyItemStatusChanged() {
var currentPurchasesModelId, currentPurchasesModelEdition, currentPurchasesModelStatus;
var previousPurchasesModelStatus;
for (var i = 0; i < purchasesModel.count; i++) {
currentPurchasesModelId = purchasesModel.get(i).id;
currentPurchasesModelEdition = purchasesModel.get(i).edition_number;
currentPurchasesModelStatus = purchasesModel.get(i).status;
for (var j = 0; j < previousPurchasesModel.count; j++) {
previousPurchasesModelStatus = previousPurchasesModel.get(j).status;
if (currentPurchasesModelId === previousPurchasesModel.get(j).id &&
currentPurchasesModelEdition === previousPurchasesModel.get(j).edition_number &&
currentPurchasesModelStatus !== previousPurchasesModelStatus) {
purchasesModel.setProperty(i, "statusChanged", true);
} else {
purchasesModel.setProperty(i, "statusChanged", false);
}
}
}
}
function updateCurrentlyWornWearables(wearables) {
for (var i = 0; i < filteredPurchasesModel.count; i++) {
for (var i = 0; i < purchasesModel.count; i++) {
for (var j = 0; j < wearables.length; j++) {
if (filteredPurchasesModel.get(i).itemType === "wearable" &&
wearables[j].entityCertID === filteredPurchasesModel.get(i).certificate_id &&
wearables[j].entityEdition.toString() === filteredPurchasesModel.get(i).edition_number) {
filteredPurchasesModel.setProperty(i, 'wornEntityID', wearables[j].entityID);
if (purchasesModel.get(i).itemType === "wearable" &&
wearables[j].entityCertID === purchasesModel.get(i).certificate_id &&
wearables[j].entityEdition.toString() === purchasesModel.get(i).edition_number) {
purchasesModel.setProperty(i, 'wornEntityID', wearables[j].entityID);
break;
}
}
@ -1149,7 +1017,7 @@ Rectangle {
switch (message.method) {
case 'updatePurchases':
referrerURL = message.referrerURL || "";
titleBarContainer.referrerURL = message.referrerURL;
titleBarContainer.referrerURL = message.referrerURL || "";
filterBar.text = message.filterText ? message.filterText : "";
break;
case 'inspectionCertificate_setCertificateId':
@ -1168,6 +1036,9 @@ Rectangle {
case 'updateWearables':
updateCurrentlyWornWearables(message.wornWearables);
break;
case 'http.response':
http.handleHttpResponse(message);
break;
default:
console.log('Unrecognized message from marketplaces.js:', JSON.stringify(message));
}

View file

@ -19,6 +19,7 @@ import "../../../controls-uit" as HifiControlsUit
import "../../../controls" as HifiControls
import "../common" as HifiCommerceCommon
import "../common/sendAsset"
import "../.." as HifiCommon
Rectangle {
HifiConstants { id: hifi; }
@ -343,8 +344,14 @@ Rectangle {
}
}
HifiCommon.RootHttpRequest {
id: http;
}
SendAsset {
id: sendMoney;
http: http;
listModelName: "Send Money Connections";
z: 997;
visible: root.activeView === "sendMoney";
anchors.fill: parent;
@ -768,6 +775,13 @@ Rectangle {
case 'updateSelectedRecipientUsername':
sendMoney.fromScript(message);
break;
case 'http.response':
http.handleHttpResponse(message);
break;
case 'palIsStale':
case 'avatarDisconnected':
// Because we don't have "channels" for sending messages to a specific QML object, the messages are broadcast to all QML Items. If an Item of yours happens to be visible when some script sends a message with a method you don't expect, you'll get "Unrecognized message..." logs.
break;
default:
console.log('Unrecognized message from wallet.js:', JSON.stringify(message));
}

View file

@ -18,27 +18,17 @@ import QtQuick.Controls 2.2
import "../../../styles-uit"
import "../../../controls-uit" as HifiControlsUit
import "../../../controls" as HifiControls
import "qrc:////qml//hifi//models" as HifiModels // Absolute path so the same code works everywhere.
Item {
HifiConstants { id: hifi; }
id: root;
property bool initialHistoryReceived: false;
property bool historyRequestPending: true;
property bool noMoreHistoryData: false;
property int pendingCount: 0;
property int currentHistoryPage: 1;
property var pagesAlreadyAdded: new Array();
onVisibleChanged: {
if (visible) {
transactionHistoryModel.clear();
Commerce.balance();
initialHistoryReceived = false;
root.currentHistoryPage = 1;
root.noMoreHistoryData = false;
root.historyRequestPending = true;
Commerce.history(root.currentHistoryPage);
transactionHistoryModel.getFirstPage();
Commerce.getAvailableUpdates();
} else {
refreshTimer.stop();
@ -53,86 +43,7 @@ Item {
}
onHistoryResult : {
root.initialHistoryReceived = true;
root.historyRequestPending = false;
if (result.status === 'success') {
var currentPage = parseInt(result.current_page);
if (result.data.history.length === 0) {
root.noMoreHistoryData = true;
console.log("No more data to retrieve from Commerce.history() endpoint.")
} else if (root.currentHistoryPage === 1) {
var sameItemCount = 0;
tempTransactionHistoryModel.clear();
tempTransactionHistoryModel.append(result.data.history);
for (var i = 0; i < tempTransactionHistoryModel.count; i++) {
if (!transactionHistoryModel.get(i)) {
sameItemCount = -1;
break;
} else if (tempTransactionHistoryModel.get(i).transaction_type === transactionHistoryModel.get(i).transaction_type &&
tempTransactionHistoryModel.get(i).text === transactionHistoryModel.get(i).text) {
sameItemCount++;
}
}
if (sameItemCount !== tempTransactionHistoryModel.count) {
transactionHistoryModel.clear();
for (var i = 0; i < tempTransactionHistoryModel.count; i++) {
transactionHistoryModel.append(tempTransactionHistoryModel.get(i));
}
calculatePendingAndInvalidated();
}
} else {
if (root.pagesAlreadyAdded.indexOf(currentPage) !== -1) {
console.log("Page " + currentPage + " of history has already been added to the list.");
} else {
// First, add the history result to a temporary model
tempTransactionHistoryModel.clear();
tempTransactionHistoryModel.append(result.data.history);
// Make a note that we've already added this page to the model...
root.pagesAlreadyAdded.push(currentPage);
var insertionIndex = 0;
// If there's nothing in the model right now, we don't need to modify insertionIndex.
if (transactionHistoryModel.count !== 0) {
var currentIteratorPage;
// Search through the whole transactionHistoryModel and look for the insertion point.
// The insertion point is found when the result page from the server is less than
// the page that the current item came from, OR when we've reached the end of the whole model.
for (var i = 0; i < transactionHistoryModel.count; i++) {
currentIteratorPage = transactionHistoryModel.get(i).resultIsFromPage;
if (currentPage < currentIteratorPage) {
insertionIndex = i;
break;
} else if (i === transactionHistoryModel.count - 1) {
insertionIndex = i + 1;
break;
}
}
}
// Go through the results we just got back from the server, setting the "resultIsFromPage"
// property of those results and adding them to the main model.
for (var i = 0; i < tempTransactionHistoryModel.count; i++) {
tempTransactionHistoryModel.setProperty(i, "resultIsFromPage", currentPage);
transactionHistoryModel.insert(i + insertionIndex, tempTransactionHistoryModel.get(i))
}
calculatePendingAndInvalidated();
}
}
}
// Only auto-refresh if the user hasn't scrolled
// and there is more data to grab
if (transactionHistory.atYBeginning && !root.noMoreHistoryData) {
refreshTimer.start();
}
transactionHistoryModel.handlePage(null, result);
}
onAvailableUpdatesResult: {
@ -147,7 +58,7 @@ Item {
Connections {
target: GlobalServices
onMyUsernameChanged: {
transactionHistoryModel.clear();
transactionHistoryModel.resetModel();
usernameText.text = Account.username;
}
}
@ -235,9 +146,8 @@ Item {
onTriggered: {
if (transactionHistory.atYBeginning) {
console.log("Refreshing 1st Page of Recent Activity...");
root.historyRequestPending = true;
Commerce.balance();
Commerce.history(1);
transactionHistoryModel.getFirstPage("delayedClear");
}
}
}
@ -299,11 +209,42 @@ Item {
}
}
ListModel {
id: tempTransactionHistoryModel;
}
ListModel {
HifiModels.PSFListModel {
id: transactionHistoryModel;
listModelName: "transaction history"; // For debugging. Alternatively, we could specify endpoint for that purpose, even though it's not used directly.
itemsPerPage: 6;
getPage: function () {
console.debug('getPage', transactionHistoryModel.listModelName, transactionHistoryModel.currentPageToRetrieve);
Commerce.history(transactionHistoryModel.currentPageToRetrieve, transactionHistoryModel.itemsPerPage);
}
processPage: function (data) {
console.debug('processPage', transactionHistoryModel.listModelName, JSON.stringify(data));
var result, pending; // Set up or get the accumulator for pending.
if (transactionHistoryModel.currentPageToRetrieve == 1) {
pending = {transaction_type: "pendingCount", count: 0};
result = [pending];
} else {
pending = transactionHistoryModel.get(0);
result = [];
}
// Either add to pending, or to result.
// Note that you only see a page of pending stuff until you scroll...
data.history.forEach(function (item) {
if (item.status === 'pending') {
pending.count++;
} else {
result = result.concat(item);
}
});
// Only auto-refresh if the user hasn't scrolled
// and there is more data to grab
if (transactionHistory.atYBeginning && data.history.length) {
refreshTimer.start();
}
return result;
}
}
Item {
anchors.top: recentActivityText.bottom;
@ -312,8 +253,8 @@ Item {
anchors.left: parent.left;
anchors.right: parent.right;
Item {
visible: transactionHistoryModel.count === 0 && root.initialHistoryReceived;
Item { // On empty history. We don't want to flash and then replace, so don't show until we know we're zero.
visible: transactionHistoryModel.count === 0 && transactionHistoryModel.currentPageToRetrieve < 0;
anchors.centerIn: parent;
width: parent.width - 12;
height: parent.height;
@ -385,10 +326,10 @@ Item {
model: transactionHistoryModel;
delegate: Item {
width: parent.width;
height: (model.transaction_type === "pendingCount" && root.pendingCount !== 0) ? 40 : ((model.status === "confirmed" || model.status === "invalidated") ? transactionText.height + 30 : 0);
height: (model.transaction_type === "pendingCount" && model.count !== 0) ? 40 : ((model.status === "confirmed" || model.status === "invalidated") ? transactionText.height + 30 : 0);
Item {
visible: model.transaction_type === "pendingCount" && root.pendingCount !== 0;
visible: model.transaction_type === "pendingCount" && model.count !== 0;
anchors.top: parent.top;
anchors.left: parent.left;
width: parent.width;
@ -397,7 +338,7 @@ Item {
AnonymousProRegular {
id: pendingCountText;
anchors.fill: parent;
text: root.pendingCount + ' Transaction' + (root.pendingCount > 1 ? 's' : '') + ' Pending';
text: model.count + ' Transaction' + (model.count > 1 ? 's' : '') + ' Pending';
size: 18;
color: hifi.colors.blueAccent;
verticalAlignment: Text.AlignVCenter;
@ -460,14 +401,9 @@ Item {
}
}
onAtYEndChanged: {
if (transactionHistory.atYEnd) {
if (transactionHistory.atYEnd && !transactionHistory.atYBeginning) {
console.log("User scrolled to the bottom of 'Recent Activity'.");
if (!root.historyRequestPending && !root.noMoreHistoryData) {
// Grab next page of results and append to model
root.historyRequestPending = true;
Commerce.history(++root.currentHistoryPage);
console.log("Fetching Page " + root.currentHistoryPage + " of Recent Activity...");
}
transactionHistoryModel.getNextPage();
}
}
}
@ -506,40 +442,6 @@ Item {
return year + '-' + month + '-' + day + '<br>' + drawnHour + ':' + min + amOrPm;
}
function calculatePendingAndInvalidated(startingPendingCount) {
var pendingCount = startingPendingCount ? startingPendingCount : 0;
for (var i = 0; i < transactionHistoryModel.count; i++) {
if (transactionHistoryModel.get(i).status === "pending") {
pendingCount++;
}
}
root.pendingCount = pendingCount;
if (pendingCount > 0) {
transactionHistoryModel.insert(0, {"transaction_type": "pendingCount"});
}
}
//
// Function Name: fromScript()
//
// Relevant Variables:
// None
//
// Arguments:
// message: The message sent from the JavaScript.
// Messages are in format "{method, params}", like json-rpc.
//
// Description:
// Called when a message is received from a script.
//
function fromScript(message) {
switch (message.method) {
default:
console.log('Unrecognized message from wallet.js:', JSON.stringify(message));
}
}
signal sendSignalToWallet(var msg);
//

View file

@ -0,0 +1,168 @@
//
// PSFListModel.qml
// qml/hifi/commerce/common
//
// PSFListModel
// "PSF" stands for:
// - Paged
// - Sortable
// - Filterable
//
// Created by Zach Fox on 2018-05-15
// Copyright 2018 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
import QtQuick 2.7
ListModel {
id: root;
// Used when printing debug statements
property string listModelName: endpoint;
// Parameters. Even if you override getPage, below, please set these for clarity and consistency, when applicable.
// E.g., your getPage function could refer to this sortKey, etc.
property string endpoint;
property string sortProperty; // Currently only handles sorting on one column, which fits with current needs and tables.
property bool sortAscending;
property string sortKey: !sortProperty ? '' : (sortProperty + "," + (sortAscending ? "ASC" : "DESC"));
property string searchFilter: "";
property string tagsFilter;
// QML fires the following changed handlers even when first instantiating the Item. So we need a guard against firing them too early.
property bool initialized: false;
Component.onCompleted: initialized = true;
onEndpointChanged: if (initialized) { getFirstPage('delayClear'); }
onSortKeyChanged: if (initialized) { getFirstPage('delayClear'); }
onSearchFilterChanged: if (initialized) { getFirstPage('delayClear'); }
onTagsFilterChanged: if (initialized) { getFirstPage('delayClear'); }
property int itemsPerPage: 100;
// State.
property int currentPageToRetrieve: 0; // 0 = before first page. -1 = we have them all. Otherwise 1-based page number.
property bool retrievedAtLeastOnePage: false;
// We normally clear on reset. But if we want to "refresh", we can delay clearing the model until we get a result.
// Not normally set directly, but rather by giving a truthy argument to getFirstPage(true);
property bool delayedClear: false;
function resetModel() {
if (!delayedClear) { root.clear(); }
currentPageToRetrieve = 1;
retrievedAtLeastOnePage = false;
}
// Page processing.
// Override to return one property of data, and/or to transform the elements. Must return an array of model elements.
property var processPage: function (data) { return data; }
property var listView; // Optional. For debugging.
// Check consistency and call processPage.
function handlePage(error, response) {
var processed;
console.debug('handlePage', listModelName, additionalFirstPageRequested, error, JSON.stringify(response));
function fail(message) {
console.warn("Warning page fail", listModelName, JSON.stringify(message));
currentPageToRetrieve = -1;
requestPending = false;
delayedClear = false;
}
if (error || (response.status !== 'success')) {
return fail(error || response.status);
}
if (!requestPending) {
return fail("No request in flight.");
}
requestPending = false;
if (response.current_page && response.current_page !== currentPageToRetrieve) { // Not all endpoints specify this property.
return fail("Mismatched page, expected:" + currentPageToRetrieve);
}
processed = processPage(response.data || response);
if (response.total_pages && (response.total_pages === currentPageToRetrieve)) {
currentPageToRetrieve = -1;
}
if (delayedClear) {
root.clear();
delayedClear = false;
}
root.append(processed); // FIXME keep index steady, and apply any post sort
retrievedAtLeastOnePage = true;
// Suppose two properties change at once, and both of their change handlers request a new first page.
// (An example is when the a filter box gets cleared with text in it, so that the search and tags are both reset.)
// Or suppose someone just types new search text quicker than the server response.
// In these cases, we would have multiple requests in flight, and signal based responses aren't generally very good
// at matching up the right handler with the right message. Rather than require all the APIs to carefully handle such,
// and also to cut down on useless requests, we take care of that case here.
if (additionalFirstPageRequested) {
console.debug('deferred getFirstPage', listModelName);
additionalFirstPageRequested = false;
getFirstPage('delayedClear');
}
}
function debugView(label) {
if (!listView) { return; }
console.debug(label, listModelName, 'perPage:', itemsPerPage, 'count:', listView.count,
'index:', listView.currentIndex, 'section:', listView.currentSection,
'atYBeginning:', listView.atYBeginning, 'atYEnd:', listView.atYEnd,
'y:', listView.y, 'contentY:', listView.contentY);
}
// Override either http or getPage.
property var http; // An Item that has a request function.
property var getPage: function () { // Any override MUST call handlePage(), above, even if results empty.
if (!http) { return console.warn("Neither http nor getPage was set for", listModelName); }
// If it is a path starting with slash, add the metaverseServer domain.
var url = /^\//.test(endpoint) ? (Account.metaverseServerURL + endpoint) : endpoint;
var parameters = [
'per_page=' + itemsPerPage,
'page=' + currentPageToRetrieve
];
if (searchFilter) {
parameters.splice(parameters.length, 0, 'search=' + searchFilter);
}
if (sortKey) {
parameters.splice(parameters.length, 0, 'sort=' + sortKey);
}
var parametersSeparator = /\?/.test(url) ? '&' : '?';
url = url + parametersSeparator + parameters.join('&');
console.debug('getPage', listModelName, currentPageToRetrieve);
http.request({uri: url}, handlePage);
}
// Start the show by retrieving data according to `getPage()`.
// It can be custom-defined by this item's Parent.
property var getFirstPage: function (delayClear) {
if (requestPending) {
console.debug('deferring getFirstPage', listModelName);
additionalFirstPageRequested = true;
return;
}
delayedClear = !!delayClear;
resetModel();
requestPending = true;
console.debug("getFirstPage", listModelName, currentPageToRetrieve);
getPage();
}
property bool additionalFirstPageRequested: false;
property bool requestPending: false; // For de-bouncing getNextPage.
// This function, will get the _next_ page of data according to `getPage()`.
// It can be custom-defined by this item's Parent. Typical usage:
// ListView {
// id: theList
// model: thisPSFListModelId
// onAtYEndChanged: if (theList.atYEnd && !theList.atYBeginning) { thisPSFListModelId.getNextPage(); }
// ...}
property var getNextPage: function () {
if (requestPending || currentPageToRetrieve < 0) {
return;
}
currentPageToRetrieve++;
console.debug("getNextPage", listModelName, currentPageToRetrieve);
requestPending = true;
getPage();
}
}

View file

@ -34,41 +34,16 @@ StackView {
height: parent !== null ? parent.height : undefined
property int cardWidth: 212;
property int cardHeight: 152;
property string metaverseBase: addressBarDialog.metaverseServerUrl + "/api/v1/";
property var tablet: null;
// This version only implements rpc(method, parameters, callback(error, result)) calls initiated from here, not initiated from .js, nor "notifications".
property var rpcCalls: ({});
property var rpcCounter: 0;
RootHttpRequest { id: http; }
signal sendToScript(var message);
function rpc(method, parameters, callback) {
console.debug('TabletAddressDialog: rpc: method = ', method, 'parameters = ', parameters, 'callback = ', callback)
rpcCalls[rpcCounter] = callback;
var message = {method: method, params: parameters, id: rpcCounter++, jsonrpc: "2.0"};
sendToScript(message);
}
function fromScript(message) {
if (message.method === 'refreshFeeds') {
var feeds = [happeningNow, places, snapshots];
console.debug('TabletAddressDialog::fromScript: refreshFeeds', 'feeds = ', feeds);
feeds.forEach(function(feed) {
feed.protocol = encodeURIComponent(message.protocolSignature);
Qt.callLater(feed.fillDestinations);
});
return;
switch (message.method) {
case 'http.response':
http.handleHttpResponse(message);
break;
}
var callback = rpcCalls[message.id];
if (!callback) {
// FIXME: We often recieve very long messages here, the logging of which is drastically slowing down the main thread
//console.log('No callback for message fromScript', JSON.stringify(message));
return;
}
delete rpcCalls[message.id];
callback(message.error, message.result);
}
Component { id: tabletWebView; TabletWebView {} }
@ -346,12 +321,11 @@ StackView {
width: parent.width;
cardWidth: 312 + (2 * 4);
cardHeight: 163 + (2 * 4);
metaverseServerUrl: addressBarDialog.metaverseServerUrl;
labelText: 'HAPPENING NOW';
actions: 'announcement';
filter: addressLine.text;
goFunction: goCard;
rpc: root.rpc;
http: http;
}
Feed {
id: places;
@ -359,12 +333,11 @@ StackView {
cardWidth: 210;
cardHeight: 110 + messageHeight;
messageHeight: 44;
metaverseServerUrl: addressBarDialog.metaverseServerUrl;
labelText: 'PLACES';
actions: 'concurrency';
filter: addressLine.text;
goFunction: goCard;
rpc: root.rpc;
http: http;
}
Feed {
id: snapshots;
@ -373,12 +346,11 @@ StackView {
cardHeight: 75 + messageHeight + 4;
messageHeight: 32;
textPadding: 6;
metaverseServerUrl: addressBarDialog.metaverseServerUrl;
labelText: 'RECENT SNAPS';
actions: 'snapshot';
filter: addressLine.text;
goFunction: goCard;
rpc: root.rpc;
http: http;
}
}
}

View file

@ -134,8 +134,14 @@ void Ledger::balance(const QStringList& keys) {
keysQuery("balance", "balanceSuccess", "balanceFailure");
}
void Ledger::inventory(const QStringList& keys) {
keysQuery("inventory", "inventorySuccess", "inventoryFailure");
void Ledger::inventory(const QString& editionFilter, const QString& typeFilter, const QString& titleFilter, const int& page, const int& perPage) {
QJsonObject params;
params["edition_filter"] = editionFilter;
params["type_filter"] = typeFilter;
params["title_filter"] = titleFilter;
params["page"] = page;
params["per_page"] = perPage;
keysQuery("inventory", "inventorySuccess", "inventoryFailure", params);
}
QString hfcString(const QJsonValue& sentValue, const QJsonValue& receivedValue) {
@ -260,9 +266,9 @@ void Ledger::historyFailure(QNetworkReply& reply) {
failResponse("history", reply);
}
void Ledger::history(const QStringList& keys, const int& pageNumber) {
void Ledger::history(const QStringList& keys, const int& pageNumber, const int& itemsPerPage) {
QJsonObject params;
params["per_page"] = 100;
params["per_page"] = itemsPerPage;
params["page"] = pageNumber;
keysQuery("history", "historySuccess", "historyFailure", params);
}

View file

@ -28,8 +28,8 @@ public:
void buy(const QString& hfc_key, int cost, const QString& asset_id, const QString& inventory_key, const bool controlled_failure = false);
bool receiveAt(const QString& hfc_key, const QString& signing_key);
void balance(const QStringList& keys);
void inventory(const QStringList& keys);
void history(const QStringList& keys, const int& pageNumber);
void inventory(const QString& editionFilter, const QString& typeFilter, const QString& titleFilter, const int& page, const int& perPage);
void history(const QStringList& keys, const int& pageNumber, const int& itemsPerPage);
void account();
void updateLocation(const QString& asset_id, const QString& location, const bool& alsoUpdateSiblings = false, const bool controlledFailure = false);
void certificateInfo(const QString& certificateId);

View file

@ -105,21 +105,21 @@ void QmlCommerce::balance() {
}
}
void QmlCommerce::inventory() {
void QmlCommerce::inventory(const QString& editionFilter, const QString& typeFilter, const QString& titleFilter, const int& page, const int& perPage) {
auto ledger = DependencyManager::get<Ledger>();
auto wallet = DependencyManager::get<Wallet>();
QStringList cachedPublicKeys = wallet->listPublicKeys();
if (!cachedPublicKeys.isEmpty()) {
ledger->inventory(cachedPublicKeys);
ledger->inventory(editionFilter, typeFilter, titleFilter, page, perPage);
}
}
void QmlCommerce::history(const int& pageNumber) {
void QmlCommerce::history(const int& pageNumber, const int& itemsPerPage) {
auto ledger = DependencyManager::get<Ledger>();
auto wallet = DependencyManager::get<Wallet>();
QStringList cachedPublicKeys = wallet->listPublicKeys();
if (!cachedPublicKeys.isEmpty()) {
ledger->history(cachedPublicKeys, pageNumber);
ledger->history(cachedPublicKeys, pageNumber, itemsPerPage);
}
}

View file

@ -73,8 +73,8 @@ protected:
Q_INVOKABLE void buy(const QString& assetId, int cost, const bool controlledFailure = false);
Q_INVOKABLE void balance();
Q_INVOKABLE void inventory();
Q_INVOKABLE void history(const int& pageNumber);
Q_INVOKABLE void inventory(const QString& editionFilter = QString(), const QString& typeFilter = QString(), const QString& titleFilter = QString(), const int& page = 1, const int& perPage = 20);
Q_INVOKABLE void history(const int& pageNumber, const int& itemsPerPage = 100);
Q_INVOKABLE void generateKeyPair();
Q_INVOKABLE void account();

View file

@ -12,6 +12,7 @@
//
var DEFAULT_SCRIPTS_COMBINED = [
"system/request-service.js",
"system/progress.js",
"system/away.js",
"system/audio.js",

View file

@ -511,6 +511,9 @@
case 'wallet_availableUpdatesReceived':
// NOP
break;
case 'http.request':
// Handled elsewhere, don't log.
break;
default:
print('Unrecognized message from QML:', JSON.stringify(message));
}

View file

@ -250,7 +250,7 @@
});
}
function buyButtonClicked(id, name, author, price, href, referrer, edition) {
function buyButtonClicked(id, name, author, price, href, referrer, edition, type) {
EventBridge.emitWebEvent(JSON.stringify({
type: "CHECKOUT",
itemId: id,
@ -259,7 +259,8 @@
itemHref: href,
referrer: referrer,
itemAuthor: author,
itemEdition: edition
itemEdition: edition,
itemType: type.trim()
}));
}
@ -328,7 +329,8 @@
$(this).closest('.grid-item').find('.item-cost').text(),
$(this).attr('data-href'),
"mainPage",
-1);
-1,
$(this).closest('.grid-item').find('.item-type').text());
});
}
@ -419,6 +421,7 @@
}
var cost = $('.item-cost').text();
var type = $('.item-type').text();
var isUpdating = window.location.href.indexOf('edition=') > -1;
var urlParams = new URLSearchParams(window.location.search);
if (isUpdating) {
@ -438,7 +441,8 @@
cost,
href,
"itemPage",
urlParams.get('edition'));
urlParams.get('edition'),
type);
}
});
maybeAddPurchasesButton();

View file

@ -988,6 +988,11 @@ var selectionDisplay = null; // for gridTool.js to ignore
sendAssetParticleEffectUpdateTimer = Script.setInterval(updateSendAssetParticleEffect, SEND_ASSET_PARTICLE_TIMER_UPDATE);
}
break;
case 'http.request':
// Handled elsewhere, don't log.
break;
case 'goToPurchases_fromWalletHome': // HRS FIXME What's this about?
break;
default:
print('Unrecognized message from Checkout.qml or Purchases.qml: ' + JSON.stringify(message));
}

View file

@ -317,6 +317,8 @@ function fromQml(message) { // messages are {method, params}, like json-rpc. See
}
);
break;
case 'http.request':
break; // Handled by request-service.
default:
print('Unrecognized message from Pal.qml:', JSON.stringify(message));
}

View file

@ -0,0 +1,48 @@
"use strict";
//
// request-service.js
//
// Created by Howard Stearns on May 22, 2018
// Copyright 2018 High Fidelity, Inc
//
// Distributed under the Apache License, Version 2.0
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
(function() { // BEGIN LOCAL_SCOPE
// QML has its own XMLHttpRequest, but:
// - npm request is easier to use.
// - It is not easy to hack QML's XMLHttpRequest to use our MetaverseServer, and to supply the user's auth when contacting it.
// a. Our custom XMLHttpRequestClass object only works with QScriptEngine, not QML's javascript.
// b. We have hacked profiles that intercept requests to our MetavserseServer (providing the correct auth), but those
// only work in QML WebEngineView. Setting up communication between ordinary QML and a hiddent WebEngineView is
// tantamount to the following anyway, and would still have to duplicate the code from request.js.
// So, this script does two things:
// 1. Allows any root .qml to signal sendToScript({id: aString, method: 'http.request', params: byNameOptions})
// We will then asynchonously call fromScript({id: theSameString, method: 'http.response', error: errorOrFalsey, response: body})
// on that root object.
// RootHttpRequest.qml does this.
// 2. If the uri used (computed from byNameOptions, see request.js) is to our metaverse, we will use the appropriate auth.
var request = Script.require('request').request;
var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
function fromQml(message) { // messages are {id, method, params}, like json-rpc. See also sendToQml.
switch (message.method) {
case 'http.request':
request(message.params, function (error, response) {
tablet.sendToQml({
id: message.id,
method: 'http.response',
error: error, // Alas, this isn't always a JSON-RPC conforming error object.
response: response,
jsonrpc: '2.0'
});
});
break;
}
}
tablet.fromQml.connect(fromQml);
Script.scriptEnding.connect(function () { tablet.fromQml.disconnect(fromQml); });
}()); // END LOCAL_SCOPE

View file

@ -41,24 +41,6 @@
sortOrder: 8
});
function fromQml(message) {
console.debug('tablet-goto::fromQml: message = ', JSON.stringify(message));
var response = {id: message.id, jsonrpc: "2.0"};
switch (message.method) {
case 'request':
request(message.params, function (error, data) {
debug('rpc', request, 'error:', error, 'data:', data);
response.error = error;
response.result = data;
tablet.sendToQml(response);
});
return;
default:
response.error = {message: 'Unrecognized message', data: message};
}
tablet.sendToQml(response);
}
function messagesWaiting(isWaiting) {
button.editProperties({
icon: isWaiting ? WAITING_ICON : NORMAL_ICON
@ -66,21 +48,6 @@
});
}
var hasEventBridge = false;
function wireEventBridge(on) {
if (on) {
if (!hasEventBridge) {
tablet.fromQml.connect(fromQml);
hasEventBridge = true;
}
} else {
if (hasEventBridge) {
tablet.fromQml.disconnect(fromQml);
hasEventBridge = false;
}
}
}
function onClicked() {
if (onGotoScreen) {
// for toolbar-mode: go back to home screen, this will close the window.
@ -98,15 +65,11 @@
onGotoScreen = true;
shouldActivateButton = true;
button.editProperties({isActive: shouldActivateButton});
wireEventBridge(true);
messagesWaiting(false);
tablet.sendToQml({ method: 'refreshFeeds', protocolSignature: Window.protocolSignature() })
} else {
shouldActivateButton = false;
onGotoScreen = false;
button.editProperties({isActive: shouldActivateButton});
wireEventBridge(false);
}
}
button.clicked.connect(onClicked);