blob: 8c92b59496209bddca78bcbdb3c62fc56107551e [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
require('es6-shim');
var hg = require('mercury');
var queryString = require('query-string');
var raf = require('raf');
var $ = require('./util/jquery');
var defineClass = require('./util/define-class');
var AddButton = require('./components/add-button');
var DestinationSearch = require('./components/destination-search');
var MapWidget = require('./components/map-widget');
var Messages = require('./components/messages');
var Message = require('./components/message');
var Suggestions = require('./components/suggestions');
var Timeline = require('./components/timeline');
var TimelineClient = require('./components/timeline-client');
var TimelineService = require('./components/timeline-server');
var CastingManager = require('./casting-manager');
var Destinations = require('./destinations');
var Identity = require('./identity');
var TravelSync = require('./travelsync');
var vanadiumWrapperDefault = require('./vanadium-wrapper');
var debug = require('./debug');
var describeDestination = require('./describe-destination');
var naming = require('./naming');
var strings = require('./strings').currentLocale;
function buildStatusErrorStringMap(statusClass, stringGroup) {
var dict = {};
$.each(statusClass, function(name, value) {
dict[value] = stringGroup[name];
});
return dict;
}
function handleDestinationOrdinalUpdate(control, destination) {
return control.setPlaceholder(
describeDestination.descriptionOpenEnded(destination));
}
var CMD_REGEX = /\/(\S*)(?:\s+(.*))?/;
var SUGGESTION_PHOTO_OPTS = {
maxHeight: 96
};
var Travel = defineClass({
publics: {
dump: function() {
return this.sync.getData().then(function(data) {
debug.log(data);
return data;
}, function(err) {
console.error(err);
throw err;
});
},
status: function() {
return this.sync.status;
},
error: function (err) {
this.messages.push(Message.error(err));
},
info: function (info, promise) {
this.messages.push(new Message({
type: Message.INFO,
text: info,
promise: promise
}));
},
getActiveTripId: function() {
return this.sync.getActiveTripId();
},
invite: function(recipient) {
var self = this;
var owner = this.sync.getActiveTripOwner();
if (owner) {
this.info(strings.sendingInvite(recipient),
this.sync.invitationManager.invite(recipient,
this.sync.getActiveTripOwner(), this.sync.getActiveTripId())
.then(function() {
var me = self.sync.invitationManager.getUsername();
self.sync.message({
type: Message.INFO,
text: strings.invitationSent(recipient, me)
});
}, function(err) {
if (err.id === 'v.io/v23/verror.NoServers') {
throw strings.notReachable(recipient);
} else {
throw err;
}
}));
} else {
this.error(strings['Trip is still initializing.']);
}
},
castTimeline: function() {
}
},
privates: {
trap: function(asyncMethod) {
var self = this;
return function() {
return asyncMethod.apply(this, arguments).catch(self.error);
};
},
bindControlToDestination: function(control, destination) {
var asyncs = [];
function updateOrdinalAsync() {
return handleDestinationOrdinalUpdate(control, destination);
}
var setPlace, select, deselect, updateOrdinal;
if (destination) {
setPlace = this.trap(control.setPlace);
select = this.trap(control.select);
deselect = this.trap(control.deselect);
updateOrdinal = this.trap(updateOrdinalAsync);
destination.onPlaceChange.add(setPlace);
destination.onSelect.add(select);
destination.onDeselect.add(deselect);
destination.onOrdinalChange.add(updateOrdinal);
asyncs.push(control.setPlace(destination.getPlace()));
/* Since these controls are 1:1 with destinations, we don't want to stay
* in a state where the control has invalid text but the destination is
* still valid; that would be confusing to the user (e.g. abandoned
* query string "restaurants" for destination 4 Privet Drive.)
*
* However, if the place is valid, don't bother updating the
* destination. The destination is authoritative, and any disparity will
* be due to update lag (especially bad for remote components), which
* will result in oscillation. */
control.onPlaceChange.add(function(place) {
if (!place) {
destination.setPlace(place);
}
});
}
asyncs.push(updateOrdinalAsync());
if (destination && destination.isSelected()) {
asyncs.push(control.select());
} else {
asyncs.push(control.deselect());
}
var unbind = destination? function() {
destination.onPlaceChange.remove(setPlace);
destination.onSelect.remove(select);
destination.onDeselect.remove(deselect);
destination.onOrdinalChange.remove(updateOrdinal);
control.onPlaceChange.remove(destination.setPlace);
} : $.noop;
return Promise.all(asyncs).then(function() {
return unbind;
}, function(err) {
unbind();
throw err;
});
},
handleDestinationAdd: function(destination) {
var self = this;
this.addDestinationToTimeline(this.timeline, destination)
.then(function() {
self.bindMiniFeedback(destination);
}).catch(this.error);
},
addDestinationToTimeline: function(timeline, destination) {
var self = this;
return timeline.add(destination.getIndex()).then(function(control) {
self.bindControlToDestination(control, destination);
var asyncs = [control.setSearchBounds(self.map.getBounds())];
control.onFocus.add(function() {
if (!destination.isSelected()) {
self.map.closeActiveInfoWindow();
destination.select();
}
});
control.onSearch.add(function(results) {
/* There seems to be a bug where if you click a search suggestion (for
* a query, not a resolved location) in autocomplete, the input box
* under it gets clicked and focused... I haven't been able to figure
* out why. */
self.trap(control.focus)();
self.handleSearchResults(results);
});
if (!destination.hasNext()) {
asyncs.push(timeline.disableAdd());
var oldLastIndex = destination.getIndex() - 1;
if (oldLastIndex >= 0) {
asyncs.push(timeline.get(oldLastIndex)
.then(function(oldLast) {
if (oldLast) {
self.unbindLastDestinationSearchEvents(oldLast);
}
}));
}
self.bindLastDestinationSearchEvents(control);
}
return Promise.all([asyncs]);
});
},
handleDestinationRemove: function(destination) {
var self = this;
var index = destination.getIndex();
this.timeline.remove(index).then(function(control) {
self.unbindLastDestinationSearchEvents(control);
if (index >= self.destinations.count()) {
return self.timeline.get(-1).then(function(lastControl) {
if (lastControl) {
self.bindLastDestinationSearchEvents(lastControl);
self.handleLastPlaceChange(lastControl.getPlace());
}
});
}
//TODO(rosswang): reselect?
}).catch(this.error);
},
handleTimelineDestinationAdd: function() {
var self = this;
var timeline = this.timeline;
function selectNewControl(control) {
control.focus().catch(self.error);
timeline.onDestinationAdd.remove(selectNewControl);
}
timeline.onDestinationAdd.add(selectNewControl);
this.destinations.add().select();
},
handleMiniDestinationAdd: function() {
this.miniDestinationSearch.clear();
this.map.closeActiveInfoWindow();
var selectedDest = this.map.getSelectedDestination();
var index = selectedDest?
selectedDest.getIndex() + 1 : this.destinations.count();
var destination = this.destinations.get(index);
if (!destination || destination.hasPlace()) {
destination = this.destinations.add(index);
}
destination.select();
this.miniDestinationSearch.focus();
this.miniDestinationSearch.setPlaceholder(
destination.hasNext()?
/* Actually, the terminal case where descriptionOpenEnded would differ
* from description is always handled by the latter branch, but
* semantically we would want the open-ended description here. */
strings.add(describeDestination.descriptionOpenEnded(destination)) :
strings['Add destination']);
},
bindMiniFeedback: function(destination) {
var mf = this.miniFeedback;
destination.onSelect.add(mf.handleSelect);
destination.onDeselect.add(mf.handleDeselect);
},
initMiniFeedback: function() {
var self = this;
var selectedDestination;
//context: destination
function handlePlaceChange(place) {
self.miniDestinationSearch.setPlace(place);
self.miniDestinationSearch.setPlaceholder(
strings.change(describeDestination.description(this)));
}
//context: destination
function handleSelect() {
selectedDestination = this;
handlePlaceChange.call(this, this.getPlace());
this.onPlaceChange.add(handlePlaceChange);
}
//context: destination
function handleDeselect() {
this.onPlaceChange.remove(handlePlaceChange);
if (selectedDestination === this) {
selectedDestination = null;
if (self.miniDestinationSearch.getPlace()) {
self.miniDestinationSearch.clear();
}
self.miniDestinationSearch.setPlaceholder(strings['Search']);
}
}
this.miniFeedback = {
handleSelect: handleSelect,
handleDeselect: handleDeselect,
handlePlaceChange: handlePlaceChange
};
},
showTimeline: function() {
if (this.$timelineContainer.hasClass('collapsed')) {
this.$toggleTimeline.removeClass('collapsed');
this.$timelineContainer.removeClass('collapsed');
this.$minPanel.addClass('collapsed');
//disable the control, but wait until offscreen to avoid distraction
this.$minPanel.one('transitionend', this.miniDestinationSearch.disable);
this.watchMapResizes();
}
},
collapseTimeline: function() {
if (!this.$timelineContainer.hasClass('collapsed')) {
this.$toggleTimeline.addClass('collapsed');
this.$timelineContainer.addClass('collapsed');
this.$minPanel.removeClass('collapsed');
this.miniDestinationSearch.enable();
if (!this.miniDestinationSearch.getPlace()) {
this.miniDestinationSearch.focus();
}
this.watchMapResizes();
}
},
bindLastDestinationSearchEvents: function(control) {
control.onPlaceChange.add(this.handleLastPlaceChange);
control.onDeselect.add(this.handleLastPlaceDeselected);
},
unbindLastDestinationSearchEvents: function(control) {
control.onPlaceChange.remove(this.handleLastPlaceChange);
control.onDeselect.remove(this.handleLastPlaceDeselected);
},
handleLastPlaceChange: function(place) {
if (place) {
this.timeline.enableAdd().catch(this.error);
} else {
this.timeline.disableAdd().catch(this.error);
}
},
handleLastPlaceDeselected: function() {
this.trimUnusedDestinations().catch(this.error);
},
runCommand: function(command, rest) {
var handler = this.commands[command];
if (handler) {
var args = handler.parseArgs? handler.parseArgs(rest) : [rest];
handler.op.apply(this, args);
} else {
this.error('Unrecognized command ' + command);
}
},
handleInvite: function(invitation) {
var self = this;
var sender = invitation.sender;
var owner = invitation.owner;
var tripId = invitation.tripId;
var message = new Message();
message.setType(Message.INFO);
message.setHtml(strings.invitationReceived(sender, owner));
message.setPromise(new Promise(function(resolve, reject) {
message.$.find('a[name=accept]').click(function() {
invitation.accept().then(function() {
self.sync.watchForTrip(tripId);
return strings.invitationAccepted(sender, owner);
}).then(resolve, reject);
return false;
});
message.$.find('a[name=decline]').click(function() {
invitation.decline().then(function() {
return strings.invitationDeclined(sender, owner);
}).then(resolve, reject);
return false;
});
invitation.onDismiss.add(function() {
resolve(strings.invitationDismissed(sender, owner));
});
}));
this.messages.push(message);
},
handleSendCast: function(targetOwner, targetDeviceName, spec) {
switch (spec.panelName) {
case 'timeline':
this.sendTimelineCast(targetOwner, targetDeviceName);
break;
default:
this.error(strings.notCastable(spec.panelName));
}
},
handleReceiveCast: function(spec) {
switch (spec.panelName) {
case 'timeline':
this.receiveTimelineCast();
break;
default:
this.error(strings.notCastable(spec.panelName));
}
},
sendTimelineCast: function(targetOwner, targetDeviceName) {
var self = this;
this.vanadiumStartup.then(function(args) {
var endpoint = naming.rpcMount(
targetOwner, targetDeviceName, 'timeline');
return args.vanadiumWrapper.client(endpoint).then(function(ts) {
var tc = new TimelineClient(args.vanadiumWrapper.context(),
ts, self.map.maps);
tc.onError.add(self.error);
return self.adoptTimeline(tc);
});
}).catch(this.error);
},
receiveTimelineCast: function() {
var self = this;
var timeline = new Timeline(this.map.maps);
var ts = new TimelineService(timeline, this.map.maps);
this.vanadiumStartup.then(function(args) {
return args.vanadiumWrapper.server(
args.mountNames.rpcMount('timeline'), ts);
}).then(function() {
//TODO(rosswang): delay swap until after initialized
self.$appRoot.replaceWith(timeline.$);
}).catch(this.error);
},
adoptTimeline: function(timeline) {
var self = this;
timeline.onAddClick.add(this.handleTimelineDestinationAdd);
this.map.onBoundsChange.add(this.trap(timeline.setSearchBounds));
var async = Promise.resolve();
this.destinations.each(function(i, destination) {
async = async.then(function() {
return self.addDestinationToTimeline(timeline, destination);
});
});
this.timeline = timeline;
if (timeline.$) {
this.$timelineContainer.empty().append(timeline.$).show();
this.$toggleTimeline.show();
} else {
this.$timelineContainer.hide();
this.$toggleTimeline.hide();
}
this.map.invalidateSize();
return async;
},
handleUserMessage: function(message, raw) {
var match = CMD_REGEX.exec(raw);
if (match) {
this.runCommand(match[1], match[2]);
} else {
this.sync.message(message);
}
},
trimUnusedDestinations: function() {
var self = this;
var lastIndex = this.destinations.count() - 1;
if (lastIndex > 0) {
return this.timeline.get(lastIndex).then(function(lastControl) {
return Promise.all([
lastControl.getPlace(),
lastControl.isSelected()
]);
}).then(function(conditions) {
if (!(conditions[0] || conditions[1])) {
//check for race condition; if we're no longer up-to-date
//just execute the next "iteration" without actually removing
if (lastIndex === self.destinations.count() - 1) {
self.destinations.remove(-1);
}
return self.trimUnusedDestinations();
}
});
} else {
return Promise.resolve();
}
},
handleSearchResults: function(results) {
this.map.showSearchResults(results);
if (results.length > 1) {
this.hgState.suggestions.set({
suggestions: results.map(function(place) {
var details = place.getDetails();
var photoUrl;
if (details.photos && details.photos[0]) {
photoUrl = details.photos[0].getUrl(SUGGESTION_PHOTO_OPTS);
}
return {
placeId: place.toObject().placeId,
placeName: place.getName(),
photoUrl: photoUrl,
iconUrl: details.icon,
rating: details.rating,
priceLevel: details['price_level']
};
})
});
this.showSuggestions();
} else {
this.dismissSuggestions();
}
},
showSuggestions: function() {
if (!this.$hgSuggestionsRoot) {
this.$hgSuggestionsRoot = $('<div>')
.addClass('suggestions-container')
.insertBefore(this.map.$);
this.dismissHgSuggestions = hg.app(
this.$hgSuggestionsRoot[0], this.hgState, this.hgRenderSuggestions);
this.map.invalidateSize();
}
this.$hgSuggestionsRoot.prop('scrollTop', 0);
},
hgRenderSuggestions: function(state) {
return Suggestions.render(state.suggestions);
},
dismissSuggestions: function() {
if (this.dismissHgSuggestions) {
this.dismissHgSuggestions();
delete this.dismissHgSuggestions;
this.$hgSuggestionsRoot.remove();
delete this.$hgSuggestionsRoot;
this.map.invalidateSize();
}
},
/**
* The map widget isn't very sensitive to size updates, so we need to
* continuously invalidate during animations.
*/
watchMapResizes: function() {
var newWidth = this.map.$.width();
if (newWidth !== this.mapWidth) {
this.widthStable = 0;
this.mapWidth = newWidth;
this.map.invalidateSize();
raf(this.watchMapResizes);
} else if (this.widthStable < 5) {
raf(this.watchMapResizes);
this.widthStable++;
} else {
this.mapWidth = null;
}
}
},
constants: [ 'hgState' ],
init: function (opts) {
var self = this;
opts = opts || {};
var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
this.hgState = hg.state({
suggestions: new Suggestions()
});
var destinations = this.destinations = new Destinations();
destinations.onAdd.add(this.handleDestinationAdd);
destinations.onRemove.add(this.handleDestinationRemove);
var map = this.map = new MapWidget(opts);
var maps = map.maps;
map.bindDestinations(destinations);
var messages = this.messages = new Messages();
var timeline = this.timeline = new Timeline(maps);
var error = this.error;
var vanadiumStartup = this.vanadiumStartup =
vanadiumWrapper.init(opts.vanadium)
.then(function(wrapper) {
wrapper.onError.add(error);
wrapper.onCrash.add(error);
var identity = new Identity(wrapper.getAccountName());
var mountNames = naming.mountNames(identity);
messages.setUsername(identity.username);
return {
identity: identity,
mountNames: mountNames,
vanadiumWrapper: wrapper
};
});
var sbName = opts.syncbase ||
queryString.parse(location.search).syncbase || 4000;
if ($.isNumeric(sbName)) {
sbName = '/localhost:' + sbName;
}
var dependencies = {
maps: maps,
placesService: map.createPlacesService()
};
var sync = this.sync = new TravelSync(
vanadiumStartup, dependencies, sbName);
sync.bindDestinations(destinations);
this.info(strings['Connecting...'], sync.startup
.then(function() {
/* Fit whatever's in the map via timeout to simplify the coding a
* little. Otherwise we'd need to hook into the asynchronous place
* vivification and routing. */
setTimeout(map.fitAll, 2250);
return strings['Connected to all services.'];
}));
var directionsServiceStatusStrings = buildStatusErrorStringMap(
maps.DirectionsStatus, strings.DirectionsStatus);
map.onError.add(function(err) {
var message = directionsServiceStatusStrings[err.directionsStatus] ||
strings['Unknown error'];
error(message);
});
sync.onError.add(error);
sync.onPossibleNearbyDevices.add(function() {
self.info(strings.castingTooltip);
});
sync.onMessages.add(function(messages) {
self.messages.push.apply(self.messages, messages);
});
sync.invitationManager.onInvite.add(this.handleInvite);
messages.onMessage.add(this.handleUserMessage);
var miniAddButton = this.miniAddButton = new AddButton();
var miniDestinationSearch = this.miniDestinationSearch =
new DestinationSearch(maps);
miniAddButton.onClick.add(this.handleMiniDestinationAdd);
miniDestinationSearch.setPlaceholder(strings['Search']).catch(error);
miniDestinationSearch.setSearchBounds(map.getBounds()).catch(error);
map.onBoundsChange.add(this.trap(miniDestinationSearch.setSearchBounds));
miniDestinationSearch.onSearch.add(function(results) {
if (results.length > 0) {
/* If we've searched for a location via the minibox, any subsequent
* map click is probably intended to deselect the destination rather
* than pick by clicking. This differs from the timeline behavior since
* when we invalidate a timeline location, we delete the destination
* place and so must pick a new one. */
map.disableLocationSelection();
}
self.handleSearchResults(results);
});
miniDestinationSearch.onPlaceChange.add(function(place) {
if (!place) {
self.map.enableLocationSelection();
} else {
self.map.disableLocationSelection();
}
});
miniDestinationSearch.onSubmit.add(function(value) {
if (!value) {
var selected = self.map.getSelectedDestination();
if (selected) {
selected.remove();
}
self.map.clearSearchMarkers();
}
});
var $miniPanel = this.$minPanel = $('<div>')
.addClass('mini-search')
.append(miniAddButton.$,
miniDestinationSearch.$);
/* This container lets us collapse the destination panel even though it has
* padding, without resorting to transform: scaleX which would
* unnecessarily distort the text (which is an effect that is nice for the
* add button, so that gets it explicitly). */
var $timelineContainer = this.$timelineContainer = $('<div>')
.addClass('timeline-container collapsed');
var $toggleTimeline = this.$toggleTimeline = $('<div>')
.addClass('toggle-timeline no-select collapsed')
.text(strings['Timeline'])
.click(this.collapseTimeline);
$toggleTimeline.hoverintent(this.showTimeline, $.noop);
map.addControls(maps.ControlPosition.TOP_CENTER, messages.$);
map.addControls(maps.ControlPosition.LEFT_TOP, $miniPanel);
map.addControls(maps.ControlPosition.LEFT_CENTER, $toggleTimeline);
var $domRoot = this.$domRoot = opts.domRoot? $(opts.domRoot) : $('body');
var $appRoot = this.$appRoot = $('<div>');
$domRoot.append($appRoot.append($timelineContainer, map.$));
this.initMiniFeedback();
var castingManager = new CastingManager(sync);
castingManager.makeCastable($timelineContainer, {
spec: {
panelName: 'timeline'
}
});
castingManager.onAmbiguousCast.add(function(related, unknown, other) {
console.debug('ambiguous cast');
console.debug(related);
console.debug(unknown);
console.debug(other);
});
castingManager.onNoNearbyDevices.add(function() {
self.error(strings.noNearbyDevices);
});
castingManager.onError.add(error);
castingManager.onSendCast.add(this.handleSendCast);
sync.onReceiveCast.add(this.handleReceiveCast);
this.adoptTimeline(timeline);
destinations.add();
miniDestinationSearch.focus().catch(error);
$domRoot.keypress(function() {
messages.open();
/* Somehow emergent behavior types the key just hit without any further
* code from us. Praise be to the code gods; pray for cross-browser. */
});
this.commands = {
invite: {
op: this.invite
},
status: {
op: function() {
this.messages.push(new Message({
type: Message.INFO,
html: strings.status(JSON.stringify(this.status(), null, 2))
}));
}
}
};
}
});
module.exports = Travel;