diff --git a/customize.dist/src/less2/include/alertify.less b/customize.dist/src/less2/include/alertify.less index cd066e2c0..6662a9df7 100644 --- a/customize.dist/src/less2/include/alertify.less +++ b/customize.dist/src/less2/include/alertify.less @@ -30,7 +30,7 @@ // These show only once .alertify-logs { - z-index: 10000; // alertify logs + z-index: 100001; // alertify logs should be in front of alertify modals @media print { visibility: hidden; } @@ -339,7 +339,7 @@ .alertify-logs { position: fixed; - z-index: 99999; // alertify logs + z-index: 100001; // alertify logs (just in front of alertify modals &.bottom, &:not(.top) { bottom: 16px; @@ -459,8 +459,15 @@ } } } + .cp-ownership { + & > label { + font-weight: bold; + } + } + } + .cp-share-column { .cp-share-grid, .cp-share-list { - .avatar_main(50px); + .avatar_main(40px); display: flex; justify-content: space-between; flex-wrap: wrap; @@ -513,6 +520,9 @@ color: @colortheme_alertify-primary-text; order: -1 !important; } + .cp-share-friend-avatar { + min-height: 40px; + } .cp-share-friend-name { overflow: hidden; white-space: nowrap; @@ -525,11 +535,6 @@ visibility: hidden; } } - .cp-ownership { - & > label { - font-weight: bold; - } - } } } diff --git a/www/common/common-messaging.js b/www/common/common-messaging.js index a9d618225..8eec5c4a3 100644 --- a/www/common/common-messaging.js +++ b/www/common/common-messaging.js @@ -10,7 +10,7 @@ define([ var Msg = {}; var createData = Msg.createData = function (proxy, hash) { - return { + var data = { channel: hash || Hash.createChannelId(), displayName: proxy['cryptpad.username'], profile: proxy.profile && proxy.profile.view, @@ -19,6 +19,8 @@ define([ notifications: Util.find(proxy, ['mailboxes', 'notifications', 'channel']), avatar: proxy.profile && proxy.profile.avatar }; + if (hash === false) { delete data.channel; } + return data; }; var getFriend = Msg.getFriend = function (proxy, pubkey) { diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index c21b189f1..3b8dac905 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -146,18 +146,6 @@ define([ }, function () { }); var $div = $(removeCol.div); - var others1 = removeCol.others; - $div.append(h('div.cp-share-grid', others1)); - $div.find('.cp-share-friend').click(function () { - var sel = $(this).hasClass('cp-selected'); - if (!sel) { - $(this).addClass('cp-selected'); - } else { - var order = $(this).attr('data-order'); - order = order ? 'order:'+order : ''; - $(this).removeClass('cp-selected').attr('style', order); - } - }); // When clicking on the remove button, we check the selected users. // If you try to remove yourself, we'll display an additional warning message var btnMsg = pending ? Messages.owner_removePendingButton : Messages.owner_removeButton; @@ -244,18 +232,6 @@ define([ //console.log(arguments); }); $div2 = $(addCol.div); - var others2 = addCol.others; - $div2.append(h('div.cp-share-grid', others2)); - $div2.find('.cp-share-friend').click(function () { - var sel = $(this).hasClass('cp-selected'); - if (!sel) { - $(this).addClass('cp-selected'); - } else { - var order = $(this).attr('data-order'); - order = order ? 'order:'+order : ''; - $(this).removeClass('cp-selected').attr('style', order); - } - }); // When clicking on the add button, we get the selected users. var addButton = h('button.no-margin', Messages.owner_addButton); $(addButton).click(function () { @@ -724,6 +700,19 @@ define([ onSelect(); }); + $(div).append(h('div.cp-share-grid', others)); + $div.on('click', '.cp-share-friend', function () { + var sel = $(this).hasClass('cp-selected'); + if (!sel) { + $(this).addClass('cp-selected'); + } else { + var order = $(this).attr('data-order'); + order = order ? 'order:'+order : ''; + $(this).removeClass('cp-selected').attr('style', order); + } + onSelect(); + }); + return { others: others, div: div @@ -749,7 +738,7 @@ define([ // Replace "copy link" by "share with friends" if at least one friend is selected // Also create the "share with friends" button if it doesn't exist var refreshButtons = function () { - var $nav = $div.parents('.alertify').find('nav'); + var $nav = $div.closest('.alertify').find('nav'); var friendMode = $div.find('.cp-share-friend.cp-selected').length; if (friendMode) { @@ -759,6 +748,7 @@ define([ } }; + config.noInclude = true; var friendsList = UIElements.getFriendsList(Messages.share_linkFriends, config, refreshButtons); var friendDiv = friendsList.div; $div.append(friendDiv); @@ -784,7 +774,6 @@ define([ friends: teams }, refreshButtons); $div.append(teamsList.div); - $(teamsList.div).append(h('div.cp-share-grid', teamsList.others)); var shareButtons = [{ className: 'primary cp-share-with-friends', @@ -870,19 +859,9 @@ define([ $(el).attr('data-order', i).css('order', i); }); // Display them + $(friendDiv).find('.cp-share-grid').detach(); $(friendDiv).append(h('div.cp-share-grid', others)); $div.append(UI.dialog.getButtons(shareButtons, config.onClose)); - $div.find('.cp-share-friend').click(function () { - var sel = $(this).hasClass('cp-selected'); - if (!sel) { - $(this).addClass('cp-selected'); - } else { - var order = $(this).attr('data-order'); - order = order ? 'order:'+order : ''; - $(this).removeClass('cp-selected').attr('style', order); - } - refreshButtons(); - }); refreshButtons(); }); return div; @@ -903,7 +882,6 @@ define([ var friendsUIClass = hasFriends ? '.cp-share-columns' : ''; var mainShareColumn = h('div.cp-share-column.contains-nav', [ - hasFriends ? h('p', Messages.share_description) : undefined, h('label', Messages.share_linkAccess), h('br'), UI.createRadio('cp-share-editable', 'cp-share-editable-true', @@ -1195,6 +1173,78 @@ define([ return UI.dialog.customModal(link, {buttons: linkButtons}); }; + UIElements.createInviteTeamModal = function (config) { + var common = config.common; + var hasFriends = Object.keys(config.friends || {}).length !== 0; + var friendsList = hasFriends ? createShareWithFriends(config) : undefined; + + if (!hasFriends) { + return void UI.alert('No friend to invite'); // XXX + } + var privateData = common.getMetadataMgr().getPrivateData(); + var team = privateData.teams[config.teamId]; + if (!team) { return void UI.warn(Messages.error); } + + var module = config.module || common.makeUniversal('team', { onEvent: function () {} }); + + var $div; + var refreshButton = function () { + if (!$div) { return; } + var $modal = $div.closest('.alertify'); + var $nav = $modal.find('nav'); + var $btn = $nav.find('button.primary'); + var selected = $div.find('.cp-share-friend.cp-selected').length; + if (selected) { + $btn.prop('disabled', ''); + } else { + $btn.prop('disabled', 'disabled'); + } + }; + var list = UIElements.getFriendsList('Pick the friends you want to invite to the team', { // XXX + common: common, + friends: config.friends, + }, refreshButton); + $div = $(list.div); + refreshButton(); + + var buttons = [{ + className: 'cancel', + name: Messages.cancel, + onClick: function () {}, + keys: [27] + }, { + className: 'primary', + name: 'INVITE', // XXX + onClick: function () { + var $sel = $div.find('.cp-share-friend.cp-selected'); + var sel = $sel.toArray(); + if (!sel.length) { return; } + + var friends = sel.forEach(function (el) { + var curve = $(el).attr('data-curve'); + module.execCommand('INVITE_TO_TEAM', { + teamId: config.teamId, + user: config.friends[curve] + }, function (obj) { + if (obj && obj.error) { + console.error(obj.error); + return UI.warn(Messages.error); + } + }); + }); + }, + keys: [13] + }]; + + var content = h('div', [ + h('h4', 'Invite friends to your team: '+ team.name), + list.div + ]); + + var modal = UI.dialog.customModal(content, {buttons: buttons}); + UI.openCustomModal(modal); + }; + UIElements.createButton = function (common, type, rightside, data, callback) { var AppConfig = common.getAppConfig(); var button; @@ -3503,27 +3553,7 @@ define([ var content = h('div.cp-share-modal', [ setHTML(h('p'), text) ]); - var buttons = [{ - name: Messages.friendRequest_later, - onClick: function () {}, - keys: [27] - }, { - className: 'primary', - name: Messages.friendRequest_accept, - onClick: function () { - todo(true); - }, - keys: [13] - }, { - className: 'primary', - name: Messages.friendRequest_decline, - onClick: function () { - todo(false); - }, - keys: [[13, 'ctrl']] - }]; - var modal = UI.dialog.customModal(content, {buttons: buttons}); - UI.openCustomModal(modal); + UI.proposal(content, todo); }; UIElements.displayAddOwnerModal = function (common, data) { @@ -3648,27 +3678,85 @@ define([ }); }; - var buttons = [{ - name: Messages.friendRequest_later, - onClick: function () {}, - keys: [27] - }, { - className: 'primary', - name: Messages.friendRequest_accept, - onClick: function () { - todo(true); - }, - keys: [13] - }, { - className: 'primary', - name: Messages.friendRequest_decline, - onClick: function () { - todo(false); - }, - keys: [[13, 'ctrl']] - }]; - var modal = UI.dialog.customModal(div, {buttons: buttons}); - UI.openCustomModal(modal); + UI.proposal(div, todo); + }; + + UIElements.getVerifiedFriend = function (common, curve, name) { + var priv = common.getMetadataMgr().getPrivateData(); + var verified = h('p'); + var $verified = $(verified); + + if (priv.friends && priv.friends[curve]) { + $verified.addClass('cp-notifications-requestedit-verified'); + var f = priv.friends[curve]; + $verified.append(h('span.fa.fa-certificate')); + var $avatar = $(h('span.cp-avatar')).appendTo($verified); + $verified.append(h('p', Messages._getKey('requestEdit_fromFriend', [f.displayName]))); + common.displayAvatar($avatar, f.avatar, f.displayName); + } else { + $verified.append(Messages._getKey('requestEdit_fromStranger', [name])); + } + return verified; + }; + + UIElements.displayInviteTeamModal = function (common, data) { + var priv = common.getMetadataMgr().getPrivateData(); + var user = common.getMetadataMgr().getUserData(); + var sframeChan = common.getSframeChannel(); + var msg = data.content.msg; + + var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous; + var teamName = Util.fixHTML(Util.find(msg, ['content', 'team', 'metadata', 'name']) || ''); + + var verified = UIElements.getVerifiedFriend(common, msg.author, name); + + //var text = Messages._getKey('', [name, title]); // XXX + var text = name + " has invited you to join the team " + teamName +""; + + var div = h('div', [ + UI.setHTML(h('p'), text), + verified + ]); + + var module = common.makeUniversal('team'); + + var answer = function (yes) { + common.mailbox.sendTo("INVITE_TO_TEAM_ANSWER", { + answer: yes, + teamChannel: msg.content.team.channel, + user: { + displayName: user.name, + avatar: user.avatar, + profile: user.profile, + notifications: user.notifications, + curvePublic: user.curvePublic, + edPublic: priv.edPublic + } + }, { + channel: msg.content.user.notifications, + curvePublic: msg.content.user.curvePublic + }); + common.mailbox.dismiss(data, function (err) { + console.log(err); + }); + }; + var todo = function (yes) { + if (yes) { + // ACCEPT + module.execCommand('JOIN_TEAM', { + team: msg.content.team + }, function (obj) { + if (obj && obj.error) { return void UI.warn(Messages.error); } + answer(true); + }); + return; + } + + // DECLINE + answer(false); + }; + + UI.proposal(div, todo); }; return UIElements; diff --git a/www/common/notifications.js b/www/common/notifications.js index 4f5d286ee..810e4b72f 100644 --- a/www/common/notifications.js +++ b/www/common/notifications.js @@ -145,22 +145,10 @@ define([ var link = h('a', { href: '#' }, Messages.requestEdit_viewPad); - var verified = h('p'); - var $verified = $(verified); var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous; var title = Util.fixHTML(msg.content.title); - - if (priv.friends && priv.friends[msg.author]) { - $verified.addClass('cp-notifications-requestedit-verified'); - var f = priv.friends[msg.author]; - $verified.append(h('span.fa.fa-certificate')); - var $avatar = $(h('span.cp-avatar')).appendTo($verified); - $verified.append(h('p', Messages._getKey('requestEdit_fromFriend', [f.displayName]))); - common.displayAvatar($avatar, f.avatar, f.displayName); - } else { - $verified.append(Messages._getKey('requestEdit_fromStranger', [name])); - } + var verified = UIElements.getVerifiedFriend(common, msg.author, name); var div = h('div', [ UI.setHTML(h('p'), Messages._getKey('requestEdit_confirm', [title, name])), @@ -268,6 +256,42 @@ define([ } }; + handlers['INVITE_TO_TEAM'] = function (common, data) { + var content = data.content; + var msg = content.msg; + + // Display the notification + var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous; + var teamName = Util.fixHTML(Util.find(msg, ['content', 'team', 'metadata', 'name']) || ''); + content.getFormatText = function () { + var text = name + " has invited you to join the team " + teamName +""; + return text; + }; + if (!content.archived) { + content.handler = function () { + UIElements.displayInviteTeamModal(common, data); + }; + } + }; + + handlers['INVITE_TO_TEAM_ANSWER'] = function (common, data) { + var content = data.content; + var msg = content.msg; + + // Display the notification + var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous; + var teamName = Util.fixHTML(Util.find(msg, ['content', 'team', 'metadata', 'name']) || ''); + var key = 'owner_request_' + (msg.content.answer ? 'accepted' : 'declined'); + content.getFormatText = function () { + //return Messages._getKey(key, [name, title]); // XXX + return name +' has ' + (msg.content.answer ? 'accepted' : 'declined') + ' your offer to join the team ' + teamName + ''; + }; + if (!content.archived) { + content.dismissHandler = defaultDismiss(common, data); + } + }; + + // NOTE: don't forget to fixHTML everything returned by "getFormatText" return { diff --git a/www/common/outer/mailbox-handlers.js b/www/common/outer/mailbox-handlers.js index 487eaecdc..cfe6fdd29 100644 --- a/www/common/outer/mailbox-handlers.js +++ b/www/common/outer/mailbox-handlers.js @@ -1,7 +1,8 @@ define([ '/common/common-messaging.js', '/common/common-hash.js', -], function (Messaging, Hash) { + '/common/common-util.js', +], function (Messaging, Hash, Util) { var getRandomTimeout = function (ctx) { var lag = ctx.store.realtime.getLag().lag || 0; @@ -309,6 +310,72 @@ define([ cb(false); }; + var invitedTo = {}; + handlers['INVITE_TO_TEAM'] = function (ctx, box, data, cb) { + var msg = data.msg; + var content = msg.content; + + if (msg.author !== content.user.curvePublic) { return void cb(true); } + if (!content.team) { + console.log('Remove invalid notification'); + return void cb(true); + } + + if (invitedTo[content.team.channel]) { return void cb(true); } + + var myTeams = Util.find(ctx, ['store', 'proxy', 'teams']) + var alreadyMember = Object.keys(myTeams).some(function (k) { + var team = myTeams[k]; + return team.channel === content.team.channel; + }); + if (alreadyMember) { return void cb(true); } + + invitedTo[content.team.channel] = true; + + cb(false); + }; + removeHandlers['INVITE_TO_TEAM'] = function (ctx, box, data) { + var channel = Util.find(data, ['content', 'team', 'channel']); + delete invitedTo[channel]; + }; + + handlers['INVITE_TO_TEAM_ANSWER'] = function (ctx, box, data, cb) { + var msg = data.msg; + var content = msg.content; + + if (msg.author !== content.user.curvePublic) { return void cb(true); } + if (!content.teamChannel) { + console.log('Remove invalid notification'); + return void cb(true); + } + + var myTeams = Util.find(ctx, ['store', 'proxy', 'teams']) + var teamId; + var team; + Object.keys(myTeams).some(function (k) { + var _team = myTeams[k]; + if (_team.channel === content.teamChannel) { + teamId = k; + team = _team; + return true; + } + }); + if (!teamId) { return void cb(true); } + + content.team = team; + + if (!content.answer) { + // If they declined the invitation, remove them from the roster (as a pending member) + try { + var module = ctx.store.modules['team']; + module.removeFromTeam(teamId, msg.author); + } catch (e) { console.error(e); } + } + + cb(false); + }; + + return { add: function (ctx, box, data, cb) { /** diff --git a/www/common/outer/team.js b/www/common/outer/team.js index 2785ee806..2331d35d9 100644 --- a/www/common/outer/team.js +++ b/www/common/outer/team.js @@ -310,8 +310,8 @@ define([ // If you're allowed to edit the roster, try to update your data if (!rosterData.edit) { return; } var data = {}; - var myData = Messaging.createData(ctx.store.proxy); - delete myData.channel; + var myData = Messaging.createData(ctx.store.proxy, false); + myData.pending = false; data[ctx.store.proxy.curvePublic] = myData; roster.describe(data, function (err) { if (!err) { return; } @@ -448,6 +448,19 @@ define([ }); }; + var joinTeam = function (ctx, data, cId, cb) { + var team = data.team; + if (!team.hash || !team.channel || !team.password + || !team.keys || !team.metadata) { return void cb({error: 'EINVAL'}); } + var id = Util.createRandomInteger(); + ctx.store.proxy.teams[id] = team; + ctx.onReadyHandlers[id] = []; + openChannel(ctx, team, id, function (obj) { + if (!(obj && obj.error)) { console.debug('Team joined:' + id); } + cb(obj); + }); + }; + var getTeamRoster = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } @@ -499,6 +512,43 @@ define([ }); }; + // TODO send guest keys only in the future + var getInviteData = function (ctx, teamId) { + var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); + if (!teamData) { return; } + var data = Util.clone(teamData); + delete data.owner; + return data; + }; + + var inviteToTeam = function (ctx, data, cId, cb) { + var teamId = data.teamId; + if (!teamId) { return void cb({error: 'EINVAL'}); } + var team = ctx.teams[teamId]; + if (!team) { return void cb ({error: 'ENOENT'}); } + if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } + var user = data.user; + if (!user || !user.curvePublic || !user.notifications) { return void cb({error: 'MISSING_DATA'}); } + delete user.channel; + delete user.lastKnownHash; + user.pending = true; + + var obj = {}; + obj[user.curvePublic] = user; + team.roster.add(obj, function (err) { + if (err && err !== 'NO_CHANGE') { return void cb({error: err}); } + ctx.store.mailbox.sendTo('INVITE_TO_TEAM', { + user: Messaging.createData(ctx.store.proxy, false), + team: getInviteData(ctx, teamId) + }, { + channel: user.notifications, + curvePublic: user.curvePublic + }, function (obj) { + cb(obj); + }); + }); + }; + // XXX Listen for changes to the roster pad to know if you've been removed // XXX onReady, if you've been removed, leave the team var removeUser = function (ctx, data, cId, cb) { @@ -584,7 +634,8 @@ define([ pinPads: cfg.pinPads, emit: emit, onReadyHandlers: {}, - teams: {} + teams: {}, + updateMetadata: cfg.updateMetadata }; var teams = store.proxy.teams = store.proxy.teams || {}; @@ -608,7 +659,8 @@ define([ Object.keys(teams).forEach(function (id) { t[id] = { name: teams[id].metadata.name, - edPublic: Util.find(teams[id], ['keys', 'drive', 'edPublic']) + edPublic: Util.find(teams[id], ['keys', 'drive', 'edPublic']), + avatar: Util.find(teams[id], ['metadata', 'avatar']) }; }); return t; @@ -616,6 +668,22 @@ define([ team.getTeams = function () { return Object.keys(ctx.teams); }; + team.removeFromTeam = function (teamId, curve) { + if (!teams[teamId]) { return; } + if (ctx.onReadyHandlers[teamId]) { + ctx.onReadyHandlers[teamId].push({cb : function () { + ctx.teams[teamId].roster.remove([curve], function (err) { + if (err && err !== 'NO_CHANGE') { console.error(err); } + }); + }}); + return; + } + if (!ctx.teams[teamId]) { return void console.error("TEAM MODULE ERROR"); } + ctx.teams[teamId].roster.remove([curve], function (err) { + if (err && err !== 'NO_CHANGE') { console.error(err); } + }); + + }; team.removeClient = function (clientId) { removeClient(ctx, clientId); }; @@ -644,6 +712,12 @@ define([ if (cmd === 'DESCRIBE_USER') { return void describeUser(ctx, data, clientId, cb); } + if (cmd === 'INVITE_TO_TEAM') { + return void inviteToTeam(ctx, data, clientId, cb); + } + if (cmd === 'JOIN_TEAM') { + return void joinTeam(ctx, data, clientId, cb); + } if (cmd === 'REMOVE_USER') { return void removeUser(ctx, data, clientId, cb); } diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index aee8b8306..11061fd80 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -167,9 +167,11 @@ define([ // Universal direct channel var modules = {}; funcs.makeUniversal = function (type, cfg) { - modules[type] = { - onEvent: cfg.onEvent || function () {} - }; + if (cfg && cfg.onEvent) { + modules[type] = { + onEvent: cfg.onEvent || function () {} + }; + } var sframeChan = funcs.getSframeChannel(); return { execCommand: function (cmd, data, cb) { diff --git a/www/team/inner.js b/www/team/inner.js index 52b9535cf..d2eb95a76 100644 --- a/www/team/inner.js +++ b/www/team/inner.js @@ -397,7 +397,7 @@ define([ var theirRole = ROLES.indexOf(data.role) || 0; // If they're a member and I have a higher role than them, I can promote them to admin if (!isMe && myRole > theirRole && theirRole === 0) { - var promote = h('fa.fa-angle-double-up', { + var promote = h('span.fa.fa-angle-double-up', { title: 'Promote' // XXX }); $(promote).click(function () { @@ -410,7 +410,7 @@ define([ // If I'm not a member and I have an equal or higher role than them, I can demote them // (if they're not already a MEMBER) if (!isMe && myRole >= theirRole && theirRole > 0) { - var demote = h('fa.fa-angle-double-down', { + var demote = h('span.fa.fa-angle-double-down', { title: 'Demote' // XXX }); $(demote).click(function () { @@ -422,7 +422,7 @@ define([ } // If I'm not a member and I have an equal or higher role than them, I can remove them if (!isMe && myRole > 0 && myRole >= theirRole) { - var remove = h('fa.fa-times', { + var remove = h('span.fa.fa-times', { title: 'Remove' // XXX }); $(remove).click(function () { @@ -460,8 +460,8 @@ define([ APP.refreshRoster = function (common, roster) { if (!roster || typeof(roster) !== "object" || Object.keys(roster) === 0) { return; } var metadataMgr = common.getMetadataMgr(); - var privateData = metadataMgr.getPrivateData(); - var me = roster[privateData.curvePublic]; + var userData = metadataMgr.getUserData(); + var me = roster[userData.curvePublic] || {}; var owner = Object.keys(roster).filter(function (k) { return roster[k].role === "OWNER"; }).map(function (k) { @@ -479,7 +479,35 @@ define([ }); // XXX LEAVE the team button // XXX INVITE to the team button + var header = h('div.cp-app-team-roster-header'); + var $header = $(header); + + // If you're an admin or an owner, you can invite your friends to the team + // TODO and acquaintances later? + if (me && (me.role === 'ADMIN' || me.role === 'OWNER')) { + var invite = h('button.btn.btn-primary', 'INVITE A FRIEND'); + var inviteFriends = common.getFriends(); + Object.keys(inviteFriends).forEach(function (curve) { + // Keep only friends that are not already in the team and that you can contact + // via their mailbox + if (roster[curve] && !roster[curve].pending) { + delete inviteFriends[curve]; + } + }); + var inviteCfg = { + teamId: APP.team, + common: common, + friends: inviteFriends, + module: APP.module + }; + $(invite).click(function () { + UIElements.createInviteTeamModal(inviteCfg); + }); + $header.append(invite); + } + return [ + header, h('h3', 'OWNER'), // XXX h('div', owner), h('h3', 'ADMINS'), // XXX