Users can now accept a follow request; Deny and remove next
This commit is contained in:
parent
d5ab53ecad
commit
460070e61d
Binary file not shown.
|
@ -1321,7 +1321,7 @@
|
|||
activityPubSocialManager: {
|
||||
config: {
|
||||
selectedActorInfoFormat: "|00|15{preferredUsername} |08(|02{name}|08)\n|07following|08: {statusIndicator}\n\n|06{plainTextSummary}"
|
||||
statusIndicatorEnabled: "|00|10√"
|
||||
statusFollowing: "|00|10√"
|
||||
staticIndicatorDisabled: "|00|12X"
|
||||
}
|
||||
0: {
|
||||
|
@ -1339,7 +1339,7 @@
|
|||
height: 15
|
||||
width: 34
|
||||
}
|
||||
TM3: {
|
||||
HM3: {
|
||||
focusTextStyle: first lower
|
||||
styleSGR1: "|00|08"
|
||||
}
|
||||
|
|
|
@ -73,6 +73,17 @@ module.exports = class Collection extends ActivityPubObject {
|
|||
);
|
||||
}
|
||||
|
||||
static followRequests(owningUser, page, cb) {
|
||||
return Collection.ownedOrderedByUser(
|
||||
Collections.FollowRequests,
|
||||
owningUser,
|
||||
true, // private
|
||||
page,
|
||||
null, // return full Follow Request Activity
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static outbox(collectionId, page, cb) {
|
||||
return Collection.publicOrderedById(
|
||||
Collections.Outbox,
|
||||
|
@ -97,16 +108,16 @@ module.exports = class Collection extends ActivityPubObject {
|
|||
);
|
||||
}
|
||||
|
||||
static addFollowRequest(owningUser, requestingActor, ignoreDupes, cb) {
|
||||
const collectionId = Endpoints.makeUserUrl(owningUser) + 'follow-requests';
|
||||
static addFollowRequest(owningUser, requestActivity, cb) {
|
||||
const collectionId = Endpoints.makeUserUrl(owningUser) + '/follow-requests';
|
||||
return Collection.addToCollection(
|
||||
Collections.FollowRequests,
|
||||
owningUser,
|
||||
collectionId,
|
||||
requestingActor.id, // Actor requesting to follow owningUser
|
||||
requestingActor,
|
||||
true,
|
||||
ignoreDupes,
|
||||
requestActivity.id,
|
||||
requestActivity,
|
||||
true, // private
|
||||
true, // ignoreDupes
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
@ -549,7 +560,12 @@ module.exports = class Collection extends ActivityPubObject {
|
|||
return cb(err);
|
||||
}
|
||||
|
||||
entries = entries || [];
|
||||
try {
|
||||
entries = (entries || []).map(e => JSON.parse(e.object_json));
|
||||
} catch (e) {
|
||||
Log.error(`Collection "${collectionId}" error: ${e.message}`);
|
||||
}
|
||||
|
||||
if (mapper && entries.length > 0) {
|
||||
entries = entries.map(mapper);
|
||||
}
|
||||
|
|
|
@ -3,9 +3,14 @@ const ActivityPubObject = require('./object');
|
|||
const UserProps = require('../user_property');
|
||||
const { Errors } = require('../enig_error');
|
||||
const Collection = require('./collection');
|
||||
const Actor = require('./actor');
|
||||
const Activity = require('./activity');
|
||||
|
||||
const async = require('async');
|
||||
|
||||
exports.sendFollowRequest = sendFollowRequest;
|
||||
exports.sendUnfollowRequest = sendUnfollowRequest;
|
||||
exports.acceptFollowRequest = acceptFollowRequest;
|
||||
|
||||
function sendFollowRequest(fromUser, toActor, cb) {
|
||||
const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId);
|
||||
|
@ -81,3 +86,55 @@ function sendUnfollowRequest(fromUser, toActor, cb) {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
function acceptFollowRequest(localUser, remoteActor, requestActivity, cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return Collection.addFollower(
|
||||
localUser,
|
||||
remoteActor,
|
||||
true, // ignore dupes
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
Actor.fromLocalUser(localUser, (err, localActor) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const accept = Activity.makeAccept(localActor.id, requestActivity);
|
||||
|
||||
accept.sendTo(remoteActor.inbox, localUser, (err, respBody, res) => {
|
||||
if (err) {
|
||||
return callback(Errors.HttpError(err.message, err.code));
|
||||
}
|
||||
|
||||
if (res.statusCode !== 202 && res.statusCode !== 200) {
|
||||
return callback(
|
||||
Errors.HttpError(
|
||||
`Unexpected HTTP status code ${res.statusCode}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return callback(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
// remove from local requests Collection
|
||||
return Collection.removeOwnedById(
|
||||
Collections.FollowRequests,
|
||||
localUser,
|
||||
requestActivity.id,
|
||||
callback
|
||||
);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,8 +7,13 @@ const stringFormat = require('../string_format');
|
|||
const { pipeToAnsi } = require('../color_codes');
|
||||
const MultiLineEditTextView =
|
||||
require('../multi_line_edit_text_view').MultiLineEditTextView;
|
||||
const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util');
|
||||
const {
|
||||
sendFollowRequest,
|
||||
sendUnfollowRequest,
|
||||
acceptFollowRequest,
|
||||
} = require('./follow_util');
|
||||
const { Collections } = require('./const');
|
||||
const EnigAssert = require('../enigma_assert');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
|
@ -42,11 +47,37 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
|
||||
this.followingActors = [];
|
||||
this.followerActors = [];
|
||||
this.followRequests = [];
|
||||
this.currentCollection = Collections.Following;
|
||||
this.currentHelpText = '';
|
||||
|
||||
this.menuMethods = {
|
||||
spaceKeyPressed: (formData, extraArgs, cb) => {
|
||||
return this._toggleSelectedActorStatus(cb);
|
||||
actorListKeyPressed: (formData, extraArgs, cb) => {
|
||||
switch (formData.key.name) {
|
||||
case 'space':
|
||||
{
|
||||
if (this.currentCollection === Collections.Following) {
|
||||
return this._toggleFollowing(cb);
|
||||
} else if (
|
||||
this.currentCollection === Collections.FollowRequests
|
||||
) {
|
||||
return this._acceptFollowRequest(cb);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
{
|
||||
if (this.currentCollection === Collections.Followers) {
|
||||
return this._removeFollower(cb);
|
||||
} else if (
|
||||
this.currentCollection === Collections.FollowRequests
|
||||
) {
|
||||
return this._denyFollowRequest(cb);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
listKeyPressed: (formData, extraArgs, cb) => {
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
|
@ -110,32 +141,7 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
);
|
||||
},
|
||||
callback => {
|
||||
this._fetchActorList(
|
||||
Collections.Following,
|
||||
(err, followingActors) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return this._fetchActorList(
|
||||
Collections.Followers,
|
||||
(err, followerActors) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const mapper = a => {
|
||||
a.plainTextSummary = htmlToMessageBody(a.summary);
|
||||
return a;
|
||||
};
|
||||
|
||||
this.followingActors = followingActors.map(mapper);
|
||||
this.followerActors = followerActors.map(mapper);
|
||||
|
||||
return callback(null);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
return this._populateActorLists(callback);
|
||||
},
|
||||
callback => {
|
||||
const v = id => this.getView('main', id);
|
||||
|
@ -156,11 +162,12 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
});
|
||||
|
||||
navMenuView.on('index update', index => {
|
||||
if (0 === index) {
|
||||
this._switchTo(Collections.Following);
|
||||
} else {
|
||||
this._switchTo(Collections.Followers);
|
||||
}
|
||||
const collectionName = [
|
||||
Collections.Following,
|
||||
Collections.Followers,
|
||||
Collections.FollowRequests,
|
||||
][index];
|
||||
this._switchTo(collectionName);
|
||||
});
|
||||
|
||||
return callback(null);
|
||||
|
@ -175,11 +182,28 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
_switchTo(collectionName) {
|
||||
this.currentCollection = collectionName;
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
if (Collections.Following === collectionName) {
|
||||
actorListView.setItems(this.followingActors);
|
||||
} else {
|
||||
actorListView.setItems(this.followerActors);
|
||||
|
||||
let list;
|
||||
switch (collectionName) {
|
||||
case Collections.Following:
|
||||
list = this.followingActors;
|
||||
this.currentHelpText =
|
||||
this.config.helpTextFollowing || 'SPC = Toggle Follower';
|
||||
break;
|
||||
case Collections.Followers:
|
||||
list = this.followerActors;
|
||||
this.currentHelpText =
|
||||
this.config.helpTextFollowers || 'DEL = Remove Follower';
|
||||
break;
|
||||
case Collections.FollowRequests:
|
||||
list = this.followRequests;
|
||||
this.currentHelpText =
|
||||
this.config.helpTextFollowRequests || 'SPC = Accept\r\nDEL = Deny';
|
||||
break;
|
||||
}
|
||||
EnigAssert(list);
|
||||
|
||||
actorListView.setItems(list);
|
||||
actorListView.redraw();
|
||||
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
|
@ -193,14 +217,23 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
this._updateSelectedActorInfo(selectedActorInfoView, selectedActor);
|
||||
} else {
|
||||
selectedActorInfoView.setText('');
|
||||
this.updateCustomViewTextsWithFilter(
|
||||
'main',
|
||||
MciViewIds.main.customRangeStart,
|
||||
this._getCustomInfoFormatObject(null),
|
||||
{ pipeSupport: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_getSelectedActorItem(index) {
|
||||
if (this.currentCollection === Collections.Following) {
|
||||
return this.followingActors[index];
|
||||
} else {
|
||||
return this.followerActors[index];
|
||||
switch (this.currentCollection) {
|
||||
case Collections.Following:
|
||||
return this.followingActors[index];
|
||||
case Collections.Followers:
|
||||
return this.followerActors[index];
|
||||
case Collections.FollowRequests:
|
||||
return this.followRequests[index];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -231,11 +264,12 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
this.updateCustomViewTextsWithFilter(
|
||||
'main',
|
||||
MciViewIds.main.customRangeStart,
|
||||
this._getCustomInfoFormatObject(actorInfo)
|
||||
this._getCustomInfoFormatObject(actorInfo),
|
||||
{ pipeSupport: true }
|
||||
);
|
||||
}
|
||||
|
||||
_toggleSelectedActorStatus(cb) {
|
||||
_toggleFollowing(cb) {
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
actorListView.getFocusItemIndex()
|
||||
|
@ -274,6 +308,49 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
}
|
||||
}
|
||||
|
||||
_acceptFollowRequest(cb) {
|
||||
EnigAssert(Collections.FollowRequests === this.currentCollection);
|
||||
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
actorListView.getFocusItemIndex()
|
||||
);
|
||||
|
||||
if (!selectedActor) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
const request = selectedActor.request;
|
||||
EnigAssert(request);
|
||||
|
||||
acceptFollowRequest(this.client.user, selectedActor, request, err => {
|
||||
if (err) {
|
||||
this.client.log.error(
|
||||
{ error: err.message },
|
||||
'Failed to fully accept Follow request'
|
||||
);
|
||||
}
|
||||
|
||||
const followingActor = this.followRequests.splice(
|
||||
actorListView.getFocusItemIndex(),
|
||||
1
|
||||
)[0];
|
||||
this.followerActors.push(followingActor); // move to followers
|
||||
|
||||
this._switchTo(this.currentCollection); // redraw
|
||||
|
||||
return cb(err);
|
||||
});
|
||||
}
|
||||
|
||||
_removeFollower(cb) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_denyFollowRequest(cb) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_followingActorToggled(actorInfo, cb) {
|
||||
// Local user/Actor wants to follow or un-follow
|
||||
const wantsToFollow = actorInfo.status;
|
||||
|
@ -327,6 +404,7 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
selectedActorStatus: actorInfo ? actorInfo.status : false,
|
||||
selectedActorStatusIndicator: v('statusIndicator'),
|
||||
text: v('name'),
|
||||
helpText: this.currentHelpText,
|
||||
});
|
||||
|
||||
return formatObj;
|
||||
|
@ -334,8 +412,90 @@ exports.getModule = class activityPubSocialManager extends MenuModule {
|
|||
|
||||
_getStatusIndicator(enabled) {
|
||||
return enabled
|
||||
? this.config.statusIndicatorEnabled || '√'
|
||||
: this.config.statusIndicatorDisabled || 'X';
|
||||
? this.config.statusFollowing || '√'
|
||||
: this.config.statusNotFollowing || 'X';
|
||||
}
|
||||
|
||||
_populateActorLists(cb) {
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
return this._fetchActorList(Collections.Following, callback);
|
||||
},
|
||||
(following, callback) => {
|
||||
this._fetchActorList(Collections.Followers, (err, followers) => {
|
||||
return callback(err, following, followers);
|
||||
});
|
||||
},
|
||||
(following, followers, callback) => {
|
||||
this._fetchFollowRequestActors((err, followRequests) => {
|
||||
return callback(err, following, followers, followRequests);
|
||||
});
|
||||
},
|
||||
(following, followers, followRequests, callback) => {
|
||||
const mapper = a => {
|
||||
a.plainTextSummary = htmlToMessageBody(a.summary);
|
||||
return a;
|
||||
};
|
||||
|
||||
this.followingActors = following.map(mapper);
|
||||
this.followerActors = followers.map(mapper);
|
||||
this.followRequests = followRequests.map(mapper);
|
||||
|
||||
return callback(null);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_fetchFollowRequestActors(cb) {
|
||||
Collection.followRequests(this.client.user, 'all', (err, collection) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!collection.orderedItems || collection.orderedItems.length < 1) {
|
||||
return cb(null, []);
|
||||
}
|
||||
|
||||
const statusIndicator = this._getStatusIndicator(false);
|
||||
|
||||
async.mapLimit(
|
||||
collection.orderedItems,
|
||||
4,
|
||||
(request, nextRequest) => {
|
||||
const actorId = request.actor;
|
||||
Actor.fromId(actorId, (err, actor, subject) => {
|
||||
if (err) {
|
||||
this.client.log.warn({ actorId }, 'Failed to retrieve Actor');
|
||||
return nextRequest(null, null);
|
||||
}
|
||||
|
||||
// Add some of our own properties
|
||||
Object.assign(actor, {
|
||||
subject,
|
||||
status: false,
|
||||
statusIndicator,
|
||||
text: actor.preferredUsername,
|
||||
request,
|
||||
});
|
||||
|
||||
return nextRequest(null, actor);
|
||||
});
|
||||
},
|
||||
(err, actorsList) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
actorsList = actorsList.filter(f => f); // drop nulls
|
||||
return cb(null, actorsList);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_fetchActorList(collectionName, cb) {
|
||||
|
|
|
@ -13,6 +13,7 @@ const MultiLineEditTextView =
|
|||
const Errors = require('../core/enig_error.js').Errors;
|
||||
const { getPredefinedMCIValue } = require('../core/predefined_mci.js');
|
||||
const EnigAssert = require('./enigma_assert');
|
||||
const { pipeToAnsi } = require('./color_codes.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
|
@ -774,10 +775,25 @@ exports.MenuModule = class MenuModule extends PluginModule {
|
|||
const format = config[view.key];
|
||||
const text = stringFormat(format, fmtObj);
|
||||
|
||||
if (options.appendMultiLine && view instanceof MultiLineEditTextView) {
|
||||
view.addText(text);
|
||||
if (view instanceof MultiLineEditTextView) {
|
||||
if (options.appendMultiLine) {
|
||||
view.addText(text);
|
||||
} else {
|
||||
if (options.pipeSupport) {
|
||||
const ansi = pipeToAnsi(text, this.client);
|
||||
if (view.getData() !== ansi) {
|
||||
view.setAnsi(ansi);
|
||||
} else {
|
||||
view.redraw();
|
||||
}
|
||||
} else if (view.getData() !== text) {
|
||||
view.setText(text);
|
||||
} else {
|
||||
view.redraw();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (view.getData() != text) {
|
||||
if (view.getData() !== text) {
|
||||
view.setText(text);
|
||||
} else {
|
||||
view.redraw();
|
||||
|
|
|
@ -6,6 +6,7 @@ const {
|
|||
getActorId,
|
||||
prepareLocalUserAsActor,
|
||||
} = require('../../../activitypub/util');
|
||||
const { acceptFollowRequest } = require('../../../activitypub/follow_util');
|
||||
const SysLog = require('../../../logger').log;
|
||||
const {
|
||||
ActivityStreamMediaType,
|
||||
|
@ -750,26 +751,36 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
// User accepts any followers automatically
|
||||
const activityPubSettings = ActivityPubSettings.fromUser(localUser);
|
||||
if (!activityPubSettings.manuallyApproveFollowers) {
|
||||
this._recordAcceptedFollowRequest(localUser, remoteActor, activity);
|
||||
return this.webServer.accepted(resp);
|
||||
}
|
||||
|
||||
// User manually approves requests; add them to their requests collection
|
||||
Collection.addFollowRequest(
|
||||
localUser,
|
||||
remoteActor,
|
||||
true, // ignore dupes
|
||||
err => {
|
||||
const addReq = () => {
|
||||
// User manually approves requests; add them to their requests collection
|
||||
// :FIXME: We need to store the Activity and fetch the Actor as needed later;
|
||||
// when accepting a request, we send back the Activity!
|
||||
Collection.addFollowRequest(localUser, activity, err => {
|
||||
if (err) {
|
||||
return this.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
return this.webServer.accepted(resp);
|
||||
});
|
||||
};
|
||||
|
||||
// User accepts any followers automatically
|
||||
const activityPubSettings = ActivityPubSettings.fromUser(localUser);
|
||||
if (activityPubSettings.manuallyApproveFollowers) {
|
||||
return addReq();
|
||||
}
|
||||
|
||||
acceptFollowRequest(localUser, remoteActor, activity, err => {
|
||||
if (err) {
|
||||
this.log.warn(
|
||||
{ error: err.message },
|
||||
'Failed to post Accept. Recording to requests instead.'
|
||||
);
|
||||
return addReq();
|
||||
}
|
||||
);
|
||||
|
||||
return this.webServer.accepted(resp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1105,81 +1116,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
});
|
||||
}
|
||||
|
||||
_recordAcceptedFollowRequest(localUser, remoteActor, requestActivity) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return Collection.addFollower(
|
||||
localUser,
|
||||
remoteActor,
|
||||
true, // ignore dupes
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
Actor.fromLocalUser(localUser, (err, localActor) => {
|
||||
if (err) {
|
||||
this.log.warn(
|
||||
{ inbox: remoteActor.inbox, error: err.message },
|
||||
'Failed to load local Actor for "Accept"'
|
||||
);
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const accept = Activity.makeAccept(
|
||||
localActor.id,
|
||||
requestActivity
|
||||
);
|
||||
|
||||
accept.sendTo(
|
||||
remoteActor.inbox,
|
||||
localUser,
|
||||
(err, respBody, res) => {
|
||||
if (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
inbox: remoteActor.inbox,
|
||||
error: err.message,
|
||||
},
|
||||
'Failed POSTing "Accept" to inbox'
|
||||
);
|
||||
return callback(null); // just a warning
|
||||
}
|
||||
|
||||
if (res.statusCode !== 202 && res.statusCode !== 200) {
|
||||
this.log.warn(
|
||||
{
|
||||
inbox: remoteActor.inbox,
|
||||
statusCode: res.statusCode,
|
||||
},
|
||||
'Unexpected status code'
|
||||
);
|
||||
return callback(null); // just a warning
|
||||
}
|
||||
|
||||
this.log.info(
|
||||
{ inbox: remoteActor.inbox },
|
||||
'Remote server received our "Accept" successfully'
|
||||
);
|
||||
|
||||
return callback(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
],
|
||||
err => {
|
||||
if (err) {
|
||||
// :TODO: move this request to the "Request queue" for the user to try later
|
||||
this.log.error(
|
||||
{ error: err.message },
|
||||
'Failed processing Follow request'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_selfAsActorHandler(localUser, localActor, req, resp) {
|
||||
this.log.info(
|
||||
{ username: localUser.username },
|
||||
|
|
|
@ -209,7 +209,7 @@
|
|||
MT2: {mode: "preview", acceptsFocus: false, acceptsInput: false}
|
||||
TM3: {
|
||||
focus: true
|
||||
items: ["following", "followers"]
|
||||
items: ["following", "followers", "pending requests"]
|
||||
}
|
||||
}
|
||||
actionKeys: [
|
||||
|
|
Loading…
Reference in New Issue