mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-10 16:43:31 +01:00
Partials (#3070)
* Remove GroupDMChannels they sparked no joy * Start partials for message deletion * MessageUpdate partials * Add partials as an opt-in client option * Add fetch() to Message * Message.author should never be undefined * Fix channels being the wrong type * Allow fetching channels * Refactor and add reaction add partials * Reaction remove partials * Check for emoji first * fix message fetching janky * User partials in audit logs * refactor overwrite code * guild member partials * partials as a whitelist * document GuildMember#fetch * fix: check whether a structure is a partial, not whether cache is true * typings: Updated for latest commit (#3075) * partials: fix messageUpdate behaviour (now "old" message can be partial) * partials: add warnings and docs * partials: add partials to index.yml * partials: tighten "partial" definitions * partials: fix embed-only messages counting as partials
This commit is contained in:
@@ -330,7 +330,7 @@ module.exports = APIMessage;
|
||||
|
||||
/**
|
||||
* A target for a message.
|
||||
* @typedef {TextChannel|DMChannel|GroupDMChannel|User|GuildMember|Webhook|WebhookClient} MessageTarget
|
||||
* @typedef {TextChannel|DMChannel|User|GuildMember|Webhook|WebhookClient} MessageTarget
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@ class Channel extends Base {
|
||||
/**
|
||||
* The type of the channel, either:
|
||||
* * `dm` - a DM channel
|
||||
* * `group` - a Group DM channel
|
||||
* * `text` - a guild text channel
|
||||
* * `voice` - a guild voice channel
|
||||
* * `category` - a guild category channel
|
||||
@@ -84,15 +83,20 @@ class Channel extends Base {
|
||||
return this.client.api.channels(this.id).delete().then(() => this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches this channel.
|
||||
* @returns {Promise<Channel>}
|
||||
*/
|
||||
fetch() {
|
||||
return this.client.channels.fetch(this.id, true);
|
||||
}
|
||||
|
||||
static create(client, data, guild) {
|
||||
const Structures = require('../util/Structures');
|
||||
let channel;
|
||||
if (data.type === ChannelTypes.DM) {
|
||||
if (data.type === ChannelTypes.DM || (data.type !== ChannelTypes.GROUP && !data.guild_id && !guild)) {
|
||||
const DMChannel = Structures.get('DMChannel');
|
||||
channel = new DMChannel(client, data);
|
||||
} else if (data.type === ChannelTypes.GROUP) {
|
||||
const GroupDMChannel = Structures.get('GroupDMChannel');
|
||||
channel = new GroupDMChannel(client, data);
|
||||
} else {
|
||||
guild = guild || client.guilds.get(data.guild_id);
|
||||
if (guild) {
|
||||
|
||||
@@ -164,42 +164,6 @@ class ClientUser extends Structures.get('User') {
|
||||
setAFK(afk) {
|
||||
return this.setPresence({ afk });
|
||||
}
|
||||
|
||||
/**
|
||||
* An object containing either a user or access token, and an optional nickname.
|
||||
* @typedef {Object} GroupDMRecipientOptions
|
||||
* @property {UserResolvable} [user] User to add to the Group DM
|
||||
* @property {string} [accessToken] Access token to use to add a user to the Group DM
|
||||
* (only available if a bot is creating the DM)
|
||||
* @property {string} [nick] Permanent nickname (only available if a bot is creating the DM)
|
||||
* @property {string} [id] If no user resolvable is provided and you want to assign nicknames
|
||||
* you must provide user ids instead
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a Group DM.
|
||||
* @param {GroupDMRecipientOptions[]} recipients The recipients
|
||||
* @returns {Promise<GroupDMChannel>}
|
||||
* @example
|
||||
* // Create a Group DM with a token provided from OAuth
|
||||
* client.user.createGroupDM([{
|
||||
* user: '66564597481480192',
|
||||
* accessToken: token
|
||||
* }])
|
||||
* .then(console.log)
|
||||
* .catch(console.error);
|
||||
*/
|
||||
createGroupDM(recipients) {
|
||||
const data = this.bot ? {
|
||||
access_tokens: recipients.map(u => u.accessToken),
|
||||
nicks: recipients.reduce((o, r) => {
|
||||
if (r.nick) o[r.user ? r.user.id : r.id] = r.nick;
|
||||
return o;
|
||||
}, {}),
|
||||
} : { recipients: recipients.map(u => this.client.users.resolveID(u.user || u.id)) };
|
||||
return this.client.api.users('@me').channels.post({ data })
|
||||
.then(res => this.client.channels.add(res));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClientUser;
|
||||
|
||||
@@ -12,6 +12,8 @@ const MessageStore = require('../stores/MessageStore');
|
||||
class DMChannel extends Channel {
|
||||
constructor(client, data) {
|
||||
super(client, data);
|
||||
// Override the channel type so partials have a known type
|
||||
this.type = 'dm';
|
||||
/**
|
||||
* A collection containing the messages sent to this channel
|
||||
* @type {MessageStore<Snowflake, Message>}
|
||||
@@ -23,11 +25,13 @@ class DMChannel extends Channel {
|
||||
_patch(data) {
|
||||
super._patch(data);
|
||||
|
||||
/**
|
||||
* The recipient on the other end of the DM
|
||||
* @type {User}
|
||||
*/
|
||||
this.recipient = this.client.users.add(data.recipients[0]);
|
||||
if (data.recipients) {
|
||||
/**
|
||||
* The recipient on the other end of the DM
|
||||
* @type {User}
|
||||
*/
|
||||
this.recipient = this.client.users.add(data.recipients[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the last message in the channel, if one was sent
|
||||
@@ -42,6 +46,14 @@ class DMChannel extends Channel {
|
||||
this.lastPinTimestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp).getTime() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this DMChannel is a partial
|
||||
* @type {boolean}
|
||||
*/
|
||||
get partial() {
|
||||
return !this.recipient;
|
||||
}
|
||||
|
||||
/**
|
||||
* When concatenated with a string, this automatically returns the recipient's mention instead of the
|
||||
* DMChannel object.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Channel = require('./Channel');
|
||||
const TextBasedChannel = require('./interfaces/TextBasedChannel');
|
||||
const Collection = require('../util/Collection');
|
||||
const DataResolver = require('../util/DataResolver');
|
||||
const MessageStore = require('../stores/MessageStore');
|
||||
|
||||
/*
|
||||
{ type: 3,
|
||||
recipients:
|
||||
[ { username: 'Charlie',
|
||||
id: '123',
|
||||
discriminator: '6631',
|
||||
avatar: '123' },
|
||||
{ username: 'Ben',
|
||||
id: '123',
|
||||
discriminator: '2055',
|
||||
avatar: '123' },
|
||||
{ username: 'Adam',
|
||||
id: '123',
|
||||
discriminator: '2406',
|
||||
avatar: '123' } ],
|
||||
owner_id: '123',
|
||||
name: null,
|
||||
last_message_id: '123',
|
||||
id: '123',
|
||||
icon: null }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a Group DM on Discord.
|
||||
* @extends {Channel}
|
||||
* @implements {TextBasedChannel}
|
||||
*/
|
||||
class GroupDMChannel extends Channel {
|
||||
constructor(client, data) {
|
||||
super(client, data);
|
||||
/**
|
||||
* A collection containing the messages sent to this channel
|
||||
* @type {MessageStore<Snowflake, Message>}
|
||||
*/
|
||||
this.messages = new MessageStore(this);
|
||||
this._typing = new Map();
|
||||
}
|
||||
|
||||
_patch(data) {
|
||||
super._patch(data);
|
||||
|
||||
/**
|
||||
* The name of this Group DM, can be null if one isn't set
|
||||
* @type {string}
|
||||
*/
|
||||
this.name = data.name;
|
||||
|
||||
/**
|
||||
* A hash of this Group DM icon
|
||||
* @type {?string}
|
||||
*/
|
||||
this.icon = data.icon;
|
||||
|
||||
/**
|
||||
* The user ID of this Group DM's owner
|
||||
* @type {Snowflake}
|
||||
*/
|
||||
this.ownerID = data.owner_id;
|
||||
|
||||
/**
|
||||
* If the DM is managed by an application
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.managed = data.managed;
|
||||
|
||||
/**
|
||||
* Application ID of the application that made this Group DM, if applicable
|
||||
* @type {?Snowflake}
|
||||
*/
|
||||
this.applicationID = data.application_id;
|
||||
|
||||
if (data.nicks) {
|
||||
/**
|
||||
* Nicknames for group members
|
||||
* @type {?Collection<Snowflake, string>}
|
||||
*/
|
||||
this.nicks = new Collection(data.nicks.map(n => [n.id, n.nick]));
|
||||
}
|
||||
|
||||
if (!this.recipients) {
|
||||
/**
|
||||
* A collection of the recipients of this DM, mapped by their ID
|
||||
* @type {Collection<Snowflake, User>}
|
||||
*/
|
||||
this.recipients = new Collection();
|
||||
}
|
||||
|
||||
if (data.recipients) {
|
||||
for (const recipient of data.recipients) {
|
||||
const user = this.client.users.add(recipient);
|
||||
this.recipients.set(user.id, user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the last message in the channel, if one was sent
|
||||
* @type {?Snowflake}
|
||||
*/
|
||||
this.lastMessageID = data.last_message_id;
|
||||
|
||||
/**
|
||||
* The timestamp when the last pinned message was pinned, if there was one
|
||||
* @type {?number}
|
||||
*/
|
||||
this.lastPinTimestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp).getTime() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner of this Group DM
|
||||
* @type {?User}
|
||||
* @readonly
|
||||
*/
|
||||
get owner() {
|
||||
return this.client.users.get(this.ownerID) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the URL to this Group DM's icon.
|
||||
* @param {ImageURLOptions} [options={}] Options for the Image URL
|
||||
* @returns {?string}
|
||||
*/
|
||||
iconURL({ format, size } = {}) {
|
||||
if (!this.icon) return null;
|
||||
return this.client.rest.cdn.GDMIcon(this.id, this.icon, format, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this channel equals another channel. It compares all properties, so for most operations
|
||||
* it is advisable to just compare `channel.id === channel2.id` as it is much faster and is often
|
||||
* what most users need.
|
||||
* @param {GroupDMChannel} channel Channel to compare with
|
||||
* @returns {boolean}
|
||||
*/
|
||||
equals(channel) {
|
||||
const equal = channel &&
|
||||
this.id === channel.id &&
|
||||
this.name === channel.name &&
|
||||
this.icon === channel.icon &&
|
||||
this.ownerID === channel.ownerID;
|
||||
|
||||
if (equal) {
|
||||
return this.recipients.equals(channel.recipients);
|
||||
}
|
||||
|
||||
return equal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits this Group DM.
|
||||
* @param {Object} data New data for this Group DM
|
||||
* @param {string} [reason] Reason for editing this Group DM
|
||||
* @returns {Promise<GroupDMChannel>}
|
||||
*/
|
||||
edit(data, reason) {
|
||||
return this.client.api.channels[this.id].patch({
|
||||
data: {
|
||||
icon: data.icon,
|
||||
name: data.name === null ? null : data.name || this.name,
|
||||
},
|
||||
reason,
|
||||
}).then(() => this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new icon for this Group DM.
|
||||
* @param {Base64Resolvable|BufferResolvable} icon The new icon of this Group DM
|
||||
* @returns {Promise<GroupDMChannel>}
|
||||
*/
|
||||
async setIcon(icon) {
|
||||
return this.edit({ icon: await DataResolver.resolveImage(icon) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new name for this Group DM.
|
||||
* @param {string} name New name for this Group DM
|
||||
* @returns {Promise<GroupDMChannel>}
|
||||
*/
|
||||
setName(name) {
|
||||
return this.edit({ name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a user to this Group DM.
|
||||
* @param {Object} options Options for this method
|
||||
* @param {UserResolvable} options.user User to add to this Group DM
|
||||
* @param {string} [options.accessToken] Access token to use to add the user to this Group DM
|
||||
* @param {string} [options.nick] Permanent nickname to give the user
|
||||
* @returns {Promise<GroupDMChannel>}
|
||||
*/
|
||||
addUser({ user, accessToken, nick }) {
|
||||
const id = this.client.users.resolveID(user);
|
||||
return this.client.api.channels[this.id].recipients[id].put({ nick, access_token: accessToken })
|
||||
.then(() => this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from this Group DM.
|
||||
* @param {UserResolvable} user User to remove
|
||||
* @returns {Promise<GroupDMChannel>}
|
||||
*/
|
||||
removeUser(user) {
|
||||
const id = this.client.users.resolveID(user);
|
||||
return this.client.api.channels[this.id].recipients[id].delete()
|
||||
.then(() => this);
|
||||
}
|
||||
|
||||
/**
|
||||
* When concatenated with a string, this automatically returns the channel's name instead of the
|
||||
* GroupDMChannel object.
|
||||
* @returns {string}
|
||||
* @example
|
||||
* // Logs: Hello from My Group DM!
|
||||
* console.log(`Hello from ${channel}!`);
|
||||
*/
|
||||
toString() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
// These are here only for documentation purposes - they are implemented by TextBasedChannel
|
||||
/* eslint-disable no-empty-function */
|
||||
get lastMessage() {}
|
||||
get lastPinAt() {}
|
||||
send() {}
|
||||
startTyping() {}
|
||||
stopTyping() {}
|
||||
get typing() {}
|
||||
get typingCount() {}
|
||||
createMessageCollector() {}
|
||||
awaitMessages() {}
|
||||
// Doesn't work on Group DMs; bulkDelete() {}
|
||||
acknowledge() {}
|
||||
_cacheMessage() {}
|
||||
}
|
||||
|
||||
TextBasedChannel.applyToClass(GroupDMChannel, true, ['bulkDelete']);
|
||||
|
||||
module.exports = GroupDMChannel;
|
||||
@@ -5,7 +5,7 @@ const Integration = require('./Integration');
|
||||
const GuildAuditLogs = require('./GuildAuditLogs');
|
||||
const Webhook = require('./Webhook');
|
||||
const VoiceRegion = require('./VoiceRegion');
|
||||
const { ChannelTypes, DefaultMessageNotifications, browser } = require('../util/Constants');
|
||||
const { ChannelTypes, DefaultMessageNotifications, PartialTypes, browser } = require('../util/Constants');
|
||||
const Collection = require('../util/Collection');
|
||||
const Util = require('../util/Util');
|
||||
const DataResolver = require('../util/DataResolver');
|
||||
@@ -341,7 +341,9 @@ class Guild extends Base {
|
||||
* @readonly
|
||||
*/
|
||||
get owner() {
|
||||
return this.members.get(this.ownerID) || null;
|
||||
return this.members.get(this.ownerID) || (this.client.options.partials.includes(PartialTypes.GUILD_MEMBER) ?
|
||||
this.members.add({ user: { id: this.ownerID } }, true) :
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ const Collection = require('../util/Collection');
|
||||
const Snowflake = require('../util/Snowflake');
|
||||
const Webhook = require('./Webhook');
|
||||
const Util = require('../util/Util');
|
||||
const PartialTypes = require('../util/Constants');
|
||||
|
||||
/**
|
||||
* The target type of an entry, e.g. `GUILD`. Here are the available types:
|
||||
@@ -234,7 +235,7 @@ class GuildAuditLogs {
|
||||
* Audit logs entry.
|
||||
*/
|
||||
class GuildAuditLogsEntry {
|
||||
constructor(logs, guild, data) {
|
||||
constructor(logs, guild, data) { // eslint-disable-line complexity
|
||||
const targetType = GuildAuditLogs.targetType(data.action_type);
|
||||
/**
|
||||
* The target type of this entry
|
||||
@@ -264,7 +265,9 @@ class GuildAuditLogsEntry {
|
||||
* The user that executed this entry
|
||||
* @type {User}
|
||||
*/
|
||||
this.executor = guild.client.users.get(data.user_id);
|
||||
this.executor = guild.client.options.partials.includes(PartialTypes.USER) ?
|
||||
guild.client.users.add({ id: data.user_id }) :
|
||||
guild.client.users.get(data.user_id);
|
||||
|
||||
/**
|
||||
* An entry in the audit log representing a specific change.
|
||||
@@ -329,8 +332,12 @@ class GuildAuditLogsEntry {
|
||||
return o;
|
||||
}, {});
|
||||
this.target.id = data.target_id;
|
||||
} else if ([Targets.USER, Targets.GUILD].includes(targetType)) {
|
||||
this.target = guild.client[`${targetType.toLowerCase()}s`].get(data.target_id);
|
||||
} else if (targetType === Targets.USER) {
|
||||
this.target = guild.client.options.partials.includes(PartialTypes.USER) ?
|
||||
guild.client.users.add({ id: data.target_id }) :
|
||||
guild.client.users.get(data.target_id);
|
||||
} else if (targetType === Targets.GUILD) {
|
||||
this.target = guild.client.guilds.get(data.target_id);
|
||||
} else if (targetType === Targets.WEBHOOK) {
|
||||
this.target = logs.webhooks.get(data.target_id) ||
|
||||
new Webhook(guild.client,
|
||||
|
||||
@@ -28,7 +28,7 @@ class GuildMember extends Base {
|
||||
* The user that this guild member instance represents
|
||||
* @type {User}
|
||||
*/
|
||||
this.user = {};
|
||||
if (data.user) this.user = client.users.add(data.user, true);
|
||||
|
||||
/**
|
||||
* The timestamp the member joined the guild at
|
||||
@@ -79,6 +79,14 @@ class GuildMember extends Base {
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this GuildMember is a partial
|
||||
* @type {boolean}
|
||||
*/
|
||||
get partial() {
|
||||
return !this.joinedTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of roles that are applied to this member, mapped by the role ID
|
||||
* @type {GuildMemberRoleStore<Snowflake, Role>}
|
||||
@@ -355,6 +363,14 @@ class GuildMember extends Base {
|
||||
return this.guild.members.ban(this, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches this GuildMember.
|
||||
* @returns {Promise<GuildMember>}
|
||||
*/
|
||||
fetch() {
|
||||
return this.guild.members.fetch(this.id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* When concatenated with a string, this automatically returns the user's mention instead of the GuildMember object.
|
||||
* @returns {string}
|
||||
|
||||
@@ -24,7 +24,7 @@ class Message extends Base {
|
||||
|
||||
/**
|
||||
* The channel that the message was sent in
|
||||
* @type {TextChannel|DMChannel|GroupDMChannel}
|
||||
* @type {TextChannel|DMChannel}
|
||||
*/
|
||||
this.channel = channel;
|
||||
|
||||
@@ -60,7 +60,7 @@ class Message extends Base {
|
||||
* The author of the message
|
||||
* @type {User}
|
||||
*/
|
||||
this.author = this.client.users.add(data.author, !data.webhook_id);
|
||||
this.author = data.author ? this.client.users.add(data.author, !data.webhook_id) : null;
|
||||
|
||||
/**
|
||||
* Whether or not this message is pinned
|
||||
@@ -90,17 +90,19 @@ class Message extends Base {
|
||||
* A list of embeds in the message - e.g. YouTube Player
|
||||
* @type {MessageEmbed[]}
|
||||
*/
|
||||
this.embeds = data.embeds.map(e => new Embed(e));
|
||||
this.embeds = (data.embeds || []).map(e => new Embed(e));
|
||||
|
||||
/**
|
||||
* A collection of attachments in the message - e.g. Pictures - mapped by their ID
|
||||
* @type {Collection<Snowflake, MessageAttachment>}
|
||||
*/
|
||||
this.attachments = new Collection();
|
||||
for (const attachment of data.attachments) {
|
||||
this.attachments.set(attachment.id, new MessageAttachment(
|
||||
attachment.url, attachment.filename, attachment
|
||||
));
|
||||
if (data.attachments) {
|
||||
for (const attachment of data.attachments) {
|
||||
this.attachments.set(attachment.id, new MessageAttachment(
|
||||
attachment.url, attachment.filename, attachment
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +169,14 @@ class Message extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not this message is a partial
|
||||
* @type {boolean}
|
||||
*/
|
||||
get partial() {
|
||||
return typeof this.content !== 'string' || !this.author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the message.
|
||||
* @param {Object} data Raw Discord message update data
|
||||
@@ -472,6 +482,14 @@ class Message extends Base {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch this message.
|
||||
* @returns {Promise<Message>}
|
||||
*/
|
||||
fetch() {
|
||||
return this.channel.messages.fetch(this.id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the webhook used to create this message.
|
||||
* @returns {Promise<?Webhook>}
|
||||
|
||||
@@ -15,7 +15,7 @@ const { Events } = require('../util/Constants');
|
||||
*/
|
||||
class MessageCollector extends Collector {
|
||||
/**
|
||||
* @param {TextChannel|DMChannel|GroupDMChannel} channel The channel
|
||||
* @param {TextChannel|DMChannel} channel The channel
|
||||
* @param {CollectorFilter} filter The filter to be applied to this collector
|
||||
* @param {MessageCollectorOptions} options The options to be applied to this collector
|
||||
* @emits MessageCollector#message
|
||||
|
||||
@@ -53,6 +53,8 @@ class User extends Base {
|
||||
*/
|
||||
if (typeof data.avatar !== 'undefined') this.avatar = data.avatar;
|
||||
|
||||
if (typeof data.bot !== 'undefined') this.bot = Boolean(data.bot);
|
||||
|
||||
/**
|
||||
* The locale of the user's client (ISO 639-1)
|
||||
* @type {?string}
|
||||
@@ -73,6 +75,14 @@ class User extends Base {
|
||||
this.lastMessageChannelID = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this User is a partial
|
||||
* @type {boolean}
|
||||
*/
|
||||
get partial() {
|
||||
return typeof this.username !== 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* The timestamp the user was created at
|
||||
* @type {number}
|
||||
@@ -228,6 +238,14 @@ class User extends Base {
|
||||
return equal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches this user.
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
fetch() {
|
||||
return this.client.users.fetch(this.id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* When concatenated with a string, this automatically returns the user's mention instead of the User object.
|
||||
* @returns {string}
|
||||
|
||||
Reference in New Issue
Block a user