From d241251f3407e9c06a358de6e8713e7bb1ee53c7 Mon Sep 17 00:00:00 2001 From: Aaron Boodman Date: Wed, 1 Jan 2014 02:04:14 -0800 Subject: [PATCH] Refactor the data part of infinite scroll into new SearchSession. This will also be used by the detail page to facilitate fast flipping and live udpates. Also fixed the thing where we discard the websocket responses and re-query for all updates except the very first one. Change-Id: Iac196670db967f1d41b20ce30641118ede61f3c2 --- server/camlistored/ui/blob_item_container.js | 100 +++-------- server/camlistored/ui/search_session.js | 176 +++++++++++++++++++ server/camlistored/ui/server_connection.js | 4 + 3 files changed, 201 insertions(+), 79 deletions(-) create mode 100644 server/camlistored/ui/search_session.js diff --git a/server/camlistored/ui/blob_item_container.js b/server/camlistored/ui/blob_item_container.js index 95ffef4a9..cf29df205 100644 --- a/server/camlistored/ui/blob_item_container.js +++ b/server/camlistored/ui/blob_item_container.js @@ -14,6 +14,7 @@ goog.require('goog.events.FileDropHandler'); goog.require('goog.ui.Container'); goog.require('camlistore.BlobItem'); goog.require('camlistore.ServerConnection'); +goog.require('SearchSession'); camlistore.BlobItemContainer = function(connection, opt_domHelper) { goog.base(this, opt_domHelper); @@ -22,6 +23,8 @@ camlistore.BlobItemContainer = function(connection, opt_domHelper) { this.connection_ = connection; + this.searchSession_ = null; + this.eh_ = new goog.events.EventHandler(this); // BlobRef of the permanode defined as the current collection/set. Selected blobitems will be added as members of that collection upon relevant actions (e.g click on the 'Add to Set' toolbar button). @@ -39,15 +42,9 @@ camlistore.BlobItemContainer = function(connection, opt_domHelper) { // Whether users can drag files onto the container to upload. this.isFileDragEnabled = false; - // Function to call when scrolling reaches end of page. - this.scrollContinuation_ = null; - // A lookup of blobRef->camlistore.BlobItem. This allows us to quickly find and reuse existing controls when we're updating the UI in response to a server push. this.itemCache_ = {}; - // We set this to true once we get our first response over the socket. We must test emperically because even if the browser supports web sockets, the server configuration can cause it to not work. - this.supportsWebSocket_ = false; - this.setFocusable(false); }; goog.inherits(camlistore.BlobItemContainer, goog.ui.Container); @@ -74,12 +71,6 @@ camlistore.BlobItemContainer.EventType = { SELECTION_CHANGED: 'Camlistore_BlobItemContainer_SelectionChanged', }; -camlistore.BlobItemContainer.prototype.searchMode_ = { - NEW: 1, // A brand new query the user has just navigated to - APPEND: 2, // Append results to the existing query because of scrolling - UPDATE: 3 // Update the existing results in response to a server push -}; - camlistore.BlobItemContainer.prototype.thumbnailSize_ = 200; camlistore.BlobItemContainer.prototype.smaller = function() { @@ -177,25 +168,15 @@ camlistore.BlobItemContainer.prototype.showRecent = function() { }; // @param {string|object} query If string, will be sent as the search 'expression'. Otherwise will be sent as the 'constraint'. See pkg/search/query.go for details. -camlistore.BlobItemContainer.prototype.search = function(query, opt_searchMode, opt_continuationToken) { - var searchMode = opt_searchMode || this.searchMode_.NEW; - - // Clear this out now in case the user scrolls while the request is outstanding. - this.scrollContinuation_ = null; - - // TODO(aa): This needs to be determined dynamically, based on size of window. Otherwise, with very large windows, we can never trigger paging. - var limit = this.constructor.NUM_ITEMS_PER_PAGE; - if (searchMode == this.searchMode_.UPDATE) { - limit = Math.ceil(this.getChildCount() / limit) * limit; +camlistore.BlobItemContainer.prototype.search = function(query) { + // TODO(aa): Also need to call this when removed from the DOM. + if (this.searchSession_) { + this.searchSession_.close(); } - // TODO(aa): Get rid of thumbnail size from protocol -- server should just return aspect ratio for each image. - var describe = { - thumbnailSize: this.thumbnailSize_ - }; - - this.connection_.search(query, describe, limit, opt_continuationToken, - goog.bind(this.searchDone_, this, query, searchMode)); + this.searchSession_ = new SearchSession(this.connection_, new goog.Uri(location.href), query); + this.eh_.listen(this.searchSession_, SearchSession.SEARCH_SESSION_CHANGED, this.searchDone_); + this.searchSession_.loadMoreResults(); }; camlistore.BlobItemContainer.prototype.reset = function() { @@ -204,70 +185,30 @@ camlistore.BlobItemContainer.prototype.reset = function() { this.layout_(); }; -camlistore.BlobItemContainer.prototype.searchDone_ = function(query, searchMode, result) { - if (searchMode == this.searchMode_.NEW) { +camlistore.BlobItemContainer.prototype.searchDone_ = function(e) { + if (e.changeType == SearchSession.SEARCH_SESSION_CHANGE_TYPE.NEW) { this.resetChildren_(); this.itemCache_ = {}; - this.startSocketQuery_(query); } + var result = this.searchSession_.getCurrentResults(); if (!result.blobs || !result.blobs.length) { return; } - this.populateChildren_(result, searchMode == this.searchMode_.APPEND); - - var lastItem = result.description.meta[ - result.blobs[result.blobs.length - 1].blob]; - if (result.continue) { - this.scrollContinuation_ = this.search.bind(this, query, this.searchMode_.APPEND, result.continue); + this.populateChildren_(result, e.changeType == SearchSession.SEARCH_SESSION_CHANGE_TYPE.APPEND); + if (!this.searchSession_.isComplete()) { // If the window was very large, we might not have enough data yet for the user to get their scroll on. Let's fix that. this.layout_(); var docHeight = goog.dom.getDocumentHeight(); var viewportHeight = goog.dom.getViewportSize().height; if (docHeight < (viewportHeight * 1.5)) { - this.scrollContinuation_(); + this.searchSession_.loadMoreResults(); } } }; -// Quick and dirty use of WebSocket to know when the current query may have changed. We don't use the response from the server directly, as it is quite hard to integrate into previous results reliably with the current protocol. Instead, we just use it as a tickle to redo the real query. -camlistore.BlobItemContainer.prototype.startSocketQuery_ = function(callerQuery) { - if (!window.WebSocket) { - return; - } - - var config = this.connection_.config_; - var uri = new goog.Uri(goog.uri.utils.appendPath(config.searchRoot, 'camli/search/ws?authtoken=' + (config.wsAuthToken || ''))); - uri.setDomain(location.hostname); - uri.setPort(location.port); - if (location.protocol == "https:") { - uri.setScheme("wss"); - } else { - uri.setScheme("ws"); - } - - var describe = {}; // so we see the 'description' in the response & attr changes - var query = this.connection_.buildQuery(callerQuery, describe); - - var ws = new WebSocket(uri.toString()); - ws.onopen = function() { - var message = { - tag: 'q1', - query: query - }; - ws.send(JSON.stringify(message)); - }; - ws.onmessage = function() { - this.supportsWebSocket_ = true; - // Ignore the first response. - ws.onmessage = function() { - this.search(callerQuery, this.searchMode_.UPDATE); - }.bind(this); - }.bind(this); -}; - camlistore.BlobItemContainer.prototype.findByBlobref_ = function(blobref) { this.connection_.describeWithThumbnails( blobref, this.thumbnailSize_, @@ -393,7 +334,8 @@ camlistore.BlobItemContainer.prototype.unselectAll = function() { }; camlistore.BlobItemContainer.prototype.populateChildren_ = function(result, append) { - for (var i = 0, blob; blob = result.blobs[i]; i++) { + var i = append ? this.getChildCount() : 0; + for (var blob; blob = result.blobs[i]; i++) { var blobRef = blob.blob; var item = this.itemCache_[blobRef]; var render = true; @@ -530,8 +472,8 @@ camlistore.BlobItemContainer.prototype.handleScroll_ = function() { return; } - if (this.scrollContinuation_) { - this.scrollContinuation_(); + if (this.searchSession_) { + this.searchSession_.loadMoreResults(); } }; @@ -591,7 +533,7 @@ camlistore.BlobItemContainer.prototype.handleDescribeSuccess_ = function(recipie this.connection_.newAddAttributeClaim(recipient, 'camliMember', permanode); } - if (this.supportsWebSocket_) { + if (this.searchSession_ && this.searchSession_.supportsChangeNotifications()) { // We'll find this when we reload. return; } diff --git a/server/camlistored/ui/search_session.js b/server/camlistored/ui/search_session.js new file mode 100644 index 000000000..9004c2147 --- /dev/null +++ b/server/camlistored/ui/search_session.js @@ -0,0 +1,176 @@ +/* +Copyright 2013 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +goog.provide('SearchSession'); + +goog.require('goog.events.EventTarget'); +goog.require('goog.Uri'); +goog.require('goog.Uri.QueryData'); +goog.require('goog.uri.utils'); + +goog.require('camlistore.ServerConnection'); + +// A search session is a standing query that notifies you when results change. It caches previous results and handles merging new data as it is received. It does not tell you _what_ changed; clients must reconcile as they see fit. +// +// TODO(aa): Only deltas should be sent from server to client +// TODO(aa): Need some way to avoid the duplicate query when websocket starts. Ideas: +// - Initial XHR query can also specify tag. This tag times out if not used rapidly. Send this same tag in socket query. +// - Socket assumes that client already has first batch of results (slightly racey though) +// - Prefer to use socket on client-side, test whether it works and fall back to XHR if not. +var SearchSession = function(connection, currentUri, query) { + this.connection_ = connection; + this.initSocketUri_(currentUri); + this.query_ = query; + + this.data_ = { + blobs: [], + description: {} + }; + this.instance_ = this.constructor.instanceCount_++; + this.isComplete_ = false; + this.continuation_ = this.getContinuation_(this.constructor.SEARCH_SESSION_CHANGE_TYPE.NEW); + this.socket_ = null; + this.supportsWebSocket_ = false; +}; +goog.inherits(SearchSession, goog.events.EventTarget); + +// We fire this event when the data changes in any way. +SearchSession.SEARCH_SESSION_CHANGED = 'search-session-change'; + +// TODO(aa): This should go away once BlobItemContainer can reconcile changes for itself. +SearchSession.SEARCH_SESSION_CHANGE_TYPE = { + NEW: 1, + APPEND: 2, + UPDATE: 3 +}; + +// This size doesn't matter, we don't use it. We only care about the aspect ratio. +// TODO(aa): Change describe to just return aspect directly. +SearchSession.prototype.THUMBNAIL_SIZE_ = 1000; + +SearchSession.prototype.PAGE_SIZE_ = 50; + +SearchSession.instanceCount_ = 0; + +// Returns all the data we currently have loaded. +SearchSession.prototype.getCurrentResults = function() { + return this.data_; +}; + +// Loads the next page of data. This is safe to call while a load is in progress; multiple calls for the same page will be collapsed. The SEARCH_SESSION_CHANGED event will be dispatched when the new data is available. +SearchSession.prototype.loadMoreResults = function() { + if (!this.continuation_) { + return; + } + + var c = this.continuation_; + this.continuation_ = null; + c(); +}; + +// Returns true if it is known that all data which can be loaded for this query has been. +SearchSession.prototype.isComplete = function() { + return this.isComplete_; +} + +SearchSession.prototype.supportsChangeNotifications = function() { + return this.supportsWebSocket_; +}; + +SearchSession.prototype.close = function() { + if (this.socket_) { + this.socket.close(); + } +}; + +SearchSession.prototype.initSocketUri_ = function(currentUri) { + if (!goog.global.WebSocket) { + return; + } + + this.socketUri_ = currentUri; + var config = this.connection_.getConfig(); + this.socketUri_.setPath(goog.uri.utils.appendPath(config.searchRoot, 'camli/search/ws')); + this.socketUri_.setQuery(goog.Uri.QueryData.createFromMap({authtoken: config.wsAuthToken || ''})); + if (this.socketUri_.getScheme() == "https") { + this.socketUri_.setScheme("wss"); + } else { + this.socketUri_.setScheme("ws"); + } +}; + +SearchSession.prototype.getContinuation_ = function(changeType, opt_continuationToken) { + var describe = { + thumbnailSize: this.THUMBNAIL_SIZE_ + }; + return this.connection_.search.bind(this.connection_, this.query_, describe, this.PAGE_SIZE_, opt_continuationToken, + this.searchDone_.bind(this, changeType)); +}; + +SearchSession.prototype.searchDone_ = function(changeType, result) { + if (changeType == this.constructor.SEARCH_SESSION_CHANGE_TYPE.APPEND) { + this.data_.blobs = this.data_.blobs.concat(result.blobs); + goog.mixin(this.data_.description, result.description); + } else { + this.data_.blobs = result.blobs; + this.data_.description = result.description; + } + + this.dispatchEvent({type: this.constructor.SEARCH_SESSION_CHANGED, changeType: changeType}); + + if (result.continue) { + this.continuation_ = this.getContinuation_(this.constructor.SEARCH_SESSION_CHANGE_TYPE.APPEND, result.continue); + } else { + this.isComplete_ = true; + } + + if (changeType == this.constructor.SEARCH_SESSION_CHANGE_TYPE.NEW || + changeType == this.constructor.SEARCH_SESSION_CHANGE_TYPE.APPEND) { + this.startSocketQuery_(); + } +}; + +SearchSession.prototype.startSocketQuery_ = function() { + if (!this.socketUri_) { + return; + } + + if (this.socket_) { + this.socket_.close(); + } + + var describe = { + thumbnailSize: this.THUMBNAIL_SIZE_ + }; + var query = this.connection_.buildQuery(this.query_, describe, this.data_.blobs.length); + + this.socket_ = new WebSocket(this.socketUri_.toString()); + this.socket_.onopen = function() { + var message = { + tag: 'q' + this.instance_, + query: query + }; + this.socket_.send(JSON.stringify(message)); + }.bind(this); + this.socket_.onmessage = function() { + this.supportsWebSocket_ = true; + // Ignore the first response. + this.socket_.onmessage = function(e) { + var result = JSON.parse(e.data); + this.searchDone_(this.constructor.SEARCH_SESSION_CHANGE_TYPE.UPDATE, result.result); + }.bind(this); + }.bind(this); +}; diff --git a/server/camlistored/ui/server_connection.js b/server/camlistored/ui/server_connection.js index eb54c460f..c6aae010d 100644 --- a/server/camlistored/ui/server_connection.js +++ b/server/camlistored/ui/server_connection.js @@ -18,6 +18,10 @@ camlistore.ServerConnection = function(config, opt_sendXhr) { this.sendXhr_ = opt_sendXhr || goog.net.XhrIo.send; }; +camlistore.ServerConnection.prototype.getConfig = function() { + return this.config_; +}; + // @param {?Function|undefined} fail Fail func to call if exists. // @return {Function} camlistore.ServerConnection.prototype.safeFail_ = function(fail) {