feat: #7743, groups/index, invite, leave,membership

v1.18.x
Barış Soner Uşaklı 6 years ago
parent d5342a40ba
commit a39ca51e06

@ -11,6 +11,7 @@ require('./data')(Groups);
require('./create')(Groups);
require('./delete')(Groups);
require('./update')(Groups);
require('./invite')(Groups);
require('./membership')(Groups);
require('./ownership')(Groups);
require('./search')(Groups);

@ -0,0 +1,105 @@
'use strict';
const db = require('../database');
const user = require('../user');
const utils = require('../utils');
const plugins = require('../plugins');
const notifications = require('../notifications');
module.exports = function (Groups) {
Groups.requestMembership = async function (groupName, uid) {
await inviteOrRequestMembership(groupName, uid, 'request');
const username = await user.getUserField(uid, 'username');
const [notification, owners] = await Promise.all([
notifications.create({
type: 'group-request-membership',
bodyShort: '[[groups:request.notification_title, ' + username + ']]',
bodyLong: '[[groups:request.notification_text, ' + username + ', ' + groupName + ']]',
nid: 'group:' + groupName + ':uid:' + uid + ':request',
path: '/groups/' + utils.slugify(groupName),
from: uid,
}),
Groups.getOwners(groupName),
]);
await notifications.push(notification, owners);
};
Groups.acceptMembership = async function (groupName, uid) {
await db.setsRemove(['group:' + groupName + ':pending', 'group:' + groupName + ':invited'], uid);
await Groups.join(groupName, uid);
};
Groups.rejectMembership = async function (groupNames, uid) {
if (!Array.isArray(groupNames)) {
groupNames = [groupNames];
}
const sets = [];
groupNames.forEach(function (groupName) {
sets.push('group:' + groupName + ':pending', 'group:' + groupName + ':invited');
});
await db.setsRemove(sets, uid);
};
Groups.invite = async function (groupName, uid) {
await inviteOrRequestMembership(groupName, uid, 'invite');
const notification = await notifications.create({
type: 'group-invite',
bodyShort: '[[groups:invited.notification_title, ' + groupName + ']]',
bodyLong: '',
nid: 'group:' + groupName + ':uid:' + uid + ':invite',
path: '/groups/' + utils.slugify(groupName),
});
await notifications.push(notification, [uid]);
};
async function inviteOrRequestMembership(groupName, uid, type) {
if (!(parseInt(uid, 10) > 0)) {
throw new Error('[[error:not-logged-in]]');
}
const [exists, isMember, isPending, isInvited] = await Promise.all([
Groups.exists(groupName),
Groups.isMember(uid, groupName),
Groups.isPending(uid, groupName),
Groups.isInvited(uid, groupName),
]);
if (!exists) {
throw new Error('[[error:no-group]]');
} else if (isMember || (type === 'invite' && isInvited)) {
return;
} else if (type === 'request' && isPending) {
throw new Error('[[error:group-already-requested]]');
}
const set = type === 'invite' ? 'group:' + groupName + ':invited' : 'group:' + groupName + ':pending';
await db.setAdd(set, uid);
const hookName = type === 'invite' ? 'action:group.inviteMember' : 'action:group.requestMembership';
plugins.fireHook(hookName, {
groupName: groupName,
uid: uid,
});
}
Groups.isInvited = async function (uid, groupName) {
if (!(parseInt(uid, 10) > 0)) {
return false;
}
return await db.isSetMember('group:' + groupName + ':invited', uid);
};
Groups.isPending = async function (uid, groupName) {
if (!(parseInt(uid, 10) > 0)) {
return false;
}
return await db.isSetMember('group:' + groupName + ':pending', uid);
};
Groups.getPending = async function (groupName) {
if (!groupName) {
return [];
}
return await db.getSetMembers('group:' + groupName + ':pending');
};
};

@ -1,123 +1,99 @@
'use strict';
const async = require('async');
const db = require('../database');
const user = require('../user');
const plugins = require('../plugins');
module.exports = function (Groups) {
Groups.leave = function (groupNames, uid, callback) {
callback = callback || function () {};
Groups.leave = async function (groupNames, uid) {
if (Array.isArray(groupNames) && !groupNames.length) {
return setImmediate(callback);
return;
}
if (!Array.isArray(groupNames)) {
groupNames = [groupNames];
}
async.waterfall([
function (next) {
async.parallel({
isMembers: async.apply(Groups.isMemberOfGroups, uid, groupNames),
exists: async.apply(Groups.exists, groupNames),
}, next);
},
function (result, next) {
groupNames = groupNames.filter(function (groupName, index) {
return result.isMembers[index] && result.exists[index];
});
if (!groupNames.length) {
return callback();
}
async.parallel([
async.apply(db.sortedSetRemove, groupNames.map(groupName => 'group:' + groupName + ':members'), uid),
async.apply(db.setRemove, groupNames.map(groupName => 'group:' + groupName + ':owners'), uid),
async.apply(db.decrObjectField, groupNames.map(groupName => 'group:' + groupName), 'memberCount'),
], next);
},
function (results, next) {
Groups.clearCache(uid, groupNames);
Groups.getGroupsFields(groupNames, ['name', 'hidden', 'memberCount'], next);
},
function (groupData, next) {
if (!groupData) {
return callback();
}
var tasks = [];
var emptyPrivilegeGroups = groupData.filter(function (groupData) {
return groupData && Groups.isPrivilegeGroup(groupData.name) && groupData.memberCount === 0;
});
if (emptyPrivilegeGroups.length) {
tasks.push(async.apply(Groups.destroy, emptyPrivilegeGroups));
}
var visibleGroups = groupData.filter(groupData => groupData && !groupData.hidden);
if (visibleGroups.length) {
tasks.push(async.apply(db.sortedSetAdd, 'groups:visible:memberCount', visibleGroups.map(groupData => groupData.memberCount), visibleGroups.map(groupData => groupData.name)));
}
async.parallel(tasks, function (err) {
next(err);
});
},
function (next) {
clearGroupTitleIfSet(groupNames, uid, next);
},
function (next) {
plugins.fireHook('action:group.leave', {
groupNames: groupNames,
uid: uid,
});
next();
},
], callback);
};
const [isMembers, exists] = await Promise.all([
Groups.isMemberOfGroups(uid, groupNames),
Groups.exists(groupNames),
]);
const groupsToLeave = groupNames.filter((groupName, index) => isMembers[index] && exists[index]);
if (!groupsToLeave.length) {
return;
}
await Promise.all([
db.sortedSetRemove(groupsToLeave.map(groupName => 'group:' + groupName + ':members'), uid),
db.setRemove(groupsToLeave.map(groupName => 'group:' + groupName + ':owners'), uid),
db.decrObjectField(groupsToLeave.map(groupName => 'group:' + groupName), 'memberCount'),
]);
function clearGroupTitleIfSet(groupNames, uid, callback) {
groupNames = groupNames.filter(function (groupName) {
return groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName);
Groups.clearCache(uid, groupsToLeave);
const groupData = await Groups.getGroupsFields(groupsToLeave, ['name', 'hidden', 'memberCount']);
if (!groupData) {
return;
}
const emptyPrivilegeGroups = groupData.filter(g => g && Groups.isPrivilegeGroup(g.name) && g.memberCount === 0);
const visibleGroups = groupData.filter(g => g && !g.hidden);
const promises = [];
if (emptyPrivilegeGroups.length) {
promises.push(Groups.destroy, emptyPrivilegeGroups);
}
if (visibleGroups.length) {
promises.push(db.sortedSetAdd, 'groups:visible:memberCount',
visibleGroups.map(groupData => groupData.memberCount),
visibleGroups.map(groupData => groupData.name)
);
}
await Promise.all(promises);
await clearGroupTitleIfSet(groupsToLeave, uid);
plugins.fireHook('action:group.leave', {
groupNames: groupsToLeave,
uid: uid,
});
};
async function clearGroupTitleIfSet(groupNames, uid) {
groupNames = groupNames.filter(groupName => groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName));
if (!groupNames.length) {
return callback();
return;
}
const userData = await user.getUserData(uid);
if (!userData) {
return;
}
const newTitleArray = userData.groupTitleArray.filter(groupTitle => !groupNames.includes(groupTitle));
if (newTitleArray.length) {
await db.setObjectField('user:' + uid, 'groupTitle', JSON.stringify(newTitleArray));
} else {
await db.deleteObjectField('user:' + uid, 'groupTitle');
}
async.waterfall([
function (next) {
user.getUserData(uid, next);
},
function (userData, next) {
var newTitleArray = userData.groupTitleArray.filter(function (groupTitle) {
return !groupNames.includes(groupTitle);
});
if (newTitleArray.length) {
db.setObjectField('user:' + uid, 'groupTitle', JSON.stringify(newTitleArray), next);
} else {
db.deleteObjectField('user:' + uid, 'groupTitle', next);
}
},
], callback);
}
Groups.leaveAllGroups = function (uid, callback) {
async.waterfall([
function (next) {
db.getSortedSetRange('groups:createtime', 0, -1, next);
},
function (groups, next) {
async.parallel([
function (next) {
Groups.leave(groups, uid, next);
},
function (next) {
Groups.rejectMembership(groups, uid, next);
},
], next);
},
], callback);
Groups.leaveAllGroups = async function (uid) {
const groups = await db.getSortedSetRange('groups:createtime', 0, -1);
await Promise.all([
Groups.leave(groups, uid),
Groups.rejectMembership(groups, uid),
]);
};
Groups.kick = async function (uid, groupName, isOwner) {
if (isOwner) {
// If the owners set only contains one member, error out!
const numOwners = await db.setCount('group:' + groupName + ':owners');
if (numOwners <= 1) {
throw new Error('[[error:group-needs-owner]]');
}
}
await Groups.leave(groupName, uid);
};
};

@ -1,234 +1,91 @@
'use strict';
var async = require('async');
var _ = require('lodash');
const _ = require('lodash');
var user = require('../user');
var utils = require('../utils');
var plugins = require('../plugins');
var notifications = require('../notifications');
var db = require('../database');
const db = require('../database');
const user = require('../user');
module.exports = function (Groups) {
Groups.requestMembership = function (groupName, uid, callback) {
async.waterfall([
async.apply(inviteOrRequestMembership, groupName, uid, 'request'),
function (next) {
user.getUserField(uid, 'username', next);
},
function (username, next) {
async.parallel({
notification: function (next) {
notifications.create({
type: 'group-request-membership',
bodyShort: '[[groups:request.notification_title, ' + username + ']]',
bodyLong: '[[groups:request.notification_text, ' + username + ', ' + groupName + ']]',
nid: 'group:' + groupName + ':uid:' + uid + ':request',
path: '/groups/' + utils.slugify(groupName),
from: uid,
}, next);
},
owners: function (next) {
Groups.getOwners(groupName, next);
},
}, next);
},
function (results, next) {
if (!results.notification || !results.owners.length) {
return next();
}
notifications.push(results.notification, results.owners, next);
},
], callback);
};
Groups.acceptMembership = function (groupName, uid, callback) {
async.waterfall([
async.apply(db.setsRemove, ['group:' + groupName + ':pending', 'group:' + groupName + ':invited'], uid),
async.apply(Groups.join, groupName, uid),
], callback);
Groups.getMembers = async function (groupName, start, stop) {
return await db.getSortedSetRevRange('group:' + groupName + ':members', start, stop);
};
Groups.rejectMembership = function (groupNames, uid, callback) {
if (!Array.isArray(groupNames)) {
groupNames = [groupNames];
Groups.getMemberUsers = async function (groupNames, start, stop) {
async function get(groupName) {
const uids = await Groups.getMembers(groupName, start, stop);
return await user.getUsersFields(uids, ['uid', 'username', 'picture', 'userslug']);
}
var sets = [];
groupNames.forEach(function (groupName) {
sets.push('group:' + groupName + ':pending', 'group:' + groupName + ':invited');
});
db.setsRemove(sets, uid, callback);
return await Promise.all(groupNames.map(name => get(name)));
};
Groups.invite = function (groupName, uid, callback) {
async.waterfall([
async.apply(inviteOrRequestMembership, groupName, uid, 'invite'),
async.apply(notifications.create, {
type: 'group-invite',
bodyShort: '[[groups:invited.notification_title, ' + groupName + ']]',
bodyLong: '',
nid: 'group:' + groupName + ':uid:' + uid + ':invite',
path: '/groups/' + utils.slugify(groupName),
}),
function (notification, next) {
notifications.push(notification, [uid], next);
},
], callback);
Groups.getMembersOfGroups = async function (groupNames) {
return await db.getSortedSetsMembers(groupNames.map(name => 'group:' + name + ':members'));
};
function inviteOrRequestMembership(groupName, uid, type, callback) {
if (!(parseInt(uid, 10) > 0)) {
return callback(new Error('[[error:not-logged-in]]'));
}
var hookName = type === 'invite' ? 'action:group.inviteMember' : 'action:group.requestMembership';
var set = type === 'invite' ? 'group:' + groupName + ':invited' : 'group:' + groupName + ':pending';
async.waterfall([
function (next) {
async.parallel({
exists: async.apply(Groups.exists, groupName),
isMember: async.apply(Groups.isMember, uid, groupName),
isPending: async.apply(Groups.isPending, uid, groupName),
isInvited: async.apply(Groups.isInvited, uid, groupName),
}, next);
},
function (checks, next) {
if (!checks.exists) {
return next(new Error('[[error:no-group]]'));
} else if (checks.isMember) {
return callback();
} else if (type === 'invite' && checks.isInvited) {
return callback();
} else if (type === 'request' && checks.isPending) {
return next(new Error('[[error:group-already-requested]]'));
}
db.setAdd(set, uid, next);
},
function (next) {
plugins.fireHook(hookName, {
groupName: groupName,
uid: uid,
});
next();
},
], callback);
}
Groups.getMembers = function (groupName, start, stop, callback) {
db.getSortedSetRevRange('group:' + groupName + ':members', start, stop, callback);
};
Groups.getMemberUsers = function (groupNames, start, stop, callback) {
async.map(groupNames, function (groupName, next) {
async.waterfall([
function (next) {
Groups.getMembers(groupName, start, stop, next);
},
function (uids, next) {
user.getUsersFields(uids, ['uid', 'username', 'picture', 'userslug'], next);
},
], next);
}, callback);
};
Groups.getMembersOfGroups = function (groupNames, callback) {
db.getSortedSetsMembers(groupNames.map(name => 'group:' + name + ':members'), callback);
};
Groups.isMember = function (uid, groupName, callback) {
Groups.isMember = async function (uid, groupName) {
if (!uid || parseInt(uid, 10) <= 0 || !groupName) {
return setImmediate(callback, null, false);
return false;
}
var cacheKey = uid + ':' + groupName;
var isMember = Groups.cache.get(cacheKey);
const cacheKey = uid + ':' + groupName;
let isMember = Groups.cache.get(cacheKey);
if (isMember !== undefined) {
Groups.cache.hits += 1;
return setImmediate(callback, null, isMember);
return isMember;
}
Groups.cache.misses += 1;
async.waterfall([
function (next) {
db.isSortedSetMember('group:' + groupName + ':members', uid, next);
},
function (isMember, next) {
Groups.cache.set(cacheKey, isMember);
next(null, isMember);
},
], callback);
isMember = await db.isSortedSetMember('group:' + groupName + ':members', uid);
Groups.cache.set(cacheKey, isMember);
return isMember;
};
Groups.isMembers = function (uids, groupName, callback) {
var cachedData = {};
function getFromCache(next) {
setImmediate(next, null, uids.map(uid => cachedData[uid + ':' + groupName]));
}
Groups.isMembers = async function (uids, groupName) {
if (!groupName || !uids.length) {
return setImmediate(callback, null, uids.map(() => false));
return uids.map(() => false);
}
if (groupName === 'guests') {
return setImmediate(callback, null, uids.map(uid => parseInt(uid, 10) === 0));
return uids.map(uid => parseInt(uid, 10) === 0);
}
var nonCachedUids = uids.filter(uid => filterNonCached(cachedData, uid, groupName));
const cachedData = {};
const nonCachedUids = uids.filter(uid => filterNonCached(cachedData, uid, groupName));
if (!nonCachedUids.length) {
return getFromCache(callback);
return uids.map(uid => cachedData[uid + ':' + groupName]);
}
async.waterfall([
function (next) {
db.isSortedSetMembers('group:' + groupName + ':members', nonCachedUids, next);
},
function (isMembers, next) {
nonCachedUids.forEach(function (uid, index) {
cachedData[uid + ':' + groupName] = isMembers[index];
Groups.cache.set(uid + ':' + groupName, isMembers[index]);
});
getFromCache(next);
},
], callback);
const isMembers = await db.isSortedSetMembers('group:' + groupName + ':members', nonCachedUids);
nonCachedUids.forEach(function (uid, index) {
cachedData[uid + ':' + groupName] = isMembers[index];
Groups.cache.set(uid + ':' + groupName, isMembers[index]);
});
return uids.map(uid => cachedData[uid + ':' + groupName]);
};
Groups.isMemberOfGroups = function (uid, groups, callback) {
var cachedData = {};
function getFromCache(next) {
setImmediate(next, null, groups.map(groupName => cachedData[uid + ':' + groupName]));
}
Groups.isMemberOfGroups = async function (uid, groups) {
if (!uid || parseInt(uid, 10) <= 0 || !groups.length) {
return callback(null, groups.map(groupName => groupName === 'guests'));
return groups.map(groupName => groupName === 'guests');
}
var nonCachedGroups = groups.filter(groupName => filterNonCached(cachedData, uid, groupName));
const cachedData = {};
const nonCachedGroups = groups.filter(groupName => filterNonCached(cachedData, uid, groupName));
if (!nonCachedGroups.length) {
return getFromCache(callback);
return groups.map(groupName => cachedData[uid + ':' + groupName]);
}
const nonCachedGroupsMemberSets = nonCachedGroups.map(groupName => 'group:' + groupName + ':members');
const isMembers = await db.isMemberOfSortedSets(nonCachedGroupsMemberSets, uid);
nonCachedGroups.forEach(function (groupName, index) {
cachedData[uid + ':' + groupName] = isMembers[index];
Groups.cache.set(uid + ':' + groupName, isMembers[index]);
});
async.waterfall([
function (next) {
const nonCachedGroupsMemberSets = nonCachedGroups.map(groupName => 'group:' + groupName + ':members');
db.isMemberOfSortedSets(nonCachedGroupsMemberSets, uid, next);
},
function (isMembers, next) {
nonCachedGroups.forEach(function (groupName, index) {
cachedData[uid + ':' + groupName] = isMembers[index];
Groups.cache.set(uid + ':' + groupName, isMembers[index]);
});
getFromCache(next);
},
], callback);
return groups.map(groupName => cachedData[uid + ':' + groupName]);
};
function filterNonCached(cachedData, uid, groupName) {
var isMember = Groups.cache.get(uid + ':' + groupName);
var isInCache = isMember !== undefined;
const isMember = Groups.cache.get(uid + ':' + groupName);
const isInCache = isMember !== undefined;
if (isInCache) {
Groups.cache.hits += 1;
cachedData[uid + ':' + groupName] = isMember;
@ -238,155 +95,62 @@ module.exports = function (Groups) {
return !isInCache;
}
Groups.isMemberOfAny = function (uid, groups, callback) {
Groups.isMemberOfAny = async function (uid, groups) {
if (!groups.length) {
return setImmediate(callback, null, false);
return false;
}
async.waterfall([
function (next) {
Groups.isMemberOfGroups(uid, groups, next);
},
function (isMembers, next) {
next(null, isMembers.some(isMember => !!isMember));
},
], callback);
};
Groups.getMemberCount = function (groupName, callback) {
async.waterfall([
function (next) {
db.getObjectField('group:' + groupName, 'memberCount', next);
},
function (count, next) {
next(null, parseInt(count, 10));
},
], callback);
const isMembers = await Groups.isMemberOfGroups(uid, groups);
return isMembers.includes(true);
};
Groups.isMemberOfGroupList = function (uid, groupListKey, callback) {
async.waterfall([
function (next) {
db.getSortedSetRange('group:' + groupListKey + ':members', 0, -1, next);
},
function (groupNames, next) {
groupNames = Groups.removeEphemeralGroups(groupNames);
if (!groupNames.length) {
return callback(null, false);
}
Groups.isMemberOfGroups(uid, groupNames, next);
},
function (isMembers, next) {
next(null, isMembers.includes(true));
},
], callback);
Groups.getMemberCount = async function (groupName) {
const count = await db.getObjectField('group:' + groupName, 'memberCount');
return parseInt(count, 10);
};
Groups.isMemberOfGroupsList = function (uid, groupListKeys, callback) {
var uniqueGroups;
var members;
async.waterfall([
function (next) {
const sets = groupListKeys.map(groupName => 'group:' + groupName + ':members');
db.getSortedSetsMembers(sets, next);
},
function (_members, next) {
members = _members;
uniqueGroups = _.uniq(_.flatten(members));
uniqueGroups = Groups.removeEphemeralGroups(uniqueGroups);
Groups.isMemberOfGroups(uid, uniqueGroups, next);
},
function (isMembers, next) {
var map = {};
uniqueGroups.forEach(function (groupName, index) {
map[groupName] = isMembers[index];
});
var result = members.map(function (groupNames) {
for (var i = 0; i < groupNames.length; i += 1) {
if (map[groupNames[i]]) {
return true;
}
}
return false;
});
Groups.isMemberOfGroupList = async function (uid, groupListKey) {
let groupNames = await db.getSortedSetRange('group:' + groupListKey + ':members', 0, -1);
groupNames = Groups.removeEphemeralGroups(groupNames);
if (!groupNames.length) {
return false;
}
next(null, result);
},
], callback);
const isMembers = await Groups.isMemberOfGroups(uid, groupNames);
return isMembers.includes(true);
};
Groups.isMembersOfGroupList = function (uids, groupListKey, callback) {
var groupNames;
var results = uids.map(() => false);
async.waterfall([
function (next) {
db.getSortedSetRange('group:' + groupListKey + ':members', 0, -1, next);
},
function (_groupNames, next) {
groupNames = Groups.removeEphemeralGroups(_groupNames);
Groups.isMemberOfGroupsList = async function (uid, groupListKeys) {
const sets = groupListKeys.map(groupName => 'group:' + groupName + ':members');
const members = await db.getSortedSetsMembers(sets);
if (groupNames.length === 0) {
return callback(null, results);
}
let uniqueGroups = _.uniq(_.flatten(members));
uniqueGroups = Groups.removeEphemeralGroups(uniqueGroups);
async.map(groupNames, function (groupName, next) {
Groups.isMembers(uids, groupName, next);
}, next);
},
function (isGroupMembers, next) {
isGroupMembers.forEach(function (isMembers) {
results.forEach(function (isMember, index) {
if (!isMember && isMembers[index]) {
results[index] = true;
}
});
});
next(null, results);
},
], callback);
};
const isMembers = await Groups.isMemberOfGroups(uid, uniqueGroups);
const isGroupMember = _.zipObject(uniqueGroups, isMembers);
Groups.isInvited = function (uid, groupName, callback) {
if (!(parseInt(uid, 10) > 0)) {
return setImmediate(callback, null, false);
}
db.isSetMember('group:' + groupName + ':invited', uid, callback);
return members.map(function (groupNames) {
return !!groupNames.find(name => isGroupMember[name]);
});
};
Groups.isPending = function (uid, groupName, callback) {
if (!(parseInt(uid, 10) > 0)) {
return setImmediate(callback, null, false);
}
db.isSetMember('group:' + groupName + ':pending', uid, callback);
};
Groups.isMembersOfGroupList = async function (uids, groupListKey) {
const results = uids.map(() => false);
Groups.getPending = function (groupName, callback) {
if (!groupName) {
return setImmediate(callback, null, []);
let groupNames = await db.getSortedSetRange('group:' + groupListKey + ':members', 0, -1);
groupNames = Groups.removeEphemeralGroups(groupNames);
if (!groupNames.length) {
return results;
}
db.getSetMembers('group:' + groupName + ':pending', callback);
};
const isGroupMembers = await Promise.all(groupNames.map(name => Groups.isMembers(uids, name)));
Groups.kick = function (uid, groupName, isOwner, callback) {
if (isOwner) {
// If the owners set only contains one member, error out!
async.waterfall([
function (next) {
db.setCount('group:' + groupName + ':owners', next);
},
function (numOwners, next) {
if (numOwners <= 1) {
return next(new Error('[[error:group-needs-owner]]'));
}
Groups.leave(groupName, uid, next);
},
], callback);
} else {
Groups.leave(groupName, uid, callback);
}
isGroupMembers.forEach(function (isMembers) {
results.forEach(function (isMember, index) {
if (!isMember && isMembers[index]) {
results[index] = true;
}
});
});
return results;
};
};

Loading…
Cancel
Save