Browse Source

merge with /devel

Xavier Priour 9 years ago
parent
commit
a7427b9ae4

+ 39 - 39
.eslintrc

@@ -1,7 +1,14 @@
 ecmaFeatures:
   experimentalObjectRestSpread: true
 
+plugins:
+  - meteor
+
+parser: babel-eslint
+
 rules:
+  strict: 0
+  no-undef: 2
   accessor-pairs: 2
   comma-dangle: [2, 'always-multiline']
   consistent-return: 2
@@ -43,36 +50,39 @@ rules:
   prefer-spread: 2
   prefer-template: 2
 
-globals:
-  # Meteor globals
-  Meteor: false
-  DDP: false
-  Mongo: false
-  Session: false
-  Accounts: false
-  Template: false
-  Blaze: false
-  UI: false
-  Match: false
-  check: false
-  Tracker: false
-  Deps: false
-  ReactiveVar: false
-  EJSON: false
-  HTTP: false
-  Email: false
-  Assets: false
-  Handlebars: false
-  Package: false
-  App: false
-  Npm: false
-  Tinytest: false
-  Random: false
-  HTML: false
+  # eslint-plugin-meteor
+  ## Meteor API
+  meteor/globals: 2
+  meteor/core: 2
+  meteor/pubsub: 2
+  meteor/methods: 2
+  meteor/check: 2
+  meteor/connections: 2
+  meteor/collections: 2
+  meteor/session: [2, 'no-equal']
+
+  ## Best practices
+  meteor/no-session: 0
+  meteor/no-zero-timeout: 2
+  meteor/no-blaze-lifecycle-assignment: 2
 
+settings:
+  meteor:
+
+    # Our collections
+    collections:
+      - AccountsTemplates
+      - Activities
+      - Attachments
+      - Boards
+      - CardComments
+      - Cards
+      - Lists
+      - UnsavedEditCollection
+      - Users
+
+globals:
   # Exported by packages we use
-  '$': false
-  _: false
   autosize: false
   Avatar: true
   Avatars: true
@@ -80,6 +90,7 @@ globals:
   BlazeLayout: false
   DocHead: false
   ESSearchResults: false
+  FastRender: false
   FlowRouter: false
   FS: false
   getSlug: false
@@ -97,17 +108,6 @@ globals:
   T9n: false
   TAPi18n: false
 
-  # Our collections
-  AccountsTemplates: true
-  Activities: true
-  Attachments: true
-  Boards: true
-  CardComments: true
-  Cards: true
-  Lists: true
-  UnsavedEditCollection: true
-  Users: true
-
   # Our objects
   CSSEvents: true
   EscapeActions: true

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@
 .tx/
 *.sublime-workspace
 tmp/
+node_modules/

+ 1 - 3
.meteor/packages

@@ -2,9 +2,6 @@
 #
 # 'meteor add' and 'meteor remove' will edit this file for you,
 # but you can also edit it by hand.
-#
-# XXX Should we replace tmeasday:presence by 3stack:presence? Or maybe the
-# packages will merge in the future?
 
 meteor-base
 
@@ -52,6 +49,7 @@ audit-argument-checks
 kadira:blaze-layout
 kadira:dochead
 kadira:flow-router
+meteorhacks:fast-render
 meteorhacks:picker
 meteorhacks:subs-manager
 mquandalle:autofocus

+ 3 - 0
.meteor/versions

@@ -35,6 +35,7 @@ cfs:tempstore@0.1.5
 cfs:upload-http@0.0.20
 cfs:worker@0.1.4
 check@1.1.0
+chuangbo:cookie@1.1.0
 coffeescript@1.0.11
 cosmos:browserify@0.8.3
 dburles:collection-helpers@1.0.4
@@ -75,6 +76,8 @@ meteor-base@1.0.1
 meteor-platform@1.2.3
 meteorhacks:aggregate@1.3.0
 meteorhacks:collection-utils@1.2.0
+meteorhacks:fast-render@2.10.0
+meteorhacks:inject-data@1.4.1
 meteorhacks:picker@1.0.3
 meteorhacks:subs-manager@1.6.2
 meteorspark:util@0.2.0

+ 2 - 3
.travis.yml

@@ -3,7 +3,6 @@ language: node_js
 node_js:
   - "0.10.40"
 install:
-  - "npm install -g eslint"
-  - "npm install -g eslint-plugin-meteor"
+  - "npm install"
 script:
-  - "eslint ./"
+  - "npm test"

+ 6 - 2
History.md

@@ -3,9 +3,13 @@
 This release features:
 
 * Card import from Trello
+* Autocompletion in the minicard editor. Start with <kbd>@</kbd> to start the
+  a board member autocompletion, or <kbd>#</kbd> for a label.
+* Accelerate the initial page rendering by sending the data on the intial HTTP
+  response instead of waiting for the DDP connection to open.
 
-Thanks to GitHub users AlexanderS, fisle, ndarilek, and xavierpriour for their
-contributions.
+Thanks to GitHub users AlexanderS, fisle, FuzzyWuzzie, ndarilek, and
+xavierpriour for their contributions.
 
 # v0.9
 

+ 2 - 2
client/components/activities/activities.js

@@ -101,9 +101,9 @@ BlazeComponent.extendComponent({
       },
       'submit .js-edit-comment'(evt) {
         evt.preventDefault();
-        const commentText = this.currentComponent().getValue();
+        const commentText = this.currentComponent().getValue().trim();
         const commentId = Template.parentData().commentId;
-        if ($.trim(commentText)) {
+        if (commentText) {
           CardComments.update(commentId, {
             $set: {
               text: commentText,

+ 6 - 4
client/components/activities/comments.js

@@ -24,11 +24,12 @@ BlazeComponent.extendComponent({
       },
       'submit .js-new-comment-form'(evt) {
         const input = this.getInput();
-        if ($.trim(input.val())) {
+        const text = input.val().trim();
+        if (text) {
           CardComments.insert({
+            text,
             boardId: this.currentData().boardId,
             cardId: this.currentData()._id,
-            text: input.val(),
           });
           resetCommentInput(input);
           Tracker.flush();
@@ -72,8 +73,9 @@ EscapeActions.register('inlinedForm',
       docId: Session.get('currentCard'),
     };
     const commentInput = $('.js-new-comment-input');
-    if ($.trim(commentInput.val())) {
-      UnsavedEdits.set(draftKey, commentInput.val());
+    const draft = commentInput.val().trim();
+    if (draft) {
+      UnsavedEdits.set(draftKey, draft);
     } else {
       UnsavedEdits.reset(draftKey);
     }

+ 10 - 7
client/components/boards/boardBody.js

@@ -34,7 +34,7 @@ BlazeComponent.extendComponent({
   },
 
   openNewListForm() {
-    this.childrenComponents('addListForm')[0].open();
+    this.childComponents('addListForm')[0].open();
   },
 
   // XXX Flow components allow us to avoid creating these two setter methods by
@@ -45,7 +45,8 @@ BlazeComponent.extendComponent({
   },
 
   scrollLeft(position = 0) {
-    this.$('.js-lists').animate({
+    const lists = this.$('.js-lists');
+    lists && lists.animate({
       scrollLeft: position,
     });
   },
@@ -179,22 +180,24 @@ BlazeComponent.extendComponent({
 
   // Proxy
   open() {
-    this.childrenComponents('inlinedForm')[0].open();
+    this.childComponents('inlinedForm')[0].open();
   },
 
   events() {
     return [{
       submit(evt) {
         evt.preventDefault();
-        const title = this.find('.list-name-input');
-        if ($.trim(title.value)) {
+        const titleInput = this.find('.list-name-input');
+        const title = titleInput.value.trim();
+        if (title) {
           Lists.insert({
-            title: title.value,
+            title,
             boardId: Session.get('currentBoard'),
             sort: $('.list').length,
           });
 
-          title.value = '';
+          titleInput.value = '';
+          titleInput.focus();
         }
       },
     }];

+ 4 - 4
client/components/cards/cardDetails.js

@@ -13,7 +13,7 @@ BlazeComponent.extendComponent({
   },
 
   reachNextPeak() {
-    const activitiesComponent = this.childrenComponents('activities')[0];
+    const activitiesComponent = this.childComponents('activities')[0];
     activitiesComponent.loadNextPage();
   },
 
@@ -75,8 +75,8 @@ BlazeComponent.extendComponent({
       },
       'submit .js-card-details-title'(evt) {
         evt.preventDefault();
-        const title = this.currentComponent().getValue();
-        if ($.trim(title)) {
+        const title = this.currentComponent().getValue().trim();
+        if (title) {
           this.data().setTitle(title);
         }
       },
@@ -106,7 +106,7 @@ BlazeComponent.extendComponent({
 
   close(isReset = false) {
     if (this.isOpen.get() && !isReset) {
-      const draft = $.trim(this.getValue());
+      const draft = this.getValue().trim();
       if (draft !== Cards.findOne(Session.get('currentCard')).description) {
         UnsavedEdits.set(this._getUnsavedEditKey(), this.getValue());
       }

+ 8 - 1
client/components/forms/forms.styl

@@ -617,8 +617,15 @@ button
         margin-right: 5px
         vertical-align: middle
 
+      .minicard-label
+        width: 11px
+        height: @width
+        border-radius: 2px
+        margin: 2px 7px -2px -2px
+        display: inline-block
+
     &.active
       background: #005377
 
-      a
+      a, .quiet
         color: white

+ 1 - 1
client/components/lists/list.js

@@ -7,7 +7,7 @@ BlazeComponent.extendComponent({
 
   // Proxy
   openForm(options) {
-    this.childrenComponents('listBody')[0].openForm(options);
+    this.childComponents('listBody')[0].openForm(options);
   },
 
   onCreated() {

+ 13 - 2
client/components/lists/listBody.jade

@@ -22,9 +22,20 @@ template(name="listBody")
 
 template(name="addCardForm")
   .minicard.minicard-composer.js-composer
-    .minicard-detailss.clearfix
-      textarea.minicard-composer-textarea.js-card-title(autofocus)
+    if getLabels
+      .minicard-labels
+        each getLabels
+          .minicard-label(class="card-label-{{color}}" title="{{name}}")
+    textarea.minicard-composer-textarea.js-card-title(autofocus)
+    if members.get
       .minicard-members.js-minicard-composer-members
+        each members.get
+          +userAvatar(userId=this)
+
   .add-controls.clearfix
     button.primary.confirm(type="submit") {{_ 'add'}}
     a.fa.fa-times-thin.js-close-inlined-form
+
+template(name="autocompleteLabelLine")
+  .minicard-label(class="card-label-{{colorName}}" title=labelName)
+  span(class="{{#if hasNoName}}quiet{{/if}}")= labelName

+ 103 - 4
client/components/lists/listBody.js

@@ -11,8 +11,8 @@ BlazeComponent.extendComponent({
     options = options || {};
     options.position = options.position || 'top';
 
-    const forms = this.childrenComponents('inlinedForm');
-    let form = _.find(forms, (component) => {
+    const forms = this.childComponents('inlinedForm');
+    let form = forms.find((component) => {
       return component.data().position === options.position;
     });
     if (!form && forms.length > 0) {
@@ -26,8 +26,10 @@ BlazeComponent.extendComponent({
     const firstCardDom = this.find('.js-minicard:first');
     const lastCardDom = this.find('.js-minicard:last');
     const textarea = $(evt.currentTarget).find('textarea');
-    const title = textarea.val();
     const position = this.currentData().position;
+    const title = textarea.val().trim();
+
+    const formComponent = this.childComponents('addCardForm')[0];
     let sortIndex;
     if (position === 'top') {
       sortIndex = Utils.calculateIndex(null, firstCardDom).base;
@@ -35,9 +37,14 @@ BlazeComponent.extendComponent({
       sortIndex = Utils.calculateIndex(lastCardDom, null).base;
     }
 
-    if ($.trim(title)) {
+    const members = formComponent.members.get();
+    const labelIds = formComponent.labels.get();
+
+    if (title) {
       const _id = Cards.insert({
         title,
+        members,
+        labelIds,
         listId: this.data()._id,
         boardId: this.data().board()._id,
         sort: sortIndex,
@@ -53,6 +60,8 @@ BlazeComponent.extendComponent({
       if (position === 'bottom') {
         this.scrollToBottom();
       }
+
+      formComponent.reset();
     }
   },
 
@@ -100,11 +109,39 @@ BlazeComponent.extendComponent({
   },
 }).register('listBody');
 
+function toggleValueInReactiveArray(reactiveValue, value) {
+  const array = reactiveValue.get();
+  const valueIndex = array.indexOf(value);
+  if (valueIndex === -1) {
+    array.push(value);
+  } else {
+    array.splice(valueIndex, 1);
+  }
+  reactiveValue.set(array);
+}
+
 BlazeComponent.extendComponent({
   template() {
     return 'addCardForm';
   },
 
+  onCreated() {
+    this.labels = new ReactiveVar([]);
+    this.members = new ReactiveVar([]);
+  },
+
+  reset() {
+    this.labels.set([]);
+    this.members.set([]);
+  },
+
+  getLabels() {
+    const currentBoardId = Session.get('currentBoard');
+    return Boards.findOne(currentBoardId).labels.filter((label) => {
+      return this.labels.get().indexOf(label._id) > -1;
+    });
+  },
+
   pressKey(evt) {
     // Pressing Enter should submit the card
     if (evt.keyCode === 13) {
@@ -140,4 +177,66 @@ BlazeComponent.extendComponent({
       keydown: this.pressKey,
     }];
   },
+
+  onRendered() {
+    const editor = this;
+    this.$('textarea').escapeableTextComplete([
+      // User mentions
+      {
+        match: /\B@(\w*)$/,
+        search(term, callback) {
+          const currentBoard = Boards.findOne(Session.get('currentBoard'));
+          callback($.map(currentBoard.members, (member) => {
+            const user = Users.findOne(member.userId);
+            return user.username.indexOf(term) === 0 ? user : null;
+          }));
+        },
+        template(user) {
+          return user.username;
+        },
+        replace(user) {
+          toggleValueInReactiveArray(editor.members, user._id);
+          return '';
+        },
+        index: 1,
+      },
+
+      // Labels
+      {
+        match: /\B#(\w*)$/,
+        search(term, callback) {
+          const currentBoard = Boards.findOne(Session.get('currentBoard'));
+          callback($.map(currentBoard.labels, (label) => {
+            if (label.name.indexOf(term) > -1 ||
+                label.color.indexOf(term) > -1) {
+              return label;
+            }
+          }));
+        },
+        template(label) {
+          return Blaze.toHTMLWithData(Template.autocompleteLabelLine, {
+            hasNoName: !Boolean(label.name),
+            colorName: label.color,
+            labelName: label.name || label.color,
+          });
+        },
+        replace(label) {
+          toggleValueInReactiveArray(editor.labels, label._id);
+          return '';
+        },
+        index: 1,
+      },
+    ], {
+      // When the autocomplete menu is shown we want both a press of both `Tab`
+      // or `Enter` to validation the auto-completion. We also need to stop the
+      // event propagation to prevent the card from submitting (on `Enter`) or
+      // going on the next column (on `Tab`).
+      onKeydown(evt, commands) {
+        if (evt.keyCode === 9 || evt.keyCode === 13) {
+          evt.stopPropagation();
+          return commands.KEY_ENTER;
+        }
+      },
+    });
+  },
 }).register('addCardForm');

+ 3 - 3
client/components/lists/listHeader.js

@@ -5,10 +5,10 @@ BlazeComponent.extendComponent({
 
   editTitle(evt) {
     evt.preventDefault();
-    const newTitle = this.childrenComponents('inlinedForm')[0].getValue();
+    const newTitle = this.childComponents('inlinedForm')[0].getValue().trim();
     const list = this.currentData();
-    if ($.trim(newTitle)) {
-      list.rename(newTitle);
+    if (newTitle) {
+      list.rename(newTitle.trim());
     }
   },
 

+ 4 - 4
client/components/main/editor.js

@@ -8,8 +8,8 @@ Template.editor.onRendered(() => {
     {
       match: /\B:([\-+\w]*)$/,
       search(term, callback) {
-        callback($.map(Emoji.values, (emoji) => {
-          return emoji.indexOf(term) === 0 ? emoji : null;
+        callback(Emoji.values.map((emoji) => {
+          return emoji.includes(term) ? emoji : null;
         }));
       },
       template(value) {
@@ -28,9 +28,9 @@ Template.editor.onRendered(() => {
       match: /\B@(\w*)$/,
       search(term, callback) {
         const currentBoard = Boards.findOne(Session.get('currentBoard'));
-        callback($.map(currentBoard.members, (member) => {
+        callback(currentBoard.members.map((member) => {
           const username = Users.findOne(member.userId).username;
-          return username.indexOf(term) === 0 ? username : null;
+          return username.includes(term) ? username : null;
         }));
       },
       template(value) {

+ 1 - 1
client/components/sidebar/sidebar.js

@@ -54,7 +54,7 @@ BlazeComponent.extendComponent({
   },
 
   reachNextPeak() {
-    const activitiesComponent = this.childrenComponents('activities')[0];
+    const activitiesComponent = this.childComponents('activities')[0];
     activitiesComponent.loadNextPage();
   },
 

+ 4 - 4
client/components/users/userHeader.js

@@ -18,9 +18,9 @@ Template.memberMenuPopup.events({
 Template.editProfilePopup.events({
   submit(evt, tpl) {
     evt.preventDefault();
-    const fullname = $.trim(tpl.find('.js-profile-fullname').value);
-    const username = $.trim(tpl.find('.js-profile-username').value);
-    const initials = $.trim(tpl.find('.js-profile-initials').value);
+    const fullname = tpl.find('.js-profile-fullname').value.trim();
+    const username = tpl.find('.js-profile-username').value.trim();
+    const initials = tpl.find('.js-profile-initials').value.trim();
     Users.update(Meteor.userId(), {$set: {
       'profile.fullname': fullname,
       'profile.initials': initials,
@@ -41,7 +41,7 @@ Template.changePasswordPopup.onRendered(function() {
 
 Template.changeLanguagePopup.helpers({
   languages() {
-    return TAPi18n.getLanguages().map((lang, tag) => {
+    return _.map(TAPi18n.getLanguages(), (lang, tag) => {
       const name = lang.name;
       return { tag, name };
     });

+ 1 - 1
client/lib/modal.js

@@ -21,7 +21,7 @@ window.Modal = new class {
     }
   }
 
-  open(modalName, { onCloseGoTo = ''}) {
+  open(modalName, { onCloseGoTo = ''} = {}) {
     this._currentModal.set(modalName);
     this._onCloseGoTo = onCloseGoTo;
   }

+ 28 - 4
client/lib/textComplete.js

@@ -3,8 +3,23 @@
 // of the vanilla `textcomplete`.
 let dropdownMenuIsOpened = false;
 
-$.fn.escapeableTextComplete = function(...args) {
-  this.textcomplete(...args);
+$.fn.escapeableTextComplete = function(strategies, options, ...otherArgs) {
+  // When the autocomplete menu is shown we want both a press of both `Tab`
+  // or `Enter` to validation the auto-completion. We also need to stop the
+  // event propagation to prevent EscapeActions side effect, for instance the
+  // minicard submission (on `Enter`) or going on the next column (on `Tab`).
+  options = {
+    onKeydown(evt, commands) {
+      if (evt.keyCode === 9 || evt.keyCode === 13) {
+        evt.stopPropagation();
+        return commands.KEY_ENTER;
+      }
+    },
+    ...options,
+  };
+
+  // Proxy to the vanilla jQuery component
+  this.textcomplete(strategies, options, ...otherArgs);
 
   // Since commit d474017 jquery-textComplete automatically closes a potential
   // opened dropdown menu when the user press Escape. This behavior conflicts
@@ -18,7 +33,14 @@ $.fn.escapeableTextComplete = function(...args) {
     },
     'textComplete:hide'() {
       Tracker.afterFlush(() => {
-        dropdownMenuIsOpened = false;
+        // XXX Hack. We unfortunately need to set a setTimeout here to make the
+        // `noClickEscapeOn` work bellow, otherwise clicking on a autocomplete
+        // item will close both the autocomplete menu (as expected) but also the
+        // next item in the stack (for example the minicard editor) which we
+        // don't want.
+        setTimeout(() => {
+          dropdownMenuIsOpened = false;
+        }, 100);
       });
     },
   });
@@ -26,5 +48,7 @@ $.fn.escapeableTextComplete = function(...args) {
 
 EscapeActions.register('textcomplete',
   () => {},
-  () => dropdownMenuIsOpened
+  () => dropdownMenuIsOpened, {
+    noClickEscapeOn: '.textcomplete-dropdown',
+  }
 );

+ 0 - 0
client/config/accounts.js → config/accounts.js


+ 1 - 1
models/attachments.js

@@ -1,4 +1,4 @@
-Attachments = new FS.Collection('attachments', {
+Attachments = new FS.Collection('attachments', { // eslint-disable-line meteor/collections
   stores: [
 
     // XXX Add a new store for cover thumbnails so we don't load big images in

+ 2 - 2
models/boards.js

@@ -97,11 +97,11 @@ Boards.helpers({
   },
 
   labelIndex(labelId) {
-    return _.indexOf(_.pluck(this.labels, '_id'), labelId);
+    return _.pluck(this.labels, '_id').indexOf(labelId);
   },
 
   memberIndex(memberId) {
-    return _.indexOf(_.pluck(this.members, 'userId'), memberId);
+    return _.pluck(this.members, 'userId').indexOf(memberId);
   },
 
   absoluteUrl() {

+ 18 - 14
models/users.js

@@ -1,4 +1,4 @@
-Users = Meteor.users;
+Users = Meteor.users; // eslint-disable-line meteor/collections
 
 // Search a user in the complete server database by its name or username. This
 // is used for instance to add a new user to a board.
@@ -8,7 +8,23 @@ Users.initEasySearch(searchInFields, {
   returnFields: [...searchInFields, 'profile.avatarUrl'],
 });
 
-
+if (Meteor.isClient) {
+  Users.helpers({
+    isBoardMember() {
+      const board = Boards.findOne(Session.get('currentBoard'));
+      return board &&
+        _.contains(_.pluck(board.members, 'userId'), this._id) &&
+        _.where(board.members, {userId: this._id})[0].isActive;
+    },
+
+    isBoardAdmin() {
+      const board = Boards.findOne(Session.get('currentBoard'));
+      return board &&
+        this.isBoardMember(board) &&
+        _.where(board.members, {userId: this._id})[0].isAdmin;
+    },
+  });
+}
 
 Users.helpers({
   boards() {
@@ -25,18 +41,6 @@ Users.helpers({
     return _.contains(starredBoards, boardId);
   },
 
-  isBoardMember() {
-    const board = Boards.findOne(Session.get('currentBoard'));
-    return board && _.contains(_.pluck(board.members, 'userId'), this._id) &&
-                         _.where(board.members, {userId: this._id})[0].isActive;
-  },
-
-  isBoardAdmin() {
-    const board = Boards.findOne(Session.get('currentBoard'));
-    return board && this.isBoardMember(board) &&
-                          _.where(board.members, {userId: this._id})[0].isAdmin;
-  },
-
   getAvatarUrl() {
     // Although we put the avatar picture URL in the `profile` object, we need
     // to support Sandstorm which put in the `picture` attribute by default.

+ 24 - 0
package.json

@@ -0,0 +1,24 @@
+{
+  "name": "wekan",
+  "version": "1.0.0",
+  "description": "The open-source Trello-like kanban",
+  "private": true,
+  "scripts": {
+    "lint": "eslint .",
+    "test": "npm run --silent lint"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/wekan/wekan.git"
+  },
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/wekan/wekan/issues"
+  },
+  "homepage": "http://wekan.io",
+  "devDependencies": {
+    "babel-eslint": "4.1.3",
+    "eslint": "1.7.3",
+    "eslint-plugin-meteor": "1.7.0"
+  }
+}

+ 30 - 7
sandstorm.js

@@ -22,12 +22,12 @@ if (isSandstorm && Meteor.isServer) {
   };
 
   function updateUserPermissions(userId, permissions) {
-    const isActive = permissions.includes('participate');
-    const isAdmin = permissions.includes('configure');
+    const isActive = permissions.indexOf('participate') > -1;
+    const isAdmin = permissions.indexOf('configure') > -1;
     const permissionDoc = { userId, isActive, isAdmin };
 
     const boardMembers = Boards.findOne(sandstormBoard._id).members;
-    const memberIndex = _.indexOf(_.pluck(boardMembers, 'userId'), userId);
+    const memberIndex = _.pluck(boardMembers, 'userId').indexOf(userId);
 
     let modifier;
     if (memberIndex > -1)
@@ -78,17 +78,40 @@ if (isSandstorm && Meteor.isServer) {
   // unique board document. Note that when the `Users.after.insert` hook is
   // called, the user is inserted into the database but not connected. So
   // despite the appearances `userId` is null in this block.
-  //
-  // XXX We should support the `preferredHandle` exposed by Sandstorm
   Users.after.insert((userId, doc) => {
     if (!Boards.findOne(sandstormBoard._id)) {
-      Boards.insert(sandstormBoard, {validate: false});
+      Boards.insert(sandstormBoard, { validate: false });
       Activities.update(
         { activityTypeId: sandstormBoard._id },
         { $set: { userId: doc._id }}
       );
     }
 
+    // We rely on username uniqueness for the user mention feature, but
+    // Sandstorm doesn't enforce this property -- see #352. Our strategy to
+    // generate unique usernames from the Sandstorm `preferredHandle` is to
+    // append a number that we increment until we generate a username that no
+    // one already uses (eg, 'max', 'max1', 'max2').
+    function generateUniqueUsername(username, appendNumber) {
+      return username + String(appendNumber === 0 ? '' : appendNumber);
+    }
+
+    const username = doc.services.sandstorm.preferredHandle;
+    let appendNumber = 0;
+    while (Users.findOne({
+      _id: { $ne: doc._id },
+      username: generateUniqueUsername(username, appendNumber),
+    })) {
+      appendNumber += 1;
+    }
+
+    Users.update(doc._id, {
+      $set: {
+        username: generateUniqueUsername(username, appendNumber),
+        'profile.fullname': doc.services.sandstorm.name,
+      },
+    });
+
     updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
   });
 
@@ -109,7 +132,7 @@ if (isSandstorm && Meteor.isClient) {
   // sandstorm client to return relative paths instead of absolutes.
   const _absoluteUrl = Meteor.absoluteUrl;
   const _defaultOptions = Meteor.absoluteUrl.defaultOptions;
-  Meteor.absoluteUrl = (path, options) => {
+  Meteor.absoluteUrl = (path, options) => { // eslint-disable-line meteor/core
     const url = _absoluteUrl(path, options);
     return url.replace(/^https?:\/\/127\.0\.0\.1:[0-9]{2,5}/, '');
   };

+ 7 - 0
server/publications/fast-render.js

@@ -0,0 +1,7 @@
+FastRender.onAllRoutes(function() {
+  this.subscribe('boards');
+});
+
+FastRender.route('/b/:id/:slug', function({ id }) {
+  this.subscribe('board', id);
+});