Starting multi-user syncing
Adding commands to the chat box
Adding InvitationManager with basic operations (TODO: accept)
Change-Id: I1463957c0cfb45fd32b0f69c53b4f51780cd8427
diff --git a/Makefile b/Makefile
index 7bcaa39..da440ef 100644
--- a/Makefile
+++ b/Makefile
@@ -68,7 +68,7 @@
.PHONY: creds
creds:
- @principal seekblessings --v23.credentials tmp/creds
+ @principal seekblessings --v23.credentials tmp/creds/$(creds)
.PHONY: syncbase
syncbase: bin
diff --git a/package.json b/package.json
index 65b6895..4166d93 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"es6-promisify": "^2.0.0",
"es6-shim": "^0.33.0",
"global": "^4.3.0",
+ "htmlencode": "^0.0.4",
"jquery": "^2.1.4",
"lodash": "^3.10.1",
"query-string": "^2.4.0",
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index a68d411..2f7211d 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -41,12 +41,28 @@
this.onClear();
},
- pushClient: function(client, color) {
+ pushClient: function(client, color, update) {
this.clients.push($.extend({}, this.topClient(), {
client: client,
color: color,
listeners: []
}));
+ if (update !== false) { //undefined => true
+ this.updateIcon();
+ this.updateTitle();
+ }
+ },
+
+ /**
+ * Flip the top two clients, to deprioritize a low-priority client that was
+ * just pushed.
+ */
+ deprioritizeClient: function() {
+ if (this.clients.length > 1) {
+ var demoted = this.topClient();
+ this.clients.splice(--this.clients.length - 1, 0, demoted);
+ }
+
this.updateIcon();
this.updateTitle();
},
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
index dc3b989..2dffcc9 100644
--- a/src/components/destination-search.js
+++ b/src/components/destination-search.js
@@ -125,6 +125,7 @@
if (e.which === 13) {
this.onSubmit(this.getValue());
}
+ e.stopPropagation();
}
},
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index 0143586..40b643e 100644
--- a/src/components/map-widget.js
+++ b/src/components/map-widget.js
@@ -136,7 +136,7 @@
var place = new Place(result);
var marker = self.getOrCreateMarker(place, SEARCH_CLIENT,
- DestinationMarker.color.RED);
+ DestinationMarker.color.RED, null, false);
self.searchMarkers.push(marker);
marker.onClick.add(marker.restrictListenerToClient(function() {
@@ -145,6 +145,8 @@
dest.setPlace(place);
}
}));
+
+ marker.deprioritizeClient();
});
}
}
@@ -194,7 +196,7 @@
this.destMeta.delete(destination);
},
- getOrCreateMarker: function(place, client, color, mergePredicate) {
+ getOrCreateMarker: function(place, client, color, mergePredicate, update) {
var self = this;
var key = place.toKey();
@@ -202,7 +204,7 @@
var marker = this.markers[key];
if (marker) {
if (!mergePredicate || mergePredicate(marker)) {
- marker.pushClient(client, color);
+ marker.pushClient(client, color, update);
} else {
marker = null;
}
diff --git a/src/components/message.js b/src/components/message.js
index e68ce89..c2857c4 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -53,7 +53,13 @@
this.$text.text(text);
},
+ setHtml: function(html) {
+ this.$text.html(html);
+ },
+
setTimestamp: function(timestamp) {
+ this.timestamp = timestamp;
+
var fmt;
if (timestamp === null || timestamp === undefined) {
fmt = '';
@@ -68,6 +74,10 @@
}
},
+ getTimestamp: function() {
+ return this.timestamp;
+ },
+
setSender: function(sender) {
this.$sender.text(sender);
if (sender) {
@@ -77,32 +87,42 @@
}
},
- set: function(message) {
- if (!message) {
- this.onLowerPriority();
- return;
- }
-
- if (typeof message === 'string') {
- message = Message.info(message);
- }
-
+ setPromise: function(promise) {
var self = this;
-
- this.setType(message.type);
- this.setSender(message.sender);
- this.setTimestamp(message.timestamp);
- this.setText(message.text);
-
- if (message.promise) {
- message.promise.then(function(message) {
- self.set(message);
+ if (promise) {
+ promise.then(function(obj) {
+ self.set(obj);
}, function(err) {
self.set(Message.error(err));
});
} else {
this.onLowerPriority();
}
+ },
+
+ set: function(obj) {
+ if (!obj) {
+ this.onLowerPriority();
+ return;
+ }
+
+ if (typeof obj === 'string') {
+ obj = {
+ type: Message.INFO,
+ text: obj
+ };
+ }
+
+ this.setType(obj.type);
+ this.setSender(obj.sender);
+ this.setTimestamp(obj.timestamp);
+ if (obj.text) {
+ this.setText(obj.text);
+ } else {
+ this.setHtml(obj.html);
+ }
+
+ this.setPromise(obj.promise);
}
},
@@ -117,9 +137,11 @@
init: function(initial) {
this.$ = $('<li>')
.append(
- this.$label = $('<span>').addClass('label').append(
- this.$sender = $('<span>').addClass('username'),
- this.$timestamp = $('<span>').addClass('timestamp')),
+ this.$label = $('<span>')
+ .addClass('label no-sender no-timestamp')
+ .append(
+ this.$sender = $('<span>').addClass('username'),
+ this.$timestamp = $('<span>').addClass('timestamp')),
this.$text = $('<span>').addClass('text'));
if (initial) {
this.set(initial);
diff --git a/src/components/messages.js b/src/components/messages.js
index 12b1087..c79a5fa 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -7,6 +7,9 @@
var Message = require('./message');
+var VK_ENTER = 13;
+var VK_ESCAPE = 27;
+
var Messages = defineClass({
statics: {
SLIDE_DOWN: 150,
@@ -60,7 +63,7 @@
open: function() {
var $messages = this.$messages;
- if (!this.isOpen()) {
+ if (this.isClosed()) {
this.$.find('.animating')
.stop(true)
.removeClass('animating')
@@ -83,12 +86,15 @@
}
});
}
-
- this.$content.focus();
}
+ this.focus();
},
- push: function(messageData) {
+ focus: function() {
+ this.$content.focus();
+ },
+
+ push: function(message) {
var self = this;
$.each(arguments, function() {
self.pushOne(this);
@@ -113,63 +119,77 @@
privates: {
inputKey: function(e) {
- if (e.which === 13) {
- var message = Message.info(this.$content.prop('value'));
- message.sender = this.username;
- this.$content.prop('value', '');
- this.onMessage(message);
+ switch (e.which) {
+ case VK_ENTER: {
+ var raw = this.$content.prop('value');
+ if (raw) {
+ var message = Message.info(raw);
+ message.sender = this.username;
+ this.$content.prop('value', '');
+ this.onMessage(message, raw);
+ }
+ break;
+ }
+ case VK_ESCAPE: {
+ this.close();
+ }
}
},
- pushOne: function(messageData) {
+ pushOne: function(message) {
var self = this;
- var messageObject = new Message(messageData);
- this.$messages.append(messageObject.$);
+ if ($.isPlainObject(message)) {
+ message = new Message(message);
+ }
- var isOld = messageData.timestamp !== undefined &&
- messageData.timestamp !== null &&
- Date.now() - messageData.timestamp >= Messages.OLD;
+ this.$messages.append(message.$);
+
+ var timestamp = message.getTimestamp();
+ var isOld = timestamp !== undefined && timestamp !== null &&
+ Date.now() - timestamp >= Messages.OLD;
if (this.isOpen()) {
this.$messages.scrollTop(this.$messages.prop('scrollHeight'));
- } else if (isOld) {
- messageObject.$.addClass('history');
- } else {
- /*
- * Implementation notes: slideDown won't work properly (won't be able to
- * calculate goal height) unless the element is in the DOM tree prior
- * to the call, so we hide first, attach, and then animate. slideDown
- * implicitly shows the element. Furthermore, it won't run unless the
- * element starts hidden.
- *
- * Similarly, we use animate rather than fadeIn because fadeIn
- * implicitly hides the element upon completion, resulting in an abrupt
- * void in the element flow. Instead, we want to keep the element taking
- * up space while invisible until we've collapsed the height via
- * slideUp.
- *
- * It would be best to use CSS animations, but at this time that would
- * mean sacrificing either auto-height or flow-affecting sliding.
- */
- messageObject.$
- .addClass('animating')
- .hide()
- .slideDown(this.SLIDE_DOWN);
}
- if (!isOld) {
- messageObject.onLowerPriority.add(function() {
- messageObject.$.addClass('history');
+ if (isOld) {
+ message.$.addClass('history');
+ } else {
+ if (!this.isOpen()) {
+ /*
+ * Implementation notes: slideDown won't work properly (won't be able
+ * to calculate goal height) unless the element is in the DOM tree
+ * prior to the call, so we hide first, attach, and then animate.
+ * slideDown implicitly shows the element. Furthermore, it won't run
+ * unless the element starts hidden.
+ *
+ * Similarly, we use animate rather than fadeIn because fadeIn
+ * implicitly hides the element upon completion, resulting in an
+ * abrupt void in the element flow. Instead, we want to keep the
+ * element taking up space while invisible until we've collapsed the
+ * height via slideUp.
+ *
+ * It would be best to use CSS animations, but at this time that would
+ * mean sacrificing either auto-height or flow-affecting sliding.
+ */
+ message.$
+ .addClass('animating')
+ .hide()
+ .slideDown(this.SLIDE_DOWN);
+ }
+
+ message.onLowerPriority.add(function() {
+ message.$.addClass('history');
if (self.isClosed()) {
- messageObject.$
+ message.$
.addClass('animating')
.show()
.delay(Messages.TTL)
.animate({ opacity: 0 }, Messages.FADE)
.slideUp(Messages.SLIDE_UP, function() {
- messageObject.$
+ message.$
.removeClass('animating')
.attr('style', null);
});
@@ -180,6 +200,10 @@
},
constants: ['$'],
+ /**
+ * @param message the message object that should be sent.
+ * @param raw the raw text input.
+ */
events: [ 'onMessage' ],
init: function() {
@@ -196,7 +220,7 @@
$('<div>').append(
this.$content = $('<input>')
.attr('type', 'text')
- .keypress(this.inputKey)));
+ .keydown(this.inputKey)));
this.$ = $('<div>')
.addClass('messages headlines')
diff --git a/src/identity.js b/src/identity.js
index d9211cf..b13e25e 100644
--- a/src/identity.js
+++ b/src/identity.js
@@ -7,7 +7,10 @@
module.exports = Identity;
function Identity(accountName) {
- this.username = extractUsername(accountName);
+ var account = processAccountName(accountName);
+
+ this.account = account.name;
+ this.username = account.username;
this.deviceType = 'desktop';
this.deviceId = uuid.v4();
@@ -19,15 +22,22 @@
return uuid.v4();
}
-function extractUsername(accountName) {
+var ACCOUNT_REGEX = /(dev\.v\.io\/u\/([^\/]+)).*/;
+
+function processAccountName(accountName) {
if (!accountName || accountName === 'unknown') {
- return autoUsername();
+ return {
+ name: '...',
+ username: autoUsername()
+ };
}
- var parts = accountName.split('/');
- if (parts[0] !== 'dev.v.io' || parts[1] !== 'u') {
- return accountName;
- }
-
- return parts[2];
+ var match = ACCOUNT_REGEX.exec(accountName);
+ return match? {
+ name: match[1],
+ username: match[2]
+ } : {
+ name: accountName,
+ username: accountName
+ };
}
diff --git a/src/ifc/ops.vdl b/src/ifc/ops.vdl
index 16e442a..5cab7f3 100644
--- a/src/ifc/ops.vdl
+++ b/src/ifc/ops.vdl
@@ -10,7 +10,7 @@
Collaborate = "C"
)
-// Stub multicast RPCs to mock SyncBase storage.
+// Stub multicast RPCs to mock Syncbase storage.
// TODO: allow multiple trips (e.g. multiple planned trips).
type TravelSync interface {
// Gets the current trip.
diff --git a/src/invitation-manager.js b/src/invitation-manager.js
new file mode 100644
index 0000000..e1a26a4
--- /dev/null
+++ b/src/invitation-manager.js
@@ -0,0 +1,158 @@
+// 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 $ = require('./util/jquery');
+var defineClass = require('./util/define-class');
+
+// TODO(rosswang): generalize this
+var ESC = {
+ '_': '_',
+ '.': 'd',
+ '@': 'a'
+};
+
+var INV = {};
+$.each(ESC, function(k, v) {
+ INV[v] = k;
+});
+
+function escapeUsername(str) {
+ return str.replace(/_|\.|@/g, function(m) {
+ return '_' + ESC[m];
+ });
+}
+
+function unescapeUsername(str) {
+ return str.replace(/_(.)/g, function(m, p1) {
+ return INV[p1];
+ });
+}
+
+function invitationKey(owner, recipient) {
+ return ['invitations', escapeUsername(owner), escapeUsername(recipient)];
+}
+
+var InvitationManager = defineClass({
+ publics: {
+ accept: function(owner) {
+ },
+
+ decline: function(owner) {
+ return Promise.all([
+ this.syncbasePromise,
+ this.prereqs
+ ]).then(function(args) {
+ var syncbase = args[0];
+ var username = args[1].identity.username;
+
+ return syncbase.delete(invitationKey(owner, username));
+ });
+ },
+
+ getActiveInvite: function() {
+ return this.activeInvite;
+ },
+
+ invite: function(username) {
+ var self = this;
+
+ return this.groupManagerPromise.then(function(gm) {
+ return gm.joinSyncGroup(username, 'invitations').then(function() {
+ return self.prereqs;
+ }).then(function(prereqs) {
+ var owner = self.activeInvite || prereqs.identity.username;
+
+ return gm.syncbaseWrapper.put(invitationKey(owner, username),
+ prereqs.identity.username);
+ });
+ });
+ },
+
+ getUsername: function() {
+ return this.username;
+ }
+ },
+
+ privates: {
+ processUpdates: function(data) {
+ var self = this;
+
+ if (data.invitations) {
+ $.each(data.invitations, function(owner, record) {
+ var ownerInvites = self.invitations[owner];
+ if (!ownerInvites) {
+ ownerInvites = self.invitations[owner] = {};
+ }
+
+ $.each(record, function(recipient, sender) {
+ if (ownerInvites[recipient]) {
+ delete ownerInvites[recipient];
+ } else {
+ self.onInvite(unescapeUsername(owner),
+ unescapeUsername(recipient), sender);
+ }
+ });
+ });
+ }
+
+ if (this.invitations) {
+ $.each(this.invitations, function(owner, record) {
+ $.each(record, function(recipient, sender) {
+ self.onDismiss(unescapeUsername(owner),
+ unescapeUsername(recipient), sender);
+ });
+ });
+ }
+
+ this.invitations = data.invitations || {};
+ }
+ },
+
+ events: {
+ /**
+ * @param owner the user who owns the trip.
+ * @param recipient the user invited to the trip.
+ * @param sender the user who sent the invitation.
+ */
+ onInvite: '',
+
+ /**
+ * @param owner the user who owns the trip.
+ * @param recipient the user invited to the trip.
+ * @param sender the user who sent the invitation.
+ */
+ onDismiss: '',
+
+ onError: 'memory'
+ },
+
+ /**
+ * @param prereqs promise of { identity, mountNames, vanadiumWrapper }
+ */
+ init: function(prereqs, groupManagerPromise) {
+ var self = this;
+
+ this.prereqs = prereqs;
+ this.syncbasePromise = groupManagerPromise.then(function(gm) {
+ gm.syncbaseWrapper.onUpdate.add(self.processUpdates);
+ return gm.syncbaseWrapper;
+ });
+ this.groupManagerPromise = groupManagerPromise;
+
+ this.invitations = {};
+
+ prereqs.then(function(args) {
+ self.username = args.identity.username;
+ });
+
+ groupManagerPromise.then(function(gm) {
+ gm.createSyncGroup('invitations', ['invitations'])
+ .catch(self.onError);
+ });
+ }
+});
+
+module.exports = InvitationManager;
\ No newline at end of file
diff --git a/src/naming.js b/src/naming.js
new file mode 100644
index 0000000..cc371c6
--- /dev/null
+++ b/src/naming.js
@@ -0,0 +1,34 @@
+// 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.
+
+var vanadium = require('vanadium');
+
+var ROOT = '/ns.dev.v.io:8101';
+
+function userMount(username) {
+ return vanadium.naming.join(ROOT, 'users', username);
+}
+
+function appMount(username) {
+ return vanadium.naming.join(userMount(username), 'travel');
+}
+
+function deviceMount(username, deviceName) {
+ return vanadium.naming.join(appMount(username), deviceName);
+}
+
+function mountNames(id) {
+ return {
+ user: userMount(id.username),
+ app: appMount(id.username),
+ device: deviceMount(id.username, id.deviceName)
+ };
+}
+
+module.exports = {
+ userMount: userMount,
+ appMount: appMount,
+ deviceMount: deviceMount,
+ mountNames: mountNames
+};
diff --git a/src/static/index.css b/src/static/index.css
index db4d8ef..a0e7517 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -151,6 +151,12 @@
margin-top: 3px;
}
+.messages.headlines a {
+ background-color: rgba(255, 255, 255, .8);
+ border-radius: 2px;
+ padding: 1px 3px;
+}
+
.messages.headlines li.history {
display: none;
}
diff --git a/src/strings.js b/src/strings.js
index 1a94323..110e555 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -2,6 +2,24 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+var htmlEncode = require('htmlencode').htmlEncode;
+
+function text(plainText) {
+ return htmlEncode(plainText);
+}
+
+function link(name, linkText) {
+ return '<a name="' + name + '" href="#">' + text(linkText) + '</a>';
+}
+
+function pre(preText) {
+ return '<pre>' + preText + '</pre>';
+}
+
+function ownerOfTrip(sender, owner) {
+ return sender === owner? 'a' : owner + '\'s';
+}
+
function getStrings(locale) {
return {
'Add destination': 'Add destination',
@@ -29,8 +47,36 @@
label: function(label, details) {
return label + ': ' + details;
},
+ invitationAccepted: function(sender, owner) {
+ return 'Accepted invite from ' + sender + ' to join ' +
+ ownerOfTrip(sender, owner) + ' trip.';
+ },
+ invitationDeclined: function(sender, owner) {
+ return 'Declined invite from ' + sender + ' to join ' +
+ ownerOfTrip(sender, owner) + ' trip.';
+ },
+ invitationReceived: function(sender, owner) {
+ return text(sender + ' has invited you to join ' +
+ ownerOfTrip(sender, owner) + ' trip. ') +
+ link('accept', 'Accept') + text(' / ') + link('decline', 'Decline');
+ },
+ invitationSent: function(recipient, sender) {
+ return sender?
+ sender + ' invited ' + recipient + ' to join the trip.' :
+ 'Invited ' + recipient + ' to join the trip.';
+ },
+ 'Not connected': 'Not connected',
+ notReachable: function(username) {
+ return username + ' is not reachable or is not a Travel Planner user.';
+ },
'Origin': 'Origin',
'Search': 'Search',
+ sendingInvite: function(username) {
+ return 'Inviting ' + username + ' to join the trip...';
+ },
+ status: function(status) {
+ return text('Status: ') + pre(status);
+ },
'Timeline': 'Timeline',
'Travel Planner': 'Travel Planner',
'Unknown error': 'Unknown error'
diff --git a/src/syncgroup-manager.js b/src/syncgroup-manager.js
new file mode 100644
index 0000000..97b8040
--- /dev/null
+++ b/src/syncgroup-manager.js
@@ -0,0 +1,92 @@
+// 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 vanadium = require('vanadium');
+
+var defineClass = require('./util/define-class');
+var naming = require('./naming');
+
+var SyncgroupManager = defineClass({
+ publics: {
+ createSyncGroup: function(name, prefixes) {
+ var self = this;
+
+ return this.prereq.then(function() {
+ var sg = self.syncbaseWrapper.syncGroup(self.sgAdmin, name);
+
+ var mgmt = vanadium.naming.join(self.mountNames.app, 'sgmt', name);
+ var spec = sg.buildSpec(prefixes, [mgmt]);
+
+ /* TODO(rosswang): Right now, duplicate Syncbase creates on
+ * different Syncbase instances results in siloed SyncGroups.
+ * Revisit this logic once it merges properly. */
+ return sg.joinOrCreate(spec).then(function() {
+ // TODO(rosswang): this is a hack to make the syncgroup joinable
+ return self.vanadiumWrapper.setPermissions(mgmt, new Map([
+ ['Admin', {in: ['...']}],
+ ['Read', {in: ['...']}],
+ ['Resolve', {in: ['...']}]
+ ]));
+ });
+ });
+ },
+
+ joinSyncGroup: function(owner, name) {
+ var sg = this.syncbaseWrapper.syncGroup(
+ vanadium.naming.join(naming.appMount(owner), 'sgadmin'), name);
+ return sg.join();
+ }
+ },
+
+ privates: {
+ advertise: function() {
+ var self = this;
+
+ var basicPerms = new Map([
+ ['Admin', {in: [this.identity.account]}],
+ ['Read', {in: ['...']}],
+ ['Resolve', {in: ['...']}]
+ ]);
+
+ return Promise.all([
+ /* TODO(rosswang): this is a very short term hack just because user
+ * mount names on ns.dev.v.io don't yet default to Resolve in [...].
+ */
+ this.vanadiumWrapper.setPermissions(this.mountNames.user, basicPerms),
+ this.vanadiumWrapper.setPermissions(this.mountNames.app, basicPerms),
+ this.prereq.then(function() {
+ // TODO(rosswang): This seems wrong too.
+ return self.vanadiumWrapper.setPermissions(self.sgAdmin, basicPerms);
+ })
+ ]);
+ }
+ },
+
+ constants: [ 'sgAdmin', 'syncbaseWrapper' ],
+
+ events: {
+ onError: 'memory'
+ },
+
+ init: function(identity, vanadiumWrapper, syncbaseWrapper, mountNames) {
+ this.identity = identity;
+ this.vanadiumWrapper = vanadiumWrapper;
+ this.syncbaseWrapper = syncbaseWrapper;
+ this.mountNames = mountNames;
+
+ this.sgAdmin = vanadium.naming.join(mountNames.app, 'sgadmin');
+
+ /* TODO(rosswang): Once Vanadium supports global SyncGroup admin
+ * creation, remove this. For now, use the first local Syncbase
+ * instance to administrate. */
+ this.prereq = vanadiumWrapper.mount(this.sgAdmin, syncbaseWrapper.mountName,
+ vanadiumWrapper.multiMount.FAIL);
+
+ this.advertise().catch(this.onError);
+ }
+});
+
+module.exports = SyncgroupManager;
\ No newline at end of file
diff --git a/src/travel.js b/src/travel.js
index 5b1c30c..9aae46d 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -2,8 +2,9 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+require('es6-shim');
+
var raf = require('raf');
-var vanadium = require('vanadium');
var $ = require('./util/jquery');
var defineClass = require('./util/define-class');
@@ -21,8 +22,9 @@
var vanadiumWrapperDefault = require('./vanadium-wrapper');
-var strings = require('./strings').currentLocale;
var describeDestination = require('./describe-destination');
+var naming = require('./naming');
+var strings = require('./strings').currentLocale;
function bindControlToDestination(control, destination) {
function updateOrdinal() {
@@ -71,18 +73,7 @@
control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
}
-function makeMountNames(id) {
- // TODO: first-class app-wide rather than siloed by account
- var parts = ['/ns.dev.v.io:8101', 'users', id.username, 'travel'];
- var names = {
- user: vanadium.naming.join(parts)
- };
-
- parts.push(id.deviceName);
- names.device = vanadium.naming.join(parts);
-
- return names;
-}
+var CMD_REGEX = /\/(\S*)(?:\s+(.*))?/;
var Travel = defineClass({
publics: {
@@ -91,9 +82,11 @@
},
info: function (info, promise) {
- var messageData = Message.info(info);
- messageData.promise = promise;
- this.messages.push(messageData);
+ this.messages.push(new Message({
+ type: Message.INFO,
+ text: info,
+ promise: promise
+ }));
}
},
@@ -277,6 +270,53 @@
raf(this.trimUnusedDestinations);
},
+ 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(owner, recipient, sender) {
+ var self = this;
+ var invitationManager = this.sync.invitationManager;
+ var me = invitationManager.getUsername();
+
+ if (recipient === me) {
+ 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() {
+ invitationManager.accept(owner).then(function() {
+ return strings.invitationAccepted(sender, owner);
+ }).then(resolve, reject);
+ return false;
+ });
+ message.$.find('a[name=decline]').click(function() {
+ invitationManager.decline(owner).then(function() {
+ return strings.invitationDeclined(sender, owner);
+ }).then(resolve, reject);
+ return false;
+ });
+ }));
+
+ self.messages.push(message);
+ }
+ },
+
+ 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() {
for (var lastControl = this.timeline.get(-1);
!lastControl.getPlace() && !lastControl.isSelected() &&
@@ -332,11 +372,12 @@
wrapper.onCrash.add(error);
var identity = new Identity(wrapper.getAccountName());
- identity.mountNames = makeMountNames(identity);
+ var mountNames = naming.mountNames(identity);
messages.setUsername(identity.username);
return {
identity: identity,
+ mountNames: mountNames,
vanadiumWrapper: wrapper
};
});
@@ -371,9 +412,9 @@
self.messages.push.apply(self.messages, messages);
});
- messages.onMessage.add(function(message) {
- sync.message(message);
- });
+ sync.invitationManager.onInvite.add(this.handleInvite);
+
+ messages.onMessage.add(this.handleUserMessage);
timeline.onAddClick.add(this.handleTimelineDestinationAdd);
@@ -447,6 +488,43 @@
destinations.add();
miniDestinationSearch.focus();
+
+ $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: function(username) {
+ this.info(strings.sendingInvite(username),
+ this.sync.invitationManager.invite(username)
+ .then(function() {
+ var me = self.sync.invitationManager.getUsername();
+ self.sync.message({
+ type: Message.INFO,
+ text: strings.invitationSent(username, me)
+ });
+ }, function(err) {
+ if (err.id === 'v.io/v23/verror.NoServers') {
+ throw strings.notReachable(username);
+ } else {
+ throw err;
+ }
+ }));
+ }
+ },
+
+ status: {
+ op: function() {
+ this.messages.push(new Message({
+ type: Message.INFO,
+ html: strings.status(JSON.stringify(this.sync.status, null, 2))
+ }));
+ }
+ }
+ };
}
});
diff --git a/src/travelsync.js b/src/travelsync.js
index baf55e7..25d5f3a 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -13,6 +13,8 @@
var defineClass = require('./util/define-class');
var debug = require('./debug');
+var SyncgroupManager = require('./syncgroup-manager');
+var InvitationManager = require('./invitation-manager');
var Place = require('./place');
var vdlTravel = require('../ifc');
@@ -236,7 +238,7 @@
});
},
- /* A note on these operations: SyncBase client operations occur
+ /* A note on these operations: Syncbase client operations occur
* asynchronously, in response to events that can rapidly change state. As
* such, each write operation must first check to ensure the record it's
* updating for is still valid (has a defined id).
@@ -382,62 +384,70 @@
this.processDestinations(data.destinations);
},
- start: function(args) {
+ serve: function(args) {
var self = this;
-
+ var mountNames = args.mountNames;
var vanadiumWrapper = args.vanadiumWrapper;
- var identity = args.identity;
+
+ this.status.rpc = 'starting';
+ return vanadiumWrapper.server(
+ vanadium.naming.join(mountNames.device, 'rpc'), this.server)
+ .then(function(server) {
+ self.status.rpc = 'ready';
+ return server;
+ }, function(err) {
+ self.status.rpc = 'failed';
+ throw err;
+ });
+ },
+
+ connectSyncbase: function(args) {
+ var self = this;
+ var vanadiumWrapper = args.vanadiumWrapper;
var sbName = queryString.parse(location.search).syncbase || 4000;
if ($.isNumeric(sbName)) {
sbName = '/localhost:' + sbName;
}
- var startSyncbase = vanadiumWrapper
+ this.status.syncbase = 'starting';
+ return vanadiumWrapper
.syncbase(sbName)
.then(function(syncbase) {
syncbase.onError.add(self.onError);
syncbase.onUpdate.add(self.processUpdates);
-
- /* TODO(rosswang): Once Vanadium supports global sync-group admin
- * creation, remove this. For now, use the first local SyncBase
- * instance to administrate. */
- var sgAdmin = vanadium.naming.join(
- identity.mountNames.user, 'sgadmin');
- return vanadiumWrapper.mount(sgAdmin, sbName,
- vanadiumWrapper.multiMount.FAIL)
- .then(function() {
- var sg = syncbase.syncGroup(sgAdmin, 'trip');
-
- var spec = sg.buildSpec(
- [''],
- [vanadium.naming.join(identity.mountNames.user, 'sgmt')]
- );
-
- /* TODO(rosswang): Right now, duplicate SyncBase creates on
- * different SyncBase instances results in siloed SyncGroups.
- * Revisit this logic once it merges properly. */
- return sg.joinOrCreate(spec);
- })
- .then(function() {
- return syncbase;
- });
+ self.status.syncbase = 'ready';
+ return syncbase;
+ }, function(err) {
+ self.status.syncbase = 'failed';
+ throw err;
});
+ },
- return Promise.all([
- vanadiumWrapper.server(
- vanadium.naming.join(identity.mountNames.device, 'rpc'), this.server),
- startSyncbase
- ]).then(function(values) {
- return {
- server: values[0],
- syncbase: values[1]
- };
- });
+ createSyncgroupManager: function(args, syncbase) {
+ var gm = new SyncgroupManager(args.identity, args.vanadiumWrapper,
+ syncbase, args.mountNames);
+ gm.onError.add(this.onError);
+
+ return gm;
+ },
+
+ createPrimarySyncGroup: function(groupManager) {
+ var self = this;
+
+ this.status.tripSyncGroup = 'creating';
+ return groupManager.createSyncGroup('trip', [''])
+ .then(function(sg) {
+ self.status.tripSyncGroup = 'created';
+ return sg;
+ }, function(err) {
+ self.status.tripSyncGroup = 'failed';
+ throw err;
+ });
}
},
- constants: [ 'startup' ],
+ constants: [ 'invitationManager', 'startup', 'status' ],
events: {
/**
* @param newSize
@@ -461,12 +471,13 @@
},
/**
- * @param promise a promise that produces { mountName, vanadiumWrapper }.
+ * @param prereqs a promise that produces { identity, mountNames,
+ * vanadiumWrapper }.
* @mapsDependencies an object with the following keys:
* maps
* placesService
*/
- init: function(promise, mapsDependencies) {
+ init: function(prereqs, mapsDependencies) {
var self = this;
this.mapsDeps = mapsDependencies;
@@ -474,9 +485,35 @@
this.tripStatus = {};
this.messages = {};
this.destRecords = [];
+ this.status = {};
this.server = new vdlTravel.TravelSync();
- this.startup = promise.then(this.start);
+ var startRpc = prereqs.then(this.serve);
+ var startSyncbase = prereqs.then(this.connectSyncbase);
+ var startSyncgroupManager = Promise
+ .all([prereqs, startSyncbase])
+ .then(function(args) {
+ return self.createSyncgroupManager(args[0], args[1]);
+ });
+ var createPrimarySyncGroup = startSyncgroupManager
+ .then(this.createPrimarySyncGroup);
+
+ this.startup = Promise.all([
+ startRpc,
+ startSyncbase,
+ startSyncgroupManager,
+ createPrimarySyncGroup
+ ]).then(function(values) {
+ return {
+ server: values[0],
+ syncbase: values[1],
+ groupManager: values[2]
+ };
+ });
+
+ this.invitationManager = new InvitationManager(prereqs,
+ startSyncgroupManager);
+ this.invitationManager.onError.add(this.onError);
this.handleDestinationPlaceChange = function() {
self.updateDestinationPlace(this);
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index ba2a3a0..e136879 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -77,6 +77,16 @@
return refreshName();
},
+ getPermissions: function(name) {
+ return this.runtime.namespace().getPermissions(
+ this.runtime.getContext(), name);
+ },
+
+ setPermissions: function(name, perms) {
+ return this.runtime.namespace().setPermissions(
+ this.runtime.getContext(), name, perms);
+ },
+
/**
* @param endpoint Vanadium name
* @returns a promise resolving to a client or rejecting with an error.
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 0c2c3e7..974720b 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -85,7 +85,7 @@
var db = app.noSqlDatabase('db');
return setUp(context, app, db).then(function() {
- return new SyncbaseWrapper(context, db);
+ return new SyncbaseWrapper(context, db, mountName);
});
}
},
@@ -341,16 +341,21 @@
}
},
+ constants: [ 'mountName' ],
+
events: {
onError: 'memory',
onUpdate: 'memory'
},
- init: function(context, db) {
+ init: function(context, db, mountName) {
+ // TODO(rosswang): mountName probably won't be necessary after SyncGroup
+ // admin instances are hosted (see group-manager).
var self = this;
this.context = context;
this.db = db;
this.t = db.table('t');
+ this.mountName = mountName;
this.writes = new Set();
diff --git a/test/identity.js b/test/identity.js
index 98c2988..42d7d2e 100644
--- a/test/identity.js
+++ b/test/identity.js
@@ -6,23 +6,24 @@
var Identity = require('../src/identity');
-function verifyAutoAccountName(t, n) {
- t.assert(n.length > 1, 'auto-generated username is nontrivial');
+function verifyAutoAccount(t, i) {
+ t.equals(i.account, '...', 'unknown account defaults to open');
+ t.assert(i.username.length > 1, 'auto-generated username is nontrivial');
}
test('auto-generated username from unknown', function(t) {
- var a = new Identity('unknown').username,
- b = new Identity('unknown').username;
- verifyAutoAccountName(t, a);
- verifyAutoAccountName(t, b);
- t.notEqual(b, a, 'auto-generated username is unique');
+ var a = new Identity('unknown'),
+ b = new Identity('unknown');
+ verifyAutoAccount(t, a);
+ verifyAutoAccount(t, b);
+ t.notEqual(b.username, a.username, 'auto-generated username is unique');
t.end();
});
function testAutoExtract(t, r) {
- var n = new Identity(r).username;
- verifyAutoAccountName(t, n);
- t.not(n, r);
+ var i = new Identity(r);
+ verifyAutoAccount(t, i);
+ t.not(i.username, r);
t.end();
}
@@ -44,6 +45,8 @@
test('init', function(t) {
var i = new Identity(testAccountName);
+ t.equals(i.account, 'dev.v.io/u/joeuser@google.com',
+ 'should generalize a dev.v.io account name');
t.equals(i.username, 'joeuser@google.com',
'should extract a username from a dev.v.io account name');
var expectedPrefix = 'joeuser@google.com/desktop_';
diff --git a/tools/start_services.sh b/tools/start_services.sh
index 1247ac5..b8e74a5 100644
--- a/tools/start_services.sh
+++ b/tools/start_services.sh
@@ -25,14 +25,17 @@
fi
}
main() {
+ PATH=${PATH}:${V23_ROOT}/release/go/bin
local -r TMP=tmp
+ local -r CREDS=./tmp/creds/${creds-}
local -r PORT=${port-4000}
local -r MOUNTTABLED_ADDR=":$((PORT+1))"
local -r SYNCBASED_ADDR=":$((PORT))"
+ local -r BLESSINGS=`principal dump --v23.credentials=${CREDS} -s=true`
mkdir -p $TMP
- ${V23_ROOT}/release/go/bin/mounttabled \
+ mounttabled \
--v23.tcp.address=${MOUNTTABLED_ADDR} \
- --v23.credentials=${TMP}/creds &
+ --v23.credentials=${CREDS} &
./bin/syncbased \
--v=5 \
--alsologtostderr=false \
@@ -40,8 +43,8 @@
--name=syncbase \
--v23.namespace.root=/${MOUNTTABLED_ADDR} \
--v23.tcp.address=${SYNCBASED_ADDR} \
- --v23.credentials=${TMP}/creds \
- --v23.permissions.literal='{"Admin":{"In":["..."]},"Write":{"In":["..."]},"Read":{"In":["..."]},"Resolve":{"In":["..."]},"Debug":{"In":["..."]}}'
+ --v23.credentials=${CREDS} \
+ --v23.permissions.literal="{\"Admin\":{\"In\":[\"${BLESSINGS}\"]},\"Write\":{\"In\":[\"${BLESSINGS}\"]},\"Read\":{\"In\":[\"${BLESSINGS}\"]},\"Resolve\":{\"In\":[\"${BLESSINGS}\"]},\"Debug\":{\"In\":[\"...\"]}}"
tail -f /dev/null # wait forever
}
main "$@"