(function () {
  // N.B.: "window.status" is an HTML-defined property, and always a
  // string, so naming things at "global"-level `status` will not have
  // the desired effect!
  assertion type online();
  assertion type present(email);

  assertion type uiTemplate(name, data) = "ui-template";

  assertion type permitted(issuer, email, permission, isDelegable);
  assertion type grant(issuer, grantor, grantee, permission, isDelegable);
  assertion type permissionRequest(issuer, grantee, permission) = "permission-request";

  assertion type conversation(id, title, creator, blurb);
  assertion type invitation(conversationId, inviter, invitee);
  assertion type inConversation(conversationId, member) = "in-conversation";
  assertion type post(id, timestamp, conversationId, author, items);

  message type createResource(description) = "create-resource";
  message type updateResource(description) = "update-resource";
  message type deleteResource(description) = "delete-resource";

  assertion type pFollow(email) = "p:follow";
  // assertion type pInvite(email) = "p:invite";
  // assertion type pSeePresence(email) = "p:see-presence";

  assertion type contactListEntry(owner, member) = "contact-list-entry";

  assertion type question(id, timestamp, klass, target, title, blurb, type);
  assertion type answer(id, value);
  assertion type yesNoQuestion(falseValue, trueValue) = "yes/no-question";
  assertion type optionQuestion(options) = "option-question";
  // ^ options = [[Any, Markdown]]
  assertion type textQuestion(isMultiline) = "text-question";
  assertion type acknowledgeQuestion() = "acknowledge-question";

  //---------------------------------------------------------------------------
  // Local assertions and messages

  assertion type selectedCid(cid); // currently-selected conversation ID, or null
  message type windowWidthChanged(newWidth);

  assertion type draftItem(timestamp, dataURL);
  message type draftSent();

  //---------------------------------------------------------------------------

  var brokerConnected = Syndicate.Broker.brokerConnected;
  var brokerConnection = Syndicate.Broker.brokerConnection;
  var toBroker = Syndicate.Broker.toBroker;
  var fromBroker = Syndicate.Broker.fromBroker;
  var forceBrokerDisconnect = Syndicate.Broker.forceBrokerDisconnect;

  ///////////////////////////////////////////////////////////////////////////

  function compute_broker_url() {
    var u = new URL(document.location);
    u.protocol = (u.protocol === 'http:') ? 'ws:' : 'wss:';
    u.pathname = '/broker';
    u.hash = '';
    return u.toString();
  }

  var sessionInfo = {}; // filled in by 'load' event handler
  var brokerUrl = compute_broker_url();

  function outbound(x) {
    return toBroker(brokerUrl, x);
  }

  function inbound(x) {
    return fromBroker(brokerUrl, x);
  }

  function avatar(email) {
    return 'https://www.gravatar.com/avatar/' + md5(email.trim().toLowerCase()) + '?s=48&d=retro';
  }

  ///////////////////////////////////////////////////////////////////////////

  document.addEventListener('dragover', function (e) {
    e.preventDefault(); // make it so drag-and-drop doesn't load the dropped object into the browser
  });

  window.addEventListener('load', function () {
    if (document.body.id === 'webchat-main') {
      $('head meta').each(function (_i, tag) {
        var itemprop = tag.attributes.itemprop;
        var prefix = 'webchat-session-';
        if (itemprop && itemprop.value.startsWith(prefix)) {
          var key = itemprop.value.substring(prefix.length);
          var value = tag.attributes.content.value;
          sessionInfo[key] = value;
        }
      });
      webchat_main();
    }
  });

  function webchat_main() {
    ground dataspace G {
      Syndicate.UI.spawnUIDriver({
        defaultLocationHash: '/conversations'
      });
      Syndicate.WakeDetector.spawnWakeDetector();
      Syndicate.Broker.spawnBrokerClientDriver();
      spawnInputChangeMonitor();

      spawn {
        this.ui = new Syndicate.UI.Anchor();
        var mainpage_c = this.ui.context('mainpage');

        field this.connectedTo = null;
        field this.myRequestCount = 0; // requests *I* have made of others
        field this.otherRequestCount = 0; // requests *others* have made of me
        field this.questionCount = 0; // questions from the system
        field this.globallyVisible = false; // mirrors *other people's experience of us*
        field this.locallyVisible = true;
        field this.showRequestsFromOthers = false;
        field this.miniMode = $(window).width() < 768;

        window.addEventListener('resize', Syndicate.Dataspace.wrap(function () {
          :: windowWidthChanged($(window).width());
        }));

        on message windowWidthChanged($newWidth) {
          this.miniMode = newWidth < 768;
        }

        assert brokerConnection(brokerUrl);

        on asserted brokerConnected($url) { this.connectedTo = url; }
        on retracted brokerConnected(_) { this.connectedTo = null; }

        during inbound(online()) {
          on start { this.globallyVisible = true; }
          on stop { this.globallyVisible = false; }
        }

        during inbound(question($qid, _, _, sessionInfo.email, _, _, _)) {
          on start { this.questionCount++; }
          on stop { this.questionCount--; }
        }

        during inbound(permissionRequest($issuer, sessionInfo.email, $permission)) {
          on start { this.myRequestCount++; }
          on stop { this.myRequestCount--; }
        }

        during inbound(uiTemplate("nav-account.html", $entry)) {
          var c = this.ui.context('nav', 0, 'account');
          assert outbound(online()) when (this.locallyVisible);
          assert c.html('#nav-ul', Mustache.render(entry, {
            email: sessionInfo.email,
            avatar: avatar(sessionInfo.email),
            questionCount: this.questionCount,
            myRequestCount: this.myRequestCount,
            otherRequestCount: this.otherRequestCount,
            globallyVisible: this.globallyVisible,
            locallyVisible: this.locallyVisible
          }));
          on message c.event('.toggleInvisible', 'click', _) {
            this.locallyVisible = !this.locallyVisible;
          }
        }

        during Syndicate.UI.locationHash('/contacts') {
          during inbound(uiTemplate("page-contacts.html", $mainEntry)) {
            assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {}));
          }

          during inbound(uiTemplate("contact-entry.html", $entry)) {
            during mainpage_c.fragmentVersion($mainpageVersion) {
              during inbound(contactListEntry(sessionInfo.email, $contact)) {
                field this.pendingContactRequest = false;
                field this.isPresent = false;
                during inbound(present(contact)) {
                  on start { this.isPresent = true; }
                  on stop { this.isPresent = false; }
                }
                during inbound(permissionRequest(contact, sessionInfo.email, pFollow(contact))) {
                  on start { this.pendingContactRequest = true; }
                  on stop { this.pendingContactRequest = false; }
                }
                var c = this.ui.context(mainpageVersion, 'all-contacts', contact);
                assert c.html('.contact-list', Mustache.render(entry, {
                  email: contact,
                  avatar: avatar(contact),
                  pendingContactRequest: this.pendingContactRequest,
                  isPresent: this.isPresent
                }));
                on message c.event('.delete-contact', 'click', _) {
                  if (confirm((this.pendingContactRequest
                               ? "Cancel contact request to "
                               : "Delete contact ")
                              + contact + "?")) {
                    :: outbound(deleteResource(permitted(sessionInfo.email,
                                                         contact,
                                                         pFollow(sessionInfo.email),
                                                         false))); // TODO: true too?!
                  }
                }
              }
            }
          }

          during mainpage_c.fragmentVersion($mainpageVersion) {
            during inputValue('#add-contact-email', $rawContact) {
              var contact = rawContact && rawContact.trim();
              if (contact) {
                on message mainpage_c.event('#add-contact', 'click', _) {
                  :: outbound(createResource(grant(sessionInfo.email,
                                                   sessionInfo.email,
                                                   contact,
                                                   pFollow(sessionInfo.email),
                                                   false)));
                  $('#add-contact-email').val('');
                }
              }
            }
          }
        }

        during Syndicate.UI.locationHash('/permissions') {
          during inbound(uiTemplate("page-permissions.html", $mainEntry)) {
            assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {}));
          }

          during inbound(uiTemplate("permission-entry.html", $entry)) {
            during mainpage_c.fragmentVersion($mainpageVersion) {
              during inbound(permitted($i, $e, $p, $d)) {
                if (i !== sessionInfo.email) {
                  var c = this.ui.context(mainpageVersion, 'permitted', i, e, p, d);
                  assert c.html('#permissions', Mustache.render(entry, {
                    issuer: i,
                    email: e,
                    permission: JSON.stringify(p),
                    isDelegable: d,
                    isRelinquishable: i !== e
                  }));
                  on message c.event('.relinquish', 'click', _) {
                    :: outbound(deleteResource(permitted(i, e, p, d)));
                  }
                }
              }
            }
          }

          during inbound(uiTemplate("grant-entry.html", $entry)) {
            during mainpage_c.fragmentVersion($mainpageVersion) {
              during inbound(grant($i, sessionInfo.email, $ge, $p, $d)) {
                var c = this.ui.context(mainpageVersion, 'granted', i, ge, p, d);
                assert c.html('#grants', Mustache.render(entry, {
                  issuer: i,
                  grantee: ge,
                  permission: JSON.stringify(p),
                  isDelegable: d
                }));
                on message c.event('.revoke', 'click', _) {
                  :: outbound(deleteResource(grant(i, sessionInfo.email, ge, p, d)));
                }
              }
            }
          }
        }

        during Syndicate.UI.locationHash('/my-requests') {
          during inbound(uiTemplate("page-my-requests.html", $mainEntry)) {
            assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {
              myRequestCount: this.myRequestCount
            }));
          }

          during inbound(permissionRequest($issuer, sessionInfo.email, $permission)) {
            during inbound(uiTemplate("permission-request-out-GENERIC.html", $genericEntry)) {
              during mainpage_c.fragmentVersion($mainpageVersion) {
                var c = this.ui.context(mainpageVersion, 'my-permission-request', issuer, permission);
                field this.entry = genericEntry;
                assert c.html('#my-permission-requests', Mustache.render(this.entry, {
                  issuer: issuer,
                  permission: permission,
                  permissionJSON: JSON.stringify(permission)
                })) when (this.entry);
                var specificTemplate = "permission-request-out-" +
                    encodeURIComponent(permission.meta.label) + ".html";
                on asserted inbound(uiTemplate(specificTemplate, $specificEntry)) {
                  this.entry = specificEntry || genericEntry;
                }
                on message c.event('.cancel', 'click', _) {
                  :: outbound(deleteResource(permissionRequest(issuer, sessionInfo.email, permission)));
                }
              }
            }
          }
        }

        during Syndicate.UI.locationHash('/questions') {
          during inbound(uiTemplate("page-questions.html", $mainEntry)) {
            assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {
              questionCount: this.questionCount,
              otherRequestCount: this.otherRequestCount,
              showRequestsFromOthers: this.showRequestsFromOthers
            }));
          }

          during mainpage_c.fragmentVersion($mainpageVersion) {
            during inputValue('#show-all-requests-from-others', $showRequestsFromOthers) {
              on start { this.showRequestsFromOthers = showRequestsFromOthers; }
            }
          }

          during inbound(uiTemplate("permission-request-in-GENERIC.html", $genericEntry)) {
            during mainpage_c.fragmentVersion($mainpageVersion) {
              during inbound(permissionRequest($issuer, $grantee, $permission)) {
                if (grantee !== sessionInfo.email) {
                  on start { this.otherRequestCount++; }
                  on stop { this.otherRequestCount--; }

                  var c = this.ui.context(mainpageVersion, 'others-permission-request', issuer, grantee, permission);
                  field this.entry = genericEntry;
                  assert c.html('#others-permission-requests', Mustache.render(this.entry, {
                    issuer: issuer,
                    grantee: grantee,
                    permission: permission,
                    permissionJSON: JSON.stringify(permission)
                  })) when (this.entry);
                  var specificTemplate = "permission-request-in-" +
                      encodeURIComponent(permission.meta.label) + ".html";
                  on asserted inbound(uiTemplate(specificTemplate, $specificEntry)) {
                    this.entry = specificEntry || genericEntry;
                  }
                  on message c.event('.grant', 'click', _) {
                    :: outbound(createResource(grant(issuer,
                                                     sessionInfo.email,
                                                     grantee,
                                                     permission,
                                                     false)));
                  }
                  on message c.event('.deny', 'click', _) {
                    :: outbound(deleteResource(permissionRequest(issuer, grantee, permission)));
                  }
                }
              }
            }
          }

          during inbound(question($qid, $timestamp, $klass, sessionInfo.email, $title, $blurb, $qt))
          {
            during mainpage_c.fragmentVersion($mainpageVersion) {
              var c = this.ui.context(mainpageVersion, 'question', timestamp, qid);

              switch (qt.meta.label) {
                case "option-question": {
                  var options = qt.fields[0];
                  during inbound(uiTemplate("option-question.html", $entry)) {
                    assert c.html('#question-container', Mustache.render(entry, {
                      questionClass: klass,
                      title: title,
                      blurb: blurb,
                      options: options
                    }));
                    on message c.event('.response', 'click', $e) {
                      react { assert outbound(answer(qid, e.target.dataset.value)); }
                    }
                  }
                  break;
                }
                default: {
                  break;
                }
              }
            }
          }
        }

        var conversations_re = /^\/conversations(\/(.*))?/;
        during Syndicate.UI.locationHash($locationHash) {
          var m = locationHash.match(conversations_re);
          if (m) {
            assert selectedCid(m[2] || false);
          }
        }

        during inbound(uiTemplate("page-conversations.html", $mainEntry)) {
          during selectedCid(false) {
            assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {
              miniMode: this.miniMode,
              showConversationList: true,
              showConversationMain: !this.miniMode,
              showConversationInfo: false,
              showConversationPosts: false,
              selected: false
            }));
          }
        }

        // Move to the conversation index page when we leave a
        // conversation (which also happens automatically when it is
        // deleted)
        during selectedCid($selected) {
          on retracted inbound(inConversation(selected, sessionInfo.email)) {
            :: Syndicate.UI.setLocationHash('/conversations');
          }
        }

        during inbound(inConversation($cid, sessionInfo.email)) {
          field this.members = Immutable.Set();
          field this.title = '';
          field this.creator = '';
          field this.blurb = '';
          field this.editingTitle = false;
          field this.editingBlurb = false;

          field this.membersJSON = [];
          dataflow {
            this.membersJSON = this.members.map(function (m) { return {
              email: m,
              avatar: avatar(m)
            }; }).toArray();
          }

          on asserted inbound(inConversation(cid, $who)) {
            this.members = this.members.add(who);
          }
          on retracted inbound(inConversation(cid, $who)) {
            this.members = this.members.remove(who);
          }

          on asserted inbound(conversation(cid, $title, $creator, $blurb)) {
            this.title = title;
            this.creator = creator;
            this.blurb = blurb;
          }

          during inbound(uiTemplate("page-conversations.html", $mainEntry)) {
            during selectedCid($selected) {
              if (selected === cid) {
                field this.showInfoMode = false;
                field this.latestPostTimestamp = 0;
                field this.latestPostId = null;

                field this.draftItems = Immutable.Map();
                on asserted draftItem($ts, $d) { this.draftItems = this.draftItems.set(ts, d); }
                on retracted draftItem($ts, _) { this.draftItems = this.draftItems.remove(ts); }

                assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {
                  miniMode: this.miniMode,
                  showConversationList: !this.miniMode,
                  showConversationMain: true,
                  showConversationInfo: !this.miniMode || this.showInfoMode,
                  showConversationPosts: !this.miniMode || !this.showInfoMode,
                  selected: selected,
                  title: this.title,
                  blurb: this.blurb,
                  members: this.membersJSON,
                  editingTitle: this.editingTitle,
                  editingBlurb: this.editingBlurb,
                  overflowMenuItems: [
                    {label: "Invite user...", action: "invite-to-conversation"},
                    {label: "Leave conversation", action: "leave-conversation"},
                    {separator: true,
                     hidden: sessionInfo.email !== this.creator},
                    {label: "Delete conversation", action: "delete-conversation",
                     hidden: sessionInfo.email !== this.creator}
                  ]
                }));

                on message mainpage_c.event('#message-input', 'focus', $e) {
                  setTimeout(function () { e.target.scrollIntoView(false); }, 500);
                }

                var spawnItemFromDataURL = (function (ui) {
                  return function (dataURL) {
                    var timestamp = +(new Date());
                    spawn {
                      field this.ui = ui.context('draft-post', timestamp);
                      assert draftItem(timestamp, dataURL);
                      manifestPostItem(this.ui,
                                       '#pending-draft-items',
                                       {
                                         isDraft: true,
                                         postId: 'draft',
                                         timestamp: timestamp,
                                         fromMe: true,
                                         author: sessionInfo.email
                                       },
                                       dataURL);
                      stop on message draftSent();
                      stop on message this.ui.event('.close-draft', 'click', _);
                    }
                  };
                })(this.ui);

                var handleDataTransfer = function (dataTransfer) {
                  return dataTransferFiles(dataTransfer, Syndicate.Dataspace.wrap(
                    function (dataURLs) {
                      dataURLs.forEach(spawnItemFromDataURL);
                    }));
                };

                on message mainpage_c.event('#conversation-main', 'drop', $e) {
                  handleDataTransfer.call(this, e.dataTransfer);
                }

                on message mainpage_c.event('#message-input', '+paste', $e) {
                  if (handleDataTransfer.call(this, e.clipboardData)) {
                    e.preventDefault();
                  }
                }

                on message mainpage_c.event('#attach-item-button', 'click', _) {
                  console.log('clickenating');
                  $('#attach-item-file').click();
                }
                on message mainpage_c.event('#attach-item-file', 'change', $e) {
                  if (e.target.files) {
                    for (var i = 0; i < e.target.files.length; i++) {
                      var file = e.target.files[i];
                      var reader = new FileReader();
                      reader.addEventListener('load', Syndicate.Dataspace.wrap(function (e) {
                        spawnItemFromDataURL(e.target.result);
                      }));
                      reader.readAsDataURL(file);
                    }
                  }
                }

                on message mainpage_c.event('#send-message-button', 'click', _) {
                  var timestamp = +(new Date());
                  var items = this.draftItems.entrySeq().toArray();
                  items.sort(function (a, b) { return a[0] - b[0]; });
                  var message = ($("#message-input").val() || '').trim();
                  if (message) {
                    var b64 = btoa(unescape(encodeURIComponent(message))); // utf-8, then base64
                    items.push([timestamp,
                                "data:text/plain;charset=utf-8;base64," + encodeURIComponent(b64)]);
                  }
                  if (items.length) {
                    :: outbound(createResource(post(random_hex_string(16),
                                                    timestamp,
                                                    cid,
                                                    sessionInfo.email,
                                                    items.map(function (di) { return di[1]; }))));
                  }
                  $("#message-input").val('').focus();
                  :: draftSent();
                }

                on message mainpage_c.event('.invite-to-conversation', 'click', _) {
                  $('#invitation-modal').modal({});
                }

                on message mainpage_c.event('.send-invitation', 'click', _) {
                  var invitee = $('#invited-username').val().trim();
                  if (invitee) {
                    :: outbound(createResource(invitation(cid, sessionInfo.email, invitee)));
                    $('#invited-username').val('');
                    $('#invitation-modal').modal('hide');
                  }
                }

                on message mainpage_c.event('.leave-conversation', 'click', _) {
                  :: outbound(deleteResource(inConversation(cid, sessionInfo.email)));
                }

                on message mainpage_c.event('.delete-conversation', 'click', _) {
                  if (confirm("Delete this conversation?")) {
                    :: outbound(deleteResource(conversation(cid,
                                                            this.title,
                                                            this.creator,
                                                            this.blurb)));
                  }
                }

                on message mainpage_c.event('.toggle-info-mode', 'click', _) {
                  this.showInfoMode = !this.showInfoMode;
                }
                on message mainpage_c.event('.end-info-mode', 'click', _) {
                  this.showInfoMode = false;
                }

                on message mainpage_c.event('#edit-conversation-title', 'click', _) {
                  this.editingTitle = true;
                }
                on message mainpage_c.event('#title-heading', 'dblclick', _) {
                  this.editingTitle = true;
                }
                on message mainpage_c.event('#accept-conversation-title', 'click', _) {
                  this.title = $('#conversation-title').val();
                  :: outbound(updateResource(conversation(cid,
                                                          this.title,
                                                          this.creator,
                                                          this.blurb)));
                  this.editingTitle = false;
                }
                on message mainpage_c.event('#cancel-edit-conversation-title', 'click', _) {
                  this.editingTitle = false;
                }

                on message mainpage_c.event('#edit-conversation-blurb', 'click', _) {
                  this.editingBlurb = true;
                }
                on message mainpage_c.event('#blurb', 'dblclick', _) {
                  this.editingBlurb = true;
                }
                on message mainpage_c.event('#accept-conversation-blurb', 'click', _) {
                  this.blurb = $('#conversation-blurb').val();
                  :: outbound(updateResource(conversation(cid,
                                                          this.title,
                                                          this.creator,
                                                          this.blurb)));
                  this.editingBlurb = false;
                }
                on message mainpage_c.event('#cancel-edit-conversation-blurb', 'click', _) {
                  this.editingBlurb = false;
                }

                during mainpage_c.fragmentVersion($mainpageVersion) {
                  during inbound(post($pid, $timestamp, cid, $author, $items)) {
                    var fromMe = (author === sessionInfo.email);
                    var postInfo = {
                      isDraft: false,
                      postId: pid,
                      timestamp: timestamp,
                      date: new Date(timestamp).toString(),
                      time: new Date(timestamp).toTimeString().substr(0, 8),
                      fromMe: fromMe,
                      author: author
                    };
                    if (timestamp > this.latestPostTimestamp) {
                      this.latestPostTimestamp = timestamp;
                      this.latestPostId = pid;
                    }
                    var c = this.ui.context(mainpageVersion, 'post', timestamp, pid);
                    during inbound(uiTemplate("post-entry.html", $postEntryTemplate)) {
                      assert c.html('.posts', Mustache.render(postEntryTemplate, postInfo));
                      during c.fragmentVersion($postEntryVersion) {
                        var itemCounter = 0;
                        items.forEach((function (itemURL) {
                          manifestPostItem(c.context('item', postEntryVersion, itemCounter++),
                                           '#post-' + pid + ' .post-item-container',
                                           postInfo,
                                           itemURL);
                        }).bind(this));
                      }
                    }
                  }
                }
              }

              during inbound(uiTemplate("conversation-index-entry.html", $indexEntry)) {
                during mainpage_c.fragmentVersion($mainpageVersion) {
                  var c = this.ui.context(mainpageVersion, 'conversationIndex', cid);
                  assert c.html('#conversation-list', Mustache.render(indexEntry, {
                    isSelected: selected === cid,
                    selected: selected,
                    cid: cid,
                    title: this.title,
                    creator: this.creator,
                    members: this.membersJSON
                  }));
                  on message c.event('.card-block', 'click', _) {
                    if (selected === cid) {
                      :: Syndicate.UI.setLocationHash('/conversations');
                    } else {
                      :: Syndicate.UI.setLocationHash('/conversations/' + cid);
                    }
                  }
                }
              }
            }
          }
        }

        during Syndicate.UI.locationHash('/new-chat') {
          field this.invitees = Immutable.Set();
          field this.searchString = '';
          field this.displayedSearchString = ''; // avoid resetting HTML every keystroke. YUCK

          during inbound(uiTemplate("page-new-chat.html", $mainEntry)) {
            assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {
              noInvitees: this.invitees.isEmpty(),
              searchString: this.displayedSearchString
            }));
          }

          during mainpage_c.fragmentVersion($mainpageVersion) {
            on message Syndicate.UI.globalEvent('#search-contacts', 'keyup', $e) {
              this.searchString = e.target.value.trim();
            }

            on message mainpage_c.event('.create-conversation', 'click', _) {
              if (!this.invitees.isEmpty()) {
                var title = $('#conversation-title').val();
                var blurb = $('#conversation-blurb').val();
                var cid = random_hex_string(32);
                :: outbound(createResource(conversation(cid, title, sessionInfo.email, blurb)));
                :: outbound(createResource(inConversation(cid, sessionInfo.email)));
                this.invitees.forEach(function (invitee) {
                  :: outbound(createResource(invitation(cid, sessionInfo.email, invitee)));
                });
                :: Syndicate.UI.setLocationHash('/conversations/' + cid);
              }
            }
          }

          during inbound(uiTemplate("invitee-entry.html", $entry)) {
            during mainpage_c.fragmentVersion($mainpageVersion) {
              during inbound(contactListEntry(sessionInfo.email, $contact)) {
                field this.isPresent = false;
                field this.isInvited = false;
                dataflow {
                  this.isInvited = this.invitees.contains(contact);
                }
                during inbound(present(contact)) {
                  on start { this.isPresent = true; }
                  on stop { this.isPresent = false; }
                }
                var c = this.ui.context(mainpageVersion, 'all-contacts', contact);
                assert c.html('.contact-list', Mustache.render(entry, {
                  email: contact,
                  avatar: avatar(contact),
                  isPresent: this.isPresent,
                  isInvited: this.isInvited
                })) when (this.isInvited ||
                          !this.searchString ||
                          contact.indexOf(this.searchString) !== -1);
                on message c.event('.toggle-invitee-status', 'click', _) {
                  if (this.invitees.contains(contact)) {
                    this.invitees = this.invitees.remove(contact);
                  } else {
                    this.invitees = this.invitees.add(contact);
                  }
                  this.displayedSearchString = this.searchString;
                }
              }
            }
          }
        }
      }
    }

    // G.dataspace.setOnStateChange(function (mux, patch) {
    //   $("#debug-space").text(Syndicate.prettyTrie(mux.routingTable));
    // });
  }

  var nextItemid = 0;
  function manifestPostItem(uiContext, containerSelector, postInfo, itemURL) {
    function cleanContentType(t) {
      t = t.toLowerCase();
      if (t.startsWith('image/')) {
        t = 'image';
      } else {
        t = t.replace('/', '-');
      }
      return t;
    }

    var item = parseDataURL(itemURL);
    var itemId = 'post-' + postInfo.postId + '-item-' + nextItemid++;
    var contentClass = cleanContentType(item.type);
    var itemInfo = {
      itemId: itemId,
      postInfo: postInfo,
      contentClass: contentClass,
      item: item,
      itemURL: itemURL
    };

    during inbound(uiTemplate("post-item.html", $postItemTemplate)) {
      field this.entry = false;
      on asserted inbound(uiTemplate("post-item-" + contentClass + ".html", $entry)) {
        if (entry) this.entry = entry;
      }
      on asserted inbound(uiTemplate("post-item-application-octet-stream.html", $entry)) {
        if (entry && !this.entry) this.entry = entry;
      }
      assert uiContext.html(containerSelector, Mustache.render(postItemTemplate, itemInfo));
      on asserted uiContext.fragmentVersion($postItemVersion) {
        var innerContext = uiContext.context('item-body', postItemVersion);
        assert innerContext.html('#' + itemId + ' .post-item-body-container',
                                 Mustache.render(this.entry, itemInfo)) when (this.entry);
        if (!postInfo.isDraft) {
          on asserted innerContext.fragmentVersion($innerContextVersion) {
            if ((this.latestPostTimestamp === postInfo.timestamp) &&
                (this.latestPostId === postInfo.postId)) {
              setTimeout(function () { $("#post-" + postInfo.postId)[0].scrollIntoView(false); }, 1);
            }
          }
        }
      }
    }
  }
})();

///////////////////////////////////////////////////////////////////////////
// Input control value monitoring

assertion type inputValue(selector, value);

function spawnInputChangeMonitor() {
  function valOf(e) {
    return e ? (e.type === 'checkbox' ? e.checked : e.value) : null;
  }

  spawn {
    during Syndicate.observe(inputValue($selector, _)) spawn {
      field this.value = valOf($(selector)[0]);
      assert inputValue(selector, this.value);
      on message Syndicate.UI.globalEvent(selector, 'change', $e) {
        this.value = valOf(e.target);
      }
    }
  }
}

///////////////////////////////////////////////////////////////////////////

function random_hex_string(halfLength) {
  var bs = new Uint8Array(halfLength);
  var encoded = [];
  crypto.getRandomValues(bs);
  for (var i = 0; i < bs.length; i++) {
    encoded.push("0123456789abcdef"[(bs[i] >> 4) & 15]);
    encoded.push("0123456789abcdef"[bs[i] & 15]);
  }
  return encoded.join('');
}

///////////////////////////////////////////////////////////////////////////

function parseDataURL(u) {
  var pieces;

  if (!u.startsWith('data:')) return null;
  u = u.substr(5);

  pieces = u.split(',');
  if (pieces.length !== 2) return null;

  var mimeType = pieces[0];
  var data = decodeURIComponent(pieces[1]);
  var isBase64 = false;

  if (mimeType.endsWith(';base64')) {
    mimeType = mimeType.substr(0, mimeType.length - 7);
    isBase64 = true;
  }

  if (isBase64) {
    data = atob(data);
  }

  pieces = mimeType.split(';');
  var type = pieces[0];

  var parameters = {};
  for (var i = 1; i < pieces.length; i++) {
    var m = pieces[i].match(/^([^=]+)=(.*)$/);
    if (m) {
      parameters[m[1].toLowerCase()] = m[2];
    }
  }

  if (type.startsWith('text/')) {
    var charset = (parameters.charset || 'US-ASCII').toLowerCase();
    switch (charset) {
      case 'utf-8':
        data = decodeURIComponent(escape(data));
        break;
      case 'us-ascii':
      case 'ascii':
      case 'latin1':
      case 'iso-8859-1':
        break;
      default:
        console.warn('Unknown charset while decoding data URL:', charset);
        break;
    }
  }

  return {
    type: type,
    parameters: parameters,
    data: data
  };
}

///////////////////////////////////////////////////////////////////////////

// Extract file contents from a DataTransfer object
function dataTransferFiles(d, k) {
  var items = d.items;
  var types = d.types;
  var files = d.files;

  var results = [];
  var expectedCount = files.length;
  var completedCount = 0;

  function completeOne() {
    completedCount++;
    if (completedCount === expectedCount) {
      k(results);
    }
  }

  for (var i = 0; i < items.length; i++) {
    (function (i) {
      var item = items[i];
      var type = types[i];
      if (type === 'text/uri-list') {
        expectedCount++;
        item.getAsString(function (itemstr) {
          var firstChunk = itemstr.substr(0, 6).toLowerCase();
          if (firstChunk.startsWith('http:') || firstChunk.startsWith('https:')) {
            $.ajax({
              type: "GET",
              url: itemstr,
              beforeSend: function (xhr) {
                xhr.overrideMimeType('text/plain; charset=x-user-defined');
              },
              success: function (_data, _status, xhr) {
                var contentType = xhr.getResponseHeader('content-type');
                var rawdata = xhr.responseText;
                var data = [];
                for (var j = 0; j < rawdata.length; j++) {
                  data = data + String.fromCharCode(rawdata.charCodeAt(j) & 0xff);
                }
                results.push('data:' + contentType + ';base64,' + encodeURIComponent(btoa(data)));
                completeOne();
              },
              error: function () {
                completeOne();
              }
            });
          } else {
            completeOne();
          }
        });
      }
    })(i);
  }

  for (var i = 0; i < files.length; i++) {
    (function (i) {
      var file = files[i];
      var reader = new FileReader();
      reader.addEventListener('load', function (e) {
        results.push(e.target.result);
        completeOne();
      });
      reader.readAsDataURL(file);
    })(i);
  }

  return (expectedCount > 0);
}
