mirror of https://github.com/xwiki-labs/cryptpad
Transfer team ownership
This commit is contained in:
parent
3fb0cc38ec
commit
295a712942
www
|
@ -288,6 +288,7 @@ define([
|
|||
}
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// Add one of our teams as an owner
|
||||
if (toAddTeams.length) {
|
||||
// Send the command
|
||||
sframeChan.query('Q_SET_PAD_METADATA', {
|
||||
|
@ -320,6 +321,7 @@ define([
|
|||
}));
|
||||
}
|
||||
}).nThen(function (waitFor) {
|
||||
// Offer ownership to a friend
|
||||
if (toAdd.length) {
|
||||
// Send the command
|
||||
sframeChan.query('Q_SET_PAD_METADATA', {
|
||||
|
@ -1293,7 +1295,7 @@ define([
|
|||
var team = privateData.teams[config.teamId];
|
||||
if (!team) { return void UI.warn(Messages.error); }
|
||||
|
||||
var module = config.module || common.makeUniversal('team', { onEvent: function () {} });
|
||||
var module = config.module || common.makeUniversal('team');
|
||||
|
||||
var $div;
|
||||
var refreshButton = function () {
|
||||
|
@ -3785,6 +3787,130 @@ define([
|
|||
|
||||
UI.proposal(div, todo);
|
||||
};
|
||||
UIElements.displayAddTeamOwnerModal = 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 title = Util.fixHTML(msg.content.title);
|
||||
|
||||
//var text = Messages._getKey('owner_team_add', [name, title]); // XXX
|
||||
var text = name + ' wants you to be an owner of the team ' + title; // XXX
|
||||
|
||||
var div = h('div', [
|
||||
UI.setHTML(h('p'), text),
|
||||
]);
|
||||
|
||||
var answer = function (yes) {
|
||||
common.mailbox.sendTo("ADD_OWNER_ANSWER", {
|
||||
teamChannel: msg.content.teamChannel,
|
||||
title: msg.content.title,
|
||||
answer: yes,
|
||||
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) {
|
||||
if (err) { console.log(err); }
|
||||
});
|
||||
};
|
||||
var module = common.makeUniversal('team');
|
||||
|
||||
var addOwner = function (chan, waitFor, cb) {
|
||||
// Remove yourself from the pending owners
|
||||
sframeChan.query('Q_SET_PAD_METADATA', {
|
||||
channel: chan,
|
||||
command: 'ADD_OWNERS',
|
||||
value: [priv.edPublic]
|
||||
}, function (err, res) {
|
||||
err = err || (res && res.error);
|
||||
if (!err) { return; }
|
||||
waitFor.abort();
|
||||
cb(err);
|
||||
});
|
||||
};
|
||||
var removePending = function (chan, waitFor, cb) {
|
||||
// Remove yourself from the pending owners
|
||||
sframeChan.query('Q_SET_PAD_METADATA', {
|
||||
channel: chan,
|
||||
command: 'RM_PENDING_OWNERS',
|
||||
value: [priv.edPublic]
|
||||
}, waitFor(function (err, res) {
|
||||
err = err || (res && res.error);
|
||||
if (!err) { return; }
|
||||
waitFor.abort();
|
||||
cb(err);
|
||||
}));
|
||||
};
|
||||
var changeAll = function (add, _cb) {
|
||||
var f = add ? addOwner : removePending;
|
||||
var cb = Util.once(_cb);
|
||||
NThen(function (waitFor) {
|
||||
f(msg.content.teamChannel, waitFor, cb);
|
||||
f(msg.content.chatChannel, waitFor, cb);
|
||||
f(msg.content.rosterChannel, waitFor, cb);
|
||||
}).nThen(function () { cb(); });
|
||||
};
|
||||
|
||||
var todo = function (yes) {
|
||||
if (yes) {
|
||||
// ACCEPT
|
||||
changeAll(true, function (err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
var text = err === "INSUFFICIENT_PERMISSIONS" ? Messages.fm_forbidden
|
||||
: Messages.error;
|
||||
return void UI.warn(text);
|
||||
}
|
||||
UI.log(Messages.saved);
|
||||
|
||||
// Send notification to the sender
|
||||
answer(true);
|
||||
|
||||
// Mark ourselves as "owner" in our local team data
|
||||
module.execCommand("ANSWER_OWNERSHIP", {
|
||||
teamChannel: msg.content.teamChannel,
|
||||
answer: true
|
||||
}, function (obj) {
|
||||
if (obj && obj.error) { console.error(obj.error); }
|
||||
});
|
||||
|
||||
// Remove yourself from the pending owners
|
||||
changeAll(false, function (err) {
|
||||
if (err) { console.error(err); }
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// DECLINE
|
||||
// Remove yourself from the pending owners
|
||||
changeAll(false, function (err) {
|
||||
if (err) { console.error(err); }
|
||||
// Send notification to the sender
|
||||
answer(false);
|
||||
// Set our role back to ADMIN
|
||||
module.execCommand("ANSWER_OWNERSHIP", {
|
||||
teamChannel: msg.content.teamChannel,
|
||||
answer: false
|
||||
}, function (obj) {
|
||||
if (obj && obj.error) { console.error(obj.error); }
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
UI.proposal(div, todo);
|
||||
};
|
||||
|
||||
UIElements.getVerifiedFriend = function (common, curve, name) {
|
||||
var priv = common.getMetadataMgr().getPrivateData();
|
||||
|
|
|
@ -216,6 +216,9 @@ define([
|
|||
// if not archived, add handlers
|
||||
if (!content.archived) {
|
||||
content.handler = function () {
|
||||
if (msg.content.teamChannel) {
|
||||
return void UIElements.displayAddTeamOwnerModal(common, data);
|
||||
}
|
||||
UIElements.displayAddOwnerModal(common, data);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -270,12 +270,12 @@ define([
|
|||
var content = msg.content;
|
||||
|
||||
if (msg.author !== content.user.curvePublic) { return void cb(true); }
|
||||
if (!content.href || !content.title || !content.channel) {
|
||||
if (!content.teamChannel && !(content.href && content.title && content.channel)) {
|
||||
console.log('Remove invalid notification');
|
||||
return void cb(true);
|
||||
}
|
||||
|
||||
var channel = content.channel;
|
||||
var channel = content.channel || content.teamChannel;
|
||||
|
||||
if (addOwners[channel]) { return void cb(true); }
|
||||
addOwners[channel] = {
|
||||
|
@ -286,7 +286,7 @@ define([
|
|||
cb(false);
|
||||
};
|
||||
removeHandlers['ADD_OWNER'] = function (ctx, box, data) {
|
||||
var channel = data.content.channel;
|
||||
var channel = data.content.channel || data.content.teamChannel;
|
||||
if (addOwners[channel]) {
|
||||
delete addOwners[channel];
|
||||
}
|
||||
|
@ -297,12 +297,23 @@ define([
|
|||
var content = msg.content;
|
||||
|
||||
if (msg.author !== content.user.curvePublic) { return void cb(true); }
|
||||
if (!content.channel) {
|
||||
if (!content.channel && !content.teamChannel) {
|
||||
console.log('Remove invalid notification');
|
||||
return void cb(true);
|
||||
}
|
||||
|
||||
var channel = content.channel;
|
||||
var channel = content.channel || content.teamChannel;
|
||||
|
||||
// If our ownership rights for a team have been removed, update the owner flag
|
||||
if (content.teamChannel) {
|
||||
var teams = ctx.store.proxy.teams || {};
|
||||
Object.keys(teams).some(function (id) {
|
||||
if (teams[id].channel === channel) {
|
||||
teams[id].owner = false;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (addOwners[channel] && content.pending) {
|
||||
return void cb(false, addOwners[channel]);
|
||||
|
|
|
@ -100,18 +100,6 @@ define([
|
|||
if (membersChannel) { list.push(membersChannel); }
|
||||
if (mailboxChannel) { list.push(mailboxChannel); }
|
||||
|
||||
|
||||
|
||||
// XXX Add the team mailbox
|
||||
/*
|
||||
if (store.proxy.mailboxes) {
|
||||
var mList = Object.keys(store.proxy.mailboxes).map(function (m) {
|
||||
return store.proxy.mailboxes[m].channel;
|
||||
});
|
||||
list = list.concat(mList);
|
||||
}
|
||||
*/
|
||||
|
||||
list.sort();
|
||||
return list;
|
||||
};
|
||||
|
@ -186,7 +174,6 @@ define([
|
|||
channel: secret.channel,
|
||||
secret: secret,
|
||||
validateKey: secret.keys.validateKey
|
||||
// XXX owners: team owner + all admins?
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -314,6 +301,11 @@ define([
|
|||
userName: 'team',
|
||||
classic: true
|
||||
};
|
||||
cfg.onMetadataUpdate = function (md) {
|
||||
var team = ctx.teams[id];
|
||||
if (!team) { return; }
|
||||
ctx.emit('ROSTER_CHANGE', id, team.clients);
|
||||
};
|
||||
lm = Listmap.create(cfg);
|
||||
lm.proxy.on('ready', waitFor());
|
||||
|
||||
|
@ -463,10 +455,14 @@ define([
|
|||
}
|
||||
}));
|
||||
}).nThen(function () {
|
||||
var id = Util.createRandomInteger();
|
||||
config.onMetadataUpdate = function (md) {
|
||||
if (!team) { return; }
|
||||
ctx.emit('ROSTER_CHANGE', id, team.clients);
|
||||
};
|
||||
var lm = Listmap.create(config);
|
||||
var proxy = lm.proxy;
|
||||
proxy.on('ready', function () {
|
||||
var id = Util.createRandomInteger();
|
||||
// Store keys in our drive
|
||||
var keys = {
|
||||
drive: {
|
||||
|
@ -617,12 +613,43 @@ define([
|
|||
var getTeamRoster = function (ctx, data, cId, cb) {
|
||||
var teamId = data.teamId;
|
||||
if (!teamId) { return void cb({error: 'EINVAL'}); }
|
||||
var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]);
|
||||
if (!teamData) { return void cb ({error: 'ENOENT'}); }
|
||||
var team = ctx.teams[teamId];
|
||||
if (!team) { return void cb ({error: 'ENOENT'}); }
|
||||
if (!team.roster) { return void cb({error: 'NO_ROSTER'}); }
|
||||
var state = team.roster.getState() || {};
|
||||
var members = state.members || {};
|
||||
|
||||
// Get pending owners
|
||||
var md = team.listmap.metadata || {};
|
||||
if (Array.isArray(md.pending_owners)) {
|
||||
// Get the members associated to the pending_owners' edPublic and mark them as such
|
||||
md.pending_owners.forEach(function (ed) {
|
||||
var member;
|
||||
Object.keys(members).some(function (curve) {
|
||||
if (members[curve].edPublic === ed) {
|
||||
member = members[curve];
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if ((!member || member.role !== 'OWNER') && teamData.owner) {
|
||||
var removeOwnership = function (chan) {
|
||||
ctx.Store.setPadMetadata(null, {
|
||||
channel: chan,
|
||||
command: 'RM_PENDING_OWNERS',
|
||||
value: [ed],
|
||||
}, function () {});
|
||||
};
|
||||
removeOwnership(teamData.channel);
|
||||
removeOwnership(Util.find(teamData, ['keys', 'roster', 'channel']));
|
||||
removeOwnership(Util.find(teamData, ['keys', 'chat', 'channel']));
|
||||
return;
|
||||
}
|
||||
member.pendingOwner = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Add online status (using messenger data)
|
||||
var chatData = team.getChatData();
|
||||
var online = ctx.store.messenger.getOnlineList(chatData.channel) || [];
|
||||
|
@ -661,6 +688,159 @@ define([
|
|||
});
|
||||
};
|
||||
|
||||
var offerOwnership = function (ctx, data, cId, _cb) {
|
||||
var cb = Util.once(_cb);
|
||||
var teamId = data.teamId;
|
||||
if (!teamId) { return void cb({error: 'EINVAL'}); }
|
||||
var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]);
|
||||
if (!teamData) { return void cb ({error: 'ENOENT'}); }
|
||||
var team = ctx.teams[teamId];
|
||||
if (!team) { return void cb ({error: 'ENOENT'}); }
|
||||
if (!team.roster) { return void cb({error: 'NO_ROSTER'}); }
|
||||
if (!data.curvePublic) { return void cb({error: 'MISSING_DATA'}); }
|
||||
var state = team.roster.getState();
|
||||
var user = state.members[data.curvePublic];
|
||||
nThen(function (waitFor) {
|
||||
// Offer ownership to a friend
|
||||
var onError = function (res) {
|
||||
var err = res && res.error;
|
||||
if (err) {
|
||||
waitFor.abort();
|
||||
return void cb({error:err});
|
||||
}
|
||||
};
|
||||
var addPendingOwner = function (chan) {
|
||||
ctx.Store.setPadMetadata(null, {
|
||||
channel: chan,
|
||||
command: 'ADD_PENDING_OWNERS',
|
||||
value: [user.edPublic],
|
||||
}, waitFor(onError));
|
||||
};
|
||||
// Team proxy
|
||||
addPendingOwner(teamData.channel);
|
||||
// Team roster
|
||||
addPendingOwner(Util.find(teamData, ['keys', 'roster', 'channel']));
|
||||
// Team chat
|
||||
addPendingOwner(Util.find(teamData, ['keys', 'chat', 'channel']));
|
||||
}).nThen(function (waitFor) {
|
||||
var obj = {};
|
||||
obj[user.curvePublic] = {
|
||||
role: 'OWNER'
|
||||
};
|
||||
team.roster.describe(obj, waitFor(function (err) {
|
||||
if (err) { console.error(err); }
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// Send mailbox to offer ownership
|
||||
var myData = Messaging.createData(ctx.store.proxy, false);
|
||||
ctx.store.mailbox.sendTo("ADD_OWNER", {
|
||||
teamChannel: teamData.channel,
|
||||
chatChannel: Util.find(teamData, ['keys', 'chat', 'channel']),
|
||||
rosterChannel: Util.find(teamData, ['keys', 'roster', 'channel']),
|
||||
title: teamData.metadata.name,
|
||||
user: myData
|
||||
}, {
|
||||
channel: user.notifications,
|
||||
curvePublic: user.curvePublic
|
||||
}, waitFor());
|
||||
}).nThen(function () {
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
var revokeOwnership = function (ctx, teamId, user, _cb) {
|
||||
var cb = Util.once(_cb);
|
||||
if (!teamId) { return void cb({error: 'EINVAL'}); }
|
||||
var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]);
|
||||
if (!teamData) { return void cb ({error: 'ENOENT'}); }
|
||||
var team = ctx.teams[teamId];
|
||||
if (!team) { return void cb ({error: 'ENOENT'}); }
|
||||
var md = team.listmap.metadata || {};
|
||||
var isPendingOwner = (md.pending_owners || []).indexOf(user.edPublic) !== -1;
|
||||
nThen(function (waitFor) {
|
||||
var cmd = isPendingOwner ? 'RM_PENDING_OWNERS' : 'RM_OWNERS';
|
||||
|
||||
var onError = function (res) {
|
||||
var err = res && res.error;
|
||||
if (err) {
|
||||
waitFor.abort();
|
||||
return void cb(err);
|
||||
}
|
||||
};
|
||||
var removeOwnership = function (chan) {
|
||||
ctx.Store.setPadMetadata(null, {
|
||||
channel: chan,
|
||||
command: cmd,
|
||||
value: [user.edPublic],
|
||||
}, waitFor(onError));
|
||||
};
|
||||
// Team proxy
|
||||
removeOwnership(teamData.channel);
|
||||
// Team roster
|
||||
removeOwnership(Util.find(teamData, ['keys', 'roster', 'channel']));
|
||||
// Team chat
|
||||
removeOwnership(Util.find(teamData, ['keys', 'chat', 'channel']));
|
||||
}).nThen(function (waitFor) {
|
||||
var obj = {};
|
||||
obj[user.curvePublic] = {
|
||||
role: 'ADMIN',
|
||||
pendingOwner: false
|
||||
};
|
||||
team.roster.describe(obj, waitFor(function (err) {
|
||||
if (err) { console.error(err); }
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// Send mailbox to offer ownership
|
||||
var myData = Messaging.createData(ctx.store.proxy, false);
|
||||
ctx.store.mailbox.sendTo("RM_OWNER", {
|
||||
teamChannel: teamData.channel,
|
||||
title: teamData.metadata.name,
|
||||
pending: isPendingOwner,
|
||||
user: myData
|
||||
}, {
|
||||
channel: user.notifications,
|
||||
curvePublic: user.curvePublic
|
||||
}, waitFor());
|
||||
}).nThen(function () {
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
// We've received an offer to be an owner of the team.
|
||||
// If we accept, we need to set the "owner" flag in our team data
|
||||
// If we decline, we need to change our role back to "ADMIN"
|
||||
var answerOwnership = function (ctx, data, cId, cb) {
|
||||
var myTeams = ctx.store.proxy.teams;
|
||||
var teamId;
|
||||
Object.keys(myTeams).forEach(function (id) {
|
||||
if (myTeams[id].channel === data.teamChannel) {
|
||||
teamId = id;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (!teamId) { return void cb({error: 'EINVAL'}); }
|
||||
var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]);
|
||||
if (!teamData) { return void cb ({error: 'ENOENT'}); }
|
||||
var team = ctx.teams[teamId];
|
||||
if (!team) { return void cb ({error: 'ENOENT'}); }
|
||||
if (!team.roster) { return void cb({error: 'NO_ROSTER'}); }
|
||||
var obj = {};
|
||||
|
||||
// Accept
|
||||
if (data.answer) {
|
||||
teamData.owner = true;
|
||||
return;
|
||||
}
|
||||
// Decline
|
||||
obj[ctx.store.proxy.curvePublic] = {
|
||||
role: 'ADMIN',
|
||||
};
|
||||
team.roster.describe(obj, function (err) {
|
||||
if (err) { return void cb({error: err}); }
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
var describeUser = function (ctx, data, cId, cb) {
|
||||
var teamId = data.teamId;
|
||||
if (!teamId) { return void cb({error: 'EINVAL'}); }
|
||||
|
@ -668,6 +848,21 @@ define([
|
|||
if (!team) { return void cb ({error: 'ENOENT'}); }
|
||||
if (!team.roster) { return void cb({error: 'NO_ROSTER'}); }
|
||||
if (!data.curvePublic || !data.data) { return void cb({error: 'MISSING_DATA'}); }
|
||||
var state = team.roster.getState();
|
||||
var user = state.members[data.curvePublic];
|
||||
|
||||
// It it is an ownership revocation, we have to set it in pad metadata first
|
||||
console.log(user.role, data.data.role);
|
||||
if (user.role === "OWNER" && data.data.role !== "OWNER") {
|
||||
revokeOwnership(ctx, teamId, user, function (err) {
|
||||
console.error(err);
|
||||
if (!err) { return; }
|
||||
waitFor.abort();
|
||||
return void cb({error: err});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var obj = {};
|
||||
obj[data.curvePublic] = data.data;
|
||||
team.roster.describe(obj, function (err) {
|
||||
|
@ -902,6 +1097,12 @@ define([
|
|||
if (cmd === 'SET_TEAM_METADATA') {
|
||||
return void setTeamMetadata(ctx, data, clientId, cb);
|
||||
}
|
||||
if (cmd === 'OFFER_OWNERSHIP') {
|
||||
return void offerOwnership(ctx, data, clientId, cb);
|
||||
}
|
||||
if (cmd === 'ANSWER_OWNERSHIP') {
|
||||
return void answerOwnership(ctx, data, clientId, cb);
|
||||
}
|
||||
if (cmd === 'DESCRIBE_USER') {
|
||||
return void describeUser(ctx, data, clientId, cb);
|
||||
}
|
||||
|
|
|
@ -430,6 +430,9 @@ define([
|
|||
common.displayAvatar($(avatar), data.avatar, data.displayName);
|
||||
// Name
|
||||
var name = h('span.cp-team-member-name', data.displayName);
|
||||
if (data.pendingOwner) {
|
||||
$(name).append(h('em', " PENDING"));
|
||||
}
|
||||
// Status
|
||||
var status = h('span.cp-team-member-status'+(data.online ? '.online' : ''));
|
||||
// Actions
|
||||
|
@ -438,6 +441,28 @@ define([
|
|||
var isMe = me && me.curvePublic === data.curvePublic;
|
||||
var myRole = me ? (ROLES.indexOf(me.role) || 0) : -1;
|
||||
var theirRole = ROLES.indexOf(data.role) || 0;
|
||||
// If they're an admin and I am an owner, I can promote them to owner
|
||||
if (!isMe && myRole > theirRole && theirRole === 1 && !data.pending) {
|
||||
var promote = h('span.fa.fa-angle-double-up', {
|
||||
title: "Offer ownership" // XXX
|
||||
});
|
||||
$(promote).click(function () {
|
||||
$(promote).hide();
|
||||
UI.confirm("Are you sure???", function (yes) { // XXX
|
||||
APP.module.execCommand('OFFER_OWNERSHIP', {
|
||||
teamId: APP.team,
|
||||
curvePublic: data.curvePublic
|
||||
}, function (obj) {
|
||||
if (obj && obj.error) {
|
||||
console.error(obj.error);
|
||||
return void UI.warn(Messages.error);
|
||||
}
|
||||
UI.log("DONE"); // XXX
|
||||
});
|
||||
});
|
||||
});
|
||||
$actions.append(promote);
|
||||
}
|
||||
// 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 && !data.pending) {
|
||||
var promote = h('span.fa.fa-angle-double-up', {
|
||||
|
@ -467,7 +492,8 @@ define([
|
|||
$actions.append(demote);
|
||||
}
|
||||
// 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) {
|
||||
// Note: we can't remove owners, we have to demote them first
|
||||
if (!isMe && myRole > 0 && myRole >= theirRole && theirRole !== 2) {
|
||||
var remove = h('span.fa.fa-times', {
|
||||
title: Messages.team_rosterKick
|
||||
});
|
||||
|
@ -514,7 +540,7 @@ define([
|
|||
var me = roster[userData.curvePublic] || {};
|
||||
var owner = Object.keys(roster).filter(function (k) {
|
||||
if (roster[k].pending) { return; }
|
||||
return roster[k].role === "OWNER";
|
||||
return roster[k].role === "OWNER" || roster[k].pendingOwner;
|
||||
}).map(function (k) {
|
||||
return makeMember(common, roster[k], me);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue