mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
DEV: Update discourse-presence plugin to use new PresenceChannel system (#14519)
This removes all custom controllers and redis/messagebus logic from discourse-presence, and replaces it with core's new PresenceChannel system.
All functionality should be retained. This implementation should scale much better to large numbers of users, reduce the number of HTTP requests made by clients, and reduce the volume of messages on the MessageBus.
For more information on PresenceChannel, see 31db8352
This commit is contained in:
@@ -1,117 +1,108 @@
|
||||
import {
|
||||
CLOSED,
|
||||
COMPOSER_TYPE,
|
||||
EDITING,
|
||||
KEEP_ALIVE_DURATION_SECONDS,
|
||||
REPLYING,
|
||||
} from "discourse/plugins/discourse-presence/discourse/lib/presence";
|
||||
import { cancel, throttle } from "@ember/runloop";
|
||||
import discourseComputed, {
|
||||
observes,
|
||||
on,
|
||||
} from "discourse-common/utils/decorators";
|
||||
import { gt, readOnly } from "@ember/object/computed";
|
||||
import { equal, gt, readOnly, union } from "@ember/object/computed";
|
||||
import Component from "@ember/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default Component.extend({
|
||||
// Passed in variables
|
||||
presenceManager: service(),
|
||||
|
||||
@discourseComputed("model.topic.id")
|
||||
users(topicId) {
|
||||
return this.presenceManager.users(topicId);
|
||||
},
|
||||
|
||||
@discourseComputed("model.topic.id")
|
||||
editingUsers(topicId) {
|
||||
return this.presenceManager.editingUsers(topicId);
|
||||
},
|
||||
|
||||
isReply: readOnly("model.replyingToTopic"),
|
||||
isEdit: readOnly("model.editingPost"),
|
||||
|
||||
@on("didInsertElement")
|
||||
subscribe() {
|
||||
this.presenceManager.subscribe(this.get("model.topic.id"), COMPOSER_TYPE);
|
||||
},
|
||||
presence: service(),
|
||||
composerPresenceManager: service(),
|
||||
|
||||
@discourseComputed(
|
||||
"model.post.id",
|
||||
"editingUsers.@each.last_seen",
|
||||
"users.@each.last_seen",
|
||||
"isReply",
|
||||
"isEdit"
|
||||
"model.replyingToTopic",
|
||||
"model.editingPost",
|
||||
"model.whisper",
|
||||
"model.composerOpened",
|
||||
"isDestroying"
|
||||
)
|
||||
presenceUsers(postId, editingUsers, users, isReply, isEdit) {
|
||||
if (isEdit) {
|
||||
return editingUsers.filterBy("post_id", postId);
|
||||
} else if (isReply) {
|
||||
return users;
|
||||
state(replyingToTopic, editingPost, whisper, composerOpen, isDestroying) {
|
||||
if (!composerOpen || isDestroying) {
|
||||
return;
|
||||
} else if (editingPost) {
|
||||
return "edit";
|
||||
} else if (whisper) {
|
||||
return "whisper";
|
||||
} else if (replyingToTopic) {
|
||||
return "reply";
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
isReply: equal("state", "reply"),
|
||||
isEdit: equal("state", "edit"),
|
||||
isWhisper: equal("state", "whisper"),
|
||||
|
||||
@discourseComputed("model.topic.id", "isReply", "isWhisper")
|
||||
replyChannelName(topicId, isReply, isWhisper) {
|
||||
if (topicId && (isReply || isWhisper)) {
|
||||
return `/discourse-presence/reply/${topicId}`;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("model.topic.id", "isReply", "isWhisper")
|
||||
whisperChannelName(topicId, isReply, isWhisper) {
|
||||
if (topicId && this.currentUser.staff && (isReply || isWhisper)) {
|
||||
return `/discourse-presence/whisper/${topicId}`;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("isEdit", "model.post.id")
|
||||
editChannelName(isEdit, postId) {
|
||||
if (isEdit) {
|
||||
return `/discourse-presence/edit/${postId}`;
|
||||
}
|
||||
},
|
||||
|
||||
_setupChannel(channelKey, name) {
|
||||
if (this[channelKey]?.name !== name) {
|
||||
this[channelKey]?.unsubscribe();
|
||||
if (name) {
|
||||
this.set(channelKey, this.presence.getChannel(name));
|
||||
this[channelKey].subscribe();
|
||||
} else if (this[channelKey]) {
|
||||
this.set(channelKey, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@observes("replyChannelName", "whisperChannelName", "editChannelName")
|
||||
_setupChannels() {
|
||||
this._setupChannel("replyChannel", this.replyChannelName);
|
||||
this._setupChannel("whisperChannel", this.whisperChannelName);
|
||||
this._setupChannel("editChannel", this.editChannelName);
|
||||
},
|
||||
|
||||
replyingUsers: union("replyChannel.users", "whisperChannel.users"),
|
||||
editingUsers: readOnly("editChannel.users"),
|
||||
|
||||
@discourseComputed("isReply", "replyingUsers.[]", "editingUsers.[]")
|
||||
presenceUsers(isReply, replyingUsers, editingUsers) {
|
||||
const users = isReply ? replyingUsers : editingUsers;
|
||||
return users
|
||||
?.filter((u) => u.id !== this.currentUser.id)
|
||||
?.slice(0, this.siteSettings.presence_max_users_shown);
|
||||
},
|
||||
|
||||
shouldDisplay: gt("presenceUsers.length", 0),
|
||||
|
||||
@observes("model.reply", "model.title")
|
||||
typing() {
|
||||
throttle(this, this._typing, KEEP_ALIVE_DURATION_SECONDS * 1000);
|
||||
@on("didInsertElement")
|
||||
subscribe() {
|
||||
this._setupChannels();
|
||||
},
|
||||
|
||||
_typing() {
|
||||
if ((!this.isReply && !this.isEdit) || !this.get("model.composerOpened")) {
|
||||
@observes("model.reply", "state", "model.post.id", "model.topic.id")
|
||||
_contentChanged() {
|
||||
if (this.model.reply === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = {
|
||||
topicId: this.get("model.topic.id"),
|
||||
state: this.isEdit ? EDITING : REPLYING,
|
||||
whisper: this.get("model.whisper"),
|
||||
postId: this.get("model.post.id"),
|
||||
presenceStaffOnly: this.get("model._presenceStaffOnly"),
|
||||
};
|
||||
|
||||
this._prevPublishData = data;
|
||||
|
||||
this._throttle = this.presenceManager.publish(
|
||||
data.topicId,
|
||||
data.state,
|
||||
data.whisper,
|
||||
data.postId,
|
||||
data.presenceStaffOnly
|
||||
);
|
||||
},
|
||||
|
||||
@observes("model.whisper")
|
||||
cancelThrottle() {
|
||||
this._cancelThrottle();
|
||||
},
|
||||
|
||||
@observes("model.action", "model.topic.id")
|
||||
composerState() {
|
||||
if (this._prevPublishData) {
|
||||
this.presenceManager.publish(
|
||||
this._prevPublishData.topicId,
|
||||
CLOSED,
|
||||
this._prevPublishData.whisper,
|
||||
this._prevPublishData.postId
|
||||
);
|
||||
this._prevPublishData = null;
|
||||
}
|
||||
const entity = this.state === "edit" ? this.model?.post : this.model?.topic;
|
||||
this.composerPresenceManager.notifyState(this.state, entity?.id);
|
||||
},
|
||||
|
||||
@on("willDestroyElement")
|
||||
closeComposer() {
|
||||
this._cancelThrottle();
|
||||
this._prevPublishData = null;
|
||||
this.presenceManager.cleanUpPresence(COMPOSER_TYPE);
|
||||
},
|
||||
|
||||
_cancelThrottle() {
|
||||
if (this._throttle) {
|
||||
cancel(this._throttle);
|
||||
this._throttle = null;
|
||||
}
|
||||
this._setupChannels();
|
||||
this.composerPresenceManager.leave();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,37 +1,63 @@
|
||||
import discourseComputed, { on } from "discourse-common/utils/decorators";
|
||||
import Component from "@ember/component";
|
||||
import { TOPIC_TYPE } from "discourse/plugins/discourse-presence/discourse/lib/presence";
|
||||
import { gt } from "@ember/object/computed";
|
||||
import { gt, union } from "@ember/object/computed";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default Component.extend({
|
||||
topic: null,
|
||||
topicId: null,
|
||||
presenceManager: service(),
|
||||
presence: service(),
|
||||
replyChannel: null,
|
||||
whisperChannel: null,
|
||||
|
||||
@discourseComputed("replyChannel.users.[]")
|
||||
replyUsers(users) {
|
||||
return users?.filter((u) => u.id !== this.currentUser.id);
|
||||
},
|
||||
|
||||
@discourseComputed("whisperChannel.users.[]")
|
||||
whisperUsers(users) {
|
||||
return users?.filter((u) => u.id !== this.currentUser.id);
|
||||
},
|
||||
|
||||
users: union("replyUsers", "whisperUsers"),
|
||||
|
||||
@discourseComputed("topic.id")
|
||||
users(topicId) {
|
||||
return this.presenceManager.users(topicId);
|
||||
replyChannelName(id) {
|
||||
return `/discourse-presence/reply/${id}`;
|
||||
},
|
||||
|
||||
@discourseComputed("topic.id")
|
||||
whisperChannelName(id) {
|
||||
return `/discourse-presence/whisper/${id}`;
|
||||
},
|
||||
|
||||
shouldDisplay: gt("users.length", 0),
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
if (this.topicId) {
|
||||
this.presenceManager.unsubscribe(this.topicId, TOPIC_TYPE);
|
||||
}
|
||||
this.set("topicId", this.get("topic.id"));
|
||||
},
|
||||
|
||||
@on("didInsertElement")
|
||||
subscribe() {
|
||||
this.set("topicId", this.get("topic.id"));
|
||||
this.presenceManager.subscribe(this.get("topic.id"), TOPIC_TYPE);
|
||||
if (this.replyChannel?.name !== this.replyChannelName) {
|
||||
this.replyChannel?.unsubscribe();
|
||||
this.set("replyChannel", this.presence.getChannel(this.replyChannelName));
|
||||
this.replyChannel.subscribe();
|
||||
}
|
||||
|
||||
if (
|
||||
this.currentUser.staff &&
|
||||
this.whisperChannel?.name !== this.whisperChannelName
|
||||
) {
|
||||
this.whisperChannel?.unsubscribe();
|
||||
this.set(
|
||||
"whisperChannel",
|
||||
this.presence.getChannel(this.whisperChannelName)
|
||||
);
|
||||
this.whisperChannel.subscribe();
|
||||
}
|
||||
},
|
||||
|
||||
@on("willDestroyElement")
|
||||
_destroyed() {
|
||||
this.presenceManager.unsubscribe(this.get("topic.id"), TOPIC_TYPE);
|
||||
this.replyChannel?.unsubscribe();
|
||||
this.whisperChannel?.unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { cancel, later } from "@ember/runloop";
|
||||
import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
// The durations chosen here determines the accuracy of the presence feature and
|
||||
// is tied closely with the server side implementation. Decreasing the duration
|
||||
// to increase the accuracy will come at the expense of having to more network
|
||||
// calls to publish the client's state.
|
||||
//
|
||||
// Logic walk through of our heuristic implementation:
|
||||
// - When client A is typing, a message is published every KEEP_ALIVE_DURATION_SECONDS.
|
||||
// - Client B receives the message and stores each user in an array and marks
|
||||
// the user with a client-side timestamp of when the user was seen.
|
||||
// - If client A continues to type, client B will continue to receive messages to
|
||||
// update the client-side timestamp of when client A was last seen.
|
||||
// - If client A disconnects or becomes inactive, the state of client A will be
|
||||
// cleaned up on client B by a scheduler that runs every TIMER_INTERVAL_MILLISECONDS
|
||||
export const KEEP_ALIVE_DURATION_SECONDS = 10;
|
||||
const BUFFER_DURATION_SECONDS = KEEP_ALIVE_DURATION_SECONDS + 2;
|
||||
|
||||
const MESSAGE_BUS_LAST_ID = 0;
|
||||
const TIMER_INTERVAL_MILLISECONDS = 2000;
|
||||
|
||||
export const REPLYING = "replying";
|
||||
export const EDITING = "editing";
|
||||
export const CLOSED = "closed";
|
||||
|
||||
export const TOPIC_TYPE = "topic";
|
||||
export const COMPOSER_TYPE = "composer";
|
||||
|
||||
const Presence = EmberObject.extend({
|
||||
users: null,
|
||||
editingUsers: null,
|
||||
subscribers: null,
|
||||
topicId: null,
|
||||
currentUser: null,
|
||||
messageBus: null,
|
||||
siteSettings: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.setProperties({
|
||||
users: [],
|
||||
editingUsers: [],
|
||||
subscribers: new Set(),
|
||||
});
|
||||
},
|
||||
|
||||
subscribe(type) {
|
||||
if (this.subscribers.size === 0) {
|
||||
this.messageBus.subscribe(
|
||||
this.channel,
|
||||
(message) => {
|
||||
const { user, state } = message;
|
||||
if (this.get("currentUser.id") === user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case REPLYING:
|
||||
this._appendUser(this.users, user);
|
||||
break;
|
||||
case EDITING:
|
||||
this._appendUser(this.editingUsers, user, {
|
||||
post_id: parseInt(message.post_id, 10),
|
||||
});
|
||||
break;
|
||||
case CLOSED:
|
||||
this._removeUser(user);
|
||||
break;
|
||||
}
|
||||
},
|
||||
MESSAGE_BUS_LAST_ID
|
||||
);
|
||||
}
|
||||
|
||||
this.subscribers.add(type);
|
||||
},
|
||||
|
||||
unsubscribe(type) {
|
||||
this.subscribers.delete(type);
|
||||
const noSubscribers = this.subscribers.size === 0;
|
||||
|
||||
if (noSubscribers) {
|
||||
this.messageBus.unsubscribe(this.channel);
|
||||
this._stopTimer();
|
||||
|
||||
this.setProperties({
|
||||
users: [],
|
||||
editingUsers: [],
|
||||
});
|
||||
}
|
||||
|
||||
return noSubscribers;
|
||||
},
|
||||
|
||||
@discourseComputed("topicId")
|
||||
channel(topicId) {
|
||||
return `/presence-plugin/${topicId}`;
|
||||
},
|
||||
|
||||
publish(state, whisper, postId, staffOnly) {
|
||||
// NOTE: `user_option` is the correct place to get this value from, but
|
||||
// it may not have been set yet. It will always have been set directly
|
||||
// on the currentUser, via the preloaded_json payload.
|
||||
// TODO: Remove this when preloaded_json is refactored.
|
||||
let hiddenProfile = this.get(
|
||||
"currentUser.user_option.hide_profile_and_presence"
|
||||
);
|
||||
if (hiddenProfile === undefined) {
|
||||
hiddenProfile = this.get("currentUser.hide_profile_and_presence");
|
||||
}
|
||||
|
||||
if (hiddenProfile && this.get("siteSettings.allow_users_to_hide_profile")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
state,
|
||||
topic_id: this.topicId,
|
||||
};
|
||||
|
||||
if (whisper) {
|
||||
data.is_whisper = true;
|
||||
}
|
||||
|
||||
if (postId && state === EDITING) {
|
||||
data.post_id = postId;
|
||||
}
|
||||
|
||||
if (staffOnly) {
|
||||
data.staff_only = true;
|
||||
}
|
||||
|
||||
return ajax("/presence-plugin/publish", {
|
||||
type: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
_removeUser(user) {
|
||||
[this.users, this.editingUsers].forEach((users) => {
|
||||
const existingUser = users.findBy("id", user.id);
|
||||
if (existingUser) {
|
||||
users.removeObject(existingUser);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_cleanUpUsers() {
|
||||
[this.users, this.editingUsers].forEach((users) => {
|
||||
const staleUsers = [];
|
||||
|
||||
users.forEach((user) => {
|
||||
if (user.last_seen <= Date.now() - BUFFER_DURATION_SECONDS * 1000) {
|
||||
staleUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
users.removeObjects(staleUsers);
|
||||
});
|
||||
|
||||
return this.users.length === 0 && this.editingUsers.length === 0;
|
||||
},
|
||||
|
||||
_appendUser(users, user, attrs) {
|
||||
let existingUser;
|
||||
let usersLength = 0;
|
||||
|
||||
users.forEach((u) => {
|
||||
if (u.id === user.id) {
|
||||
existingUser = u;
|
||||
}
|
||||
|
||||
if (attrs && attrs.post_id) {
|
||||
if (u.post_id === attrs.post_id) {
|
||||
usersLength++;
|
||||
}
|
||||
} else {
|
||||
usersLength++;
|
||||
}
|
||||
});
|
||||
|
||||
const props = attrs || {};
|
||||
props.last_seen = Date.now();
|
||||
|
||||
if (existingUser) {
|
||||
existingUser.setProperties(props);
|
||||
} else {
|
||||
const limit = this.get("siteSettings.presence_max_users_shown");
|
||||
|
||||
if (usersLength < limit) {
|
||||
users.pushObject(EmberObject.create(Object.assign(user, props)));
|
||||
}
|
||||
}
|
||||
|
||||
this._startTimer(() => {
|
||||
this._cleanUpUsers();
|
||||
});
|
||||
},
|
||||
|
||||
_scheduleTimer(callback) {
|
||||
return later(
|
||||
this,
|
||||
() => {
|
||||
const stop = callback();
|
||||
|
||||
if (!stop) {
|
||||
this.set("_timer", this._scheduleTimer(callback));
|
||||
}
|
||||
},
|
||||
TIMER_INTERVAL_MILLISECONDS
|
||||
);
|
||||
},
|
||||
|
||||
_stopTimer() {
|
||||
cancel(this._timer);
|
||||
},
|
||||
|
||||
_startTimer(callback) {
|
||||
if (!this._timer) {
|
||||
this.set("_timer", this._scheduleTimer(callback));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default Presence;
|
||||
@@ -0,0 +1,64 @@
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
import { cancel, debounce } from "@ember/runloop";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
|
||||
const PRESENCE_CHANNEL_PREFIX = "/discourse-presence";
|
||||
const KEEP_ALIVE_DURATION_SECONDS = 10;
|
||||
|
||||
export default class ComposerPresenceManager extends Service {
|
||||
@service presence;
|
||||
|
||||
notifyState(intent, id) {
|
||||
if (
|
||||
this.siteSettings.allow_users_to_hide_profile &&
|
||||
this.currentUser.hide_profile_and_presence
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === undefined) {
|
||||
return this.leave();
|
||||
}
|
||||
|
||||
if (!["reply", "whisper", "edit"].includes(intent)) {
|
||||
throw `Unknown intent ${intent}`;
|
||||
}
|
||||
|
||||
const state = `${intent}/${id}`;
|
||||
|
||||
if (this._state !== state) {
|
||||
this._enter(intent, id);
|
||||
this._state = state;
|
||||
}
|
||||
|
||||
if (!isTesting()) {
|
||||
this._autoLeaveTimer = debounce(
|
||||
this,
|
||||
this.leave,
|
||||
KEEP_ALIVE_DURATION_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
leave() {
|
||||
this._presentChannel?.leave();
|
||||
this._presentChannel = null;
|
||||
this._state = null;
|
||||
if (this._autoLeaveTimer) {
|
||||
cancel(this._autoLeaveTimer);
|
||||
this._autoLeaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_enter(intent, id) {
|
||||
this.leave();
|
||||
|
||||
let channelName = `${PRESENCE_CHANNEL_PREFIX}/${intent}/${id}`;
|
||||
this._presentChannel = this.presence.getChannel(channelName);
|
||||
this._presentChannel.enter();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this.leave();
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import Presence, {
|
||||
CLOSED,
|
||||
} from "discourse/plugins/discourse-presence/discourse/lib/presence";
|
||||
import Service from "@ember/service";
|
||||
|
||||
const PresenceManager = Service.extend({
|
||||
presences: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.setProperties({
|
||||
presences: {},
|
||||
});
|
||||
},
|
||||
|
||||
subscribe(topicId, type) {
|
||||
if (!topicId) {
|
||||
return;
|
||||
}
|
||||
this._getPresence(topicId).subscribe(type);
|
||||
},
|
||||
|
||||
unsubscribe(topicId, type) {
|
||||
if (!topicId) {
|
||||
return;
|
||||
}
|
||||
const presence = this._getPresence(topicId);
|
||||
|
||||
if (presence.unsubscribe(type)) {
|
||||
delete this.presences[topicId];
|
||||
}
|
||||
},
|
||||
|
||||
users(topicId) {
|
||||
if (!topicId) {
|
||||
return [];
|
||||
}
|
||||
return this._getPresence(topicId).users;
|
||||
},
|
||||
|
||||
editingUsers(topicId) {
|
||||
if (!topicId) {
|
||||
return [];
|
||||
}
|
||||
return this._getPresence(topicId).editingUsers;
|
||||
},
|
||||
|
||||
publish(topicId, state, whisper, postId, staffOnly) {
|
||||
if (!topicId) {
|
||||
return;
|
||||
}
|
||||
return this._getPresence(topicId).publish(
|
||||
state,
|
||||
whisper,
|
||||
postId,
|
||||
staffOnly
|
||||
);
|
||||
},
|
||||
|
||||
cleanUpPresence(type) {
|
||||
Object.keys(this.presences).forEach((key) => {
|
||||
this.publish(key, CLOSED);
|
||||
this.unsubscribe(key, type);
|
||||
});
|
||||
},
|
||||
|
||||
_getPresence(topicId) {
|
||||
if (!this.presences[topicId]) {
|
||||
this.presences[topicId] = Presence.create({
|
||||
messageBus: this.messageBus,
|
||||
siteSettings: this.siteSettings,
|
||||
currentUser: this.currentUser,
|
||||
topicId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.presences[topicId];
|
||||
},
|
||||
});
|
||||
|
||||
export default PresenceManager;
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
shouldRender(_, component) {
|
||||
return component.siteSettings.presence_enabled;
|
||||
},
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
{{!-- Note: the topic-above-footer-buttons outlet is only rendered for logged-in users --}}
|
||||
{{topic-presence-display topic=model}}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
shouldRender(_, component) {
|
||||
return component.siteSettings.presence_enabled;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user