wip
This commit is contained in:
@@ -1892,6 +1892,7 @@ _role:
|
|||||||
canImportFollowing: "フォローのインポートを許可"
|
canImportFollowing: "フォローのインポートを許可"
|
||||||
canImportMuting: "ミュートのインポートを許可"
|
canImportMuting: "ミュートのインポートを許可"
|
||||||
canImportUserLists: "リストのインポートを許可"
|
canImportUserLists: "リストのインポートを許可"
|
||||||
|
canChat: "チャットを許可"
|
||||||
_condition:
|
_condition:
|
||||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||||
isLocal: "ローカルユーザー"
|
isLocal: "ローカルユーザー"
|
||||||
|
@@ -16,9 +16,11 @@ import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityServi
|
|||||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { ChatMessagesRepository, MiChatMessage, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
|
import type { ChatMessagesRepository, MiChatMessage, MiChatRoom, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
|
||||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
@@ -47,36 +49,46 @@ export class ChatService {
|
|||||||
private pushNotificationService: PushNotificationService,
|
private pushNotificationService: PushNotificationService,
|
||||||
private userBlockingService: UserBlockingService,
|
private userBlockingService: UserBlockingService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
private roleService: RoleService,
|
||||||
|
private userFollowingService: UserFollowingService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createMessage(params: {
|
public async createMessage(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
|
||||||
fromUser: { id: MiUser['id']; host: MiUser['host']; };
|
|
||||||
toUser?: MiUser | null;
|
|
||||||
//toRoom?: MiUserRoom | null;
|
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
file?: MiDriveFile | null;
|
file?: MiDriveFile | null;
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { fromUser, toUser /*toRoom*/ } = params;
|
if (fromUser.id === toUser.id) {
|
||||||
|
throw new Error('yourself');
|
||||||
if (toUser == null /*&& toRoom == null*/) {
|
|
||||||
throw new Error('recipient is required');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toUser) {
|
if (toUser.chatScope === 'none') {
|
||||||
const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id);
|
throw new Error('recipient is cannot chat');
|
||||||
if (blocked) {
|
} else if (toUser.chatScope === 'followers') {
|
||||||
throw new Error('blocked');
|
|
||||||
|
} else if (toUser.chatScope === 'following') {
|
||||||
|
} else if (toUser.chatScope === 'mutual') {
|
||||||
|
const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
|
||||||
|
if (!isMutual) {
|
||||||
|
throw new Error('recipient is cannot chat');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((await this.roleService.getUserPolicies(toUser.id)).canChat) {
|
||||||
|
throw new Error('recipient is cannot chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id);
|
||||||
|
if (blocked) {
|
||||||
|
throw new Error('blocked');
|
||||||
|
}
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
id: this.idService.gen(),
|
id: this.idService.gen(),
|
||||||
fromUserId: fromUser.id,
|
fromUserId: fromUser.id,
|
||||||
toUserId: toUser ? toUser.id : null,
|
toUserId: toUser.id,
|
||||||
//toRoomId: recipientRoom ? recipientRoom.id : null,
|
|
||||||
text: params.text ? params.text.trim() : null,
|
text: params.text ? params.text.trim() : null,
|
||||||
fileId: params.file ? params.file.id : null,
|
fileId: params.file ? params.file.id : null,
|
||||||
reads: [],
|
reads: [],
|
||||||
@@ -87,36 +99,26 @@ export class ChatService {
|
|||||||
|
|
||||||
const packedMessage = await this.chatMessageEntityService.packLite(inserted);
|
const packedMessage = await this.chatMessageEntityService.packLite(inserted);
|
||||||
|
|
||||||
if (toUser) {
|
if (this.userEntityService.isLocalUser(toUser)) {
|
||||||
const redisPipeline = this.redisClient.pipeline();
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
redisPipeline.set(`newChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
|
redisPipeline.set(`newChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
|
||||||
redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
|
redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
|
||||||
redisPipeline.exec();
|
redisPipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.userEntityService.isLocalUser(fromUser)) {
|
if (this.userEntityService.isLocalUser(fromUser)) {
|
||||||
// 自分のストリーム
|
// 自分のストリーム
|
||||||
this.globalEventService.publishChatStream(fromUser.id, toUser.id, 'message', packedMessage);
|
this.globalEventService.publishChatStream(fromUser.id, toUser.id, 'message', packedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.userEntityService.isLocalUser(toUser)) {
|
if (this.userEntityService.isLocalUser(toUser)) {
|
||||||
// 相手のストリーム
|
// 相手のストリーム
|
||||||
this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage);
|
this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage);
|
||||||
}
|
}
|
||||||
}/* else if (toRoom) {
|
|
||||||
// グループのストリーム
|
|
||||||
this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj);
|
|
||||||
|
|
||||||
// メンバーのストリーム
|
|
||||||
const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id });
|
|
||||||
for (const joining of joinings) {
|
|
||||||
this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj);
|
|
||||||
this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj);
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
// 3秒経っても既読にならなかったらイベント発行
|
// 3秒経っても既読にならなかったらイベント発行
|
||||||
setTimeout(async () => {
|
if (this.userEntityService.isLocalUser(toUser)) {
|
||||||
if (toUser && this.userEntityService.isLocalUser(toUser)) {
|
setTimeout(async () => {
|
||||||
const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`);
|
const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`);
|
||||||
|
|
||||||
if (marker == null) return; // 既読
|
if (marker == null) return; // 既読
|
||||||
@@ -124,15 +126,8 @@ export class ChatService {
|
|||||||
const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser);
|
const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser);
|
||||||
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
|
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
|
||||||
this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
|
this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
|
||||||
}/* else if (toRoom) {
|
}, 3000);
|
||||||
const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id, userId: Not(fromUser.id) });
|
}
|
||||||
for (const joining of joinings) {
|
|
||||||
if (freshMessage.reads.includes(joining.userId)) return; // 既読
|
|
||||||
this.globalEventService.publishMainStream(joining.userId, 'newChatMessage', messageObj);
|
|
||||||
this.pushNotificationService.pushNotification(joining.userId, 'newChatMessage', messageObj);
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
/* TODO: AP
|
/* TODO: AP
|
||||||
if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
|
if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
|
||||||
@@ -160,6 +155,53 @@ export class ChatService {
|
|||||||
return packedMessage;
|
return packedMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: {
|
||||||
|
text?: string | null;
|
||||||
|
file?: MiDriveFile | null;
|
||||||
|
uri?: string | null;
|
||||||
|
}) {
|
||||||
|
const message = {
|
||||||
|
id: this.idService.gen(),
|
||||||
|
fromUserId: fromUser.id,
|
||||||
|
toRoomId: toRoom.id,
|
||||||
|
text: params.text ? params.text.trim() : null,
|
||||||
|
fileId: params.file ? params.file.id : null,
|
||||||
|
reads: [],
|
||||||
|
uri: params.uri ?? null,
|
||||||
|
} satisfies Partial<MiChatMessage>;
|
||||||
|
|
||||||
|
const inserted = await this.chatMessagesRepository.insertOne(message);
|
||||||
|
|
||||||
|
const packedMessage = await this.chatMessageEntityService.packLite(inserted);
|
||||||
|
|
||||||
|
/*
|
||||||
|
// グループのストリーム
|
||||||
|
this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj);
|
||||||
|
|
||||||
|
// メンバーのストリーム
|
||||||
|
const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id });
|
||||||
|
for (const joining of joinings) {
|
||||||
|
this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj);
|
||||||
|
this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 3秒経っても既読にならなかったらイベント発行
|
||||||
|
setTimeout(async () => {
|
||||||
|
/*
|
||||||
|
const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id, userId: Not(fromUser.id) });
|
||||||
|
for (const joining of joinings) {
|
||||||
|
if (freshMessage.reads.includes(joining.userId)) return; // 既読
|
||||||
|
this.globalEventService.publishMainStream(joining.userId, 'newChatMessage', messageObj);
|
||||||
|
this.pushNotificationService.pushNotification(joining.userId, 'newChatMessage', messageObj);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return packedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async readUserChatMessage(
|
public async readUserChatMessage(
|
||||||
readerId: MiUser['id'],
|
readerId: MiUser['id'],
|
||||||
|
@@ -63,6 +63,7 @@ export type RolePolicies = {
|
|||||||
canImportFollowing: boolean;
|
canImportFollowing: boolean;
|
||||||
canImportMuting: boolean;
|
canImportMuting: boolean;
|
||||||
canImportUserLists: boolean;
|
canImportUserLists: boolean;
|
||||||
|
canChat: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_POLICIES: RolePolicies = {
|
export const DEFAULT_POLICIES: RolePolicies = {
|
||||||
@@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||||||
canImportFollowing: true,
|
canImportFollowing: true,
|
||||||
canImportMuting: true,
|
canImportMuting: true,
|
||||||
canImportUserLists: true,
|
canImportUserLists: true,
|
||||||
|
canChat: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||||||
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
|
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
|
||||||
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
|
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
|
||||||
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
|
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
|
||||||
|
canChat: calc('canChat', vs => vs.some(v => v === true)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { IsNull } from 'typeorm';
|
import { Brackets, IsNull } from 'typeorm';
|
||||||
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
@@ -736,4 +736,20 @@ export class UserFollowingService implements OnModuleInit {
|
|||||||
.where('following.followerId = :followerId', { followerId: userId })
|
.where('following.followerId = :followerId', { followerId: userId })
|
||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
|
||||||
|
const count = await this.followingsRepository.createQueryBuilder('following')
|
||||||
|
.where(new Brackets(qb => {
|
||||||
|
qb.where('following.followerId = :aUserId', { aUserId })
|
||||||
|
.andWhere('following.followeeId = :bUserId', { bUserId });
|
||||||
|
}))
|
||||||
|
.orWhere(new Brackets(qb => {
|
||||||
|
qb.where('following.followerId = :bUserId', { bUserId })
|
||||||
|
.andWhere('following.followeeId = :aUserId', { aUserId });
|
||||||
|
}))
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
return count === 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -225,6 +225,17 @@ export class MiUser {
|
|||||||
})
|
})
|
||||||
public emojis: string[];
|
public emojis: string[];
|
||||||
|
|
||||||
|
// チャットを許可する相手
|
||||||
|
// everyone: 誰からでも
|
||||||
|
// followers: フォロワーのみ
|
||||||
|
// following: フォローしているユーザーのみ
|
||||||
|
// mutual: 相互フォローのみ
|
||||||
|
// none: 誰からも受け付けない
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 128, default: 'mutual',
|
||||||
|
})
|
||||||
|
public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 128, nullable: true,
|
length: 128, nullable: true,
|
||||||
|
@@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
canChat: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@@ -16,6 +16,7 @@ export const meta = {
|
|||||||
tags: ['chat'],
|
tags: ['chat'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
requiredRolePolicy: 'canChat',
|
||||||
|
|
||||||
prohibitMoved: true,
|
prohibitMoved: true,
|
||||||
|
|
||||||
|
@@ -107,6 +107,7 @@ export const ROLE_POLICIES = [
|
|||||||
'canImportFollowing',
|
'canImportFollowing',
|
||||||
'canImportMuting',
|
'canImportMuting',
|
||||||
'canImportUserLists',
|
'canImportUserLists',
|
||||||
|
'canChat',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// なんか動かない
|
// なんか動かない
|
||||||
|
@@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canChat }}</template>
|
||||||
|
<template #suffix>
|
||||||
|
<span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||||
|
<span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||||
|
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span>
|
||||||
|
</template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||||
|
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||||
|
</MkRange>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
|
@@ -51,6 +51,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canChat }}</template>
|
||||||
|
<template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||||
|
<MkSwitch v-model="policies.canChat">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||||
<template #suffix>{{ policies.mentionLimit }}</template>
|
<template #suffix>{{ policies.mentionLimit }}</template>
|
||||||
|
@@ -4974,6 +4974,7 @@ export type components = {
|
|||||||
canImportFollowing: boolean;
|
canImportFollowing: boolean;
|
||||||
canImportMuting: boolean;
|
canImportMuting: boolean;
|
||||||
canImportUserLists: boolean;
|
canImportUserLists: boolean;
|
||||||
|
canChat: boolean;
|
||||||
};
|
};
|
||||||
ReversiGameLite: {
|
ReversiGameLite: {
|
||||||
/** Format: id */
|
/** Format: id */
|
||||||
@@ -13800,7 +13801,7 @@ export type operations = {
|
|||||||
/** Format: misskey:id */
|
/** Format: misskey:id */
|
||||||
fileId?: string;
|
fileId?: string;
|
||||||
/** Format: misskey:id */
|
/** Format: misskey:id */
|
||||||
userId?: string | null;
|
toUserId?: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user