upd
- added all non-jumping calls (cef <=> client, client <=> server) - added @ragempcommunity types (todo)
This commit is contained in:
parent
092693acb5
commit
f8dd4d9fce
1
.gitignore
vendored
1
.gitignore
vendored
@ -18,3 +18,4 @@ server/ragemp-server.exe
|
|||||||
# Development
|
# Development
|
||||||
node_modules
|
node_modules
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
dist
|
@ -1,20 +1,18 @@
|
|||||||
import { fw } from 'rage-fw-cef'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { rpc } from 'rpc'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [data, setData] = useState('')
|
const [data, setData] = useState<unknown>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fw.event.register('customCefEvent', async ([test]) => {
|
rpc.callClient('cefReady', [])
|
||||||
setData(p => p + ' ' + test)
|
rpc.register('customCefEvent', args => setData(args))
|
||||||
return 'from cef'
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', color: 'white', textAlign: 'center' }}>
|
<div style={{ width: '100%', color: 'white', textAlign: 'center' }}>
|
||||||
<h1>Hello World!</h1>
|
<h1>Hello World!</h1>
|
||||||
<h2>{data}</h2>
|
<h2>{data!.toString()}</h2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { fw } from 'rage-fw-client'
|
import { rpc, client } from 'rpc'
|
||||||
|
|
||||||
fw.player.browser = mp.browsers.new('package://cef/index.html')
|
client.browser = mp.browsers.new('package://cef/index.html')
|
||||||
|
|
||||||
fw.event.register('cefReady', async () => {
|
rpc.register('cefReady', async () => {
|
||||||
fw.system.log.info('cefReady')
|
mp.console.logInfo('cef to client')
|
||||||
|
|
||||||
const responseCef = await fw.player.triggerBrowser('customCefEvent', [
|
rpc.callServer('customServerEvent', ['client to server'])
|
||||||
'from client',
|
})
|
||||||
])
|
|
||||||
fw.system.log.info(responseCef)
|
rpc.register('customClientEvent', async data => {
|
||||||
|
mp.console.logInfo(JSON.stringify(data))
|
||||||
await fw.player.triggerServer('customServerEvent', ['from client'])
|
|
||||||
|
rpc.callBrowser('customCefEvent', ['client to cef'])
|
||||||
})
|
})
|
||||||
|
15
apps/rpc/index.d.ts
vendored
15
apps/rpc/index.d.ts
vendored
@ -1,9 +1,8 @@
|
|||||||
declare const mp: any
|
import '@ragempcommunity/types-cef'
|
||||||
|
import '@ragempcommunity/types-client'
|
||||||
|
import '@ragempcommunity/types-server'
|
||||||
|
|
||||||
declare const global: {
|
declare const mp: Mp
|
||||||
rpcEvents: Record<string, (...args: any[]) => unknown>
|
declare const global: Record<string, (...args: any[]) => unknown>
|
||||||
}
|
declare const window: Record<string, (...args: any[]) => unknown>
|
||||||
|
declare const console: { log: (...args: any[]) => void }
|
||||||
declare const window: {
|
|
||||||
rpcEvents: Record<string, (...args: any[]) => unknown>
|
|
||||||
}
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rpc",
|
"name": "rpc",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"main": "dist/index.js",
|
"main": "src/index.ts",
|
||||||
"types": "dist/src/index.d.ts",
|
"types": "dist/src/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
@ -11,7 +11,12 @@
|
|||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.3.2"
|
"@microsoft/api-extractor": "^7.47.9",
|
||||||
|
"@ragempcommunity/types-client": "^2.1.8",
|
||||||
|
"@ragempcommunity/types-server": "^2.1.8",
|
||||||
|
"@ragempcommunity/types-cef": "^2.1.8",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
|
"tsup": "^8.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
@ -19,10 +24,12 @@
|
|||||||
"description": "RageFW RPC",
|
"description": "RageFW RPC",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "SashaGoncharov19",
|
"author": "SashaGoncharov19",
|
||||||
"contributors": [{
|
"contributors": [
|
||||||
|
{
|
||||||
"name": "rilaxik",
|
"name": "rilaxik",
|
||||||
"email": "dev.rilaxik@gmail.com",
|
"email": "dev.rilaxik@gmail.com",
|
||||||
"url": "https://github.com/rilaxik"
|
"url": "https://github.com/rilaxik"
|
||||||
}],
|
}
|
||||||
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ class Browser extends Wrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitClient(dataRaw: string) {
|
private emitClient(dataRaw: string) {
|
||||||
mp.trigger(Events.EVENT_LISTENER, dataRaw)
|
mp.trigger(Events.LOCAL_EVENT_LISTENER, dataRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(dataRaw: string) {
|
private emit(dataRaw: string) {
|
||||||
|
@ -44,11 +44,13 @@ class Client extends Wrapper {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state_[state.eventName](...state.data)
|
this.state_[state.eventName](
|
||||||
|
...(Array.isArray(state.data) ? state.data : []),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitServer(dataRaw: string) {
|
private emitServer(dataRaw: string) {
|
||||||
mp.events.callRemote(Events.EVENT_LISTENER, dataRaw)
|
mp.events.callRemote(Events.SERVER_EVENT_LISTENER, dataRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitBrowser(dataRaw: string, state: RPCState) {
|
private emitBrowser(dataRaw: string, state: RPCState) {
|
||||||
@ -57,7 +59,7 @@ class Client extends Wrapper {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this._browser.call(Events.EVENT_LISTENER, dataRaw)
|
this._browser.call(Events.LOCAL_EVENT_LISTENER, dataRaw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
import { Wrapper } from './wrapper'
|
import { Wrapper } from './wrapper'
|
||||||
|
import type { Player, PlayerServer } from './utils'
|
||||||
import { Environment, Errors, Events, RPCState, Utils } from './utils'
|
import { Environment, Errors, Events, RPCState, Utils } from './utils'
|
||||||
|
|
||||||
import { server } from './server'
|
import { server } from './server'
|
||||||
import { client } from './client'
|
import { client } from './client'
|
||||||
import { browser } from './browser'
|
import { browser } from './browser'
|
||||||
|
|
||||||
|
export { server } from './server'
|
||||||
|
export { client } from './client'
|
||||||
|
export { browser } from './browser'
|
||||||
|
|
||||||
class Rpc extends Wrapper {
|
class Rpc extends Wrapper {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@ -12,22 +18,20 @@ class Rpc extends Wrapper {
|
|||||||
throw new Error(Errors.UNKNOWN_ENVIRONMENT)
|
throw new Error(Errors.UNKNOWN_ENVIRONMENT)
|
||||||
|
|
||||||
mp.events.add(
|
mp.events.add(
|
||||||
Events.EVENT_LISTENER,
|
Events.LOCAL_EVENT_LISTENER,
|
||||||
async (player: any, dataRaw: string) => {
|
async (player: Player, dataRaw: string) => {
|
||||||
if (!dataRaw) throw new Error(Errors.NO_DATA)
|
|
||||||
|
|
||||||
switch (this.environment_) {
|
switch (this.environment_) {
|
||||||
case Environment.SERVER:
|
case Environment.SERVER:
|
||||||
server.resolveEmitDestination(player, dataRaw)
|
server.resolveEmitDestination(player, dataRaw)
|
||||||
break
|
break
|
||||||
|
|
||||||
case Environment.CLIENT:
|
case Environment.CLIENT:
|
||||||
dataRaw = player
|
dataRaw = player as string
|
||||||
client.resolveEmitDestination(dataRaw)
|
client.resolveEmitDestination(dataRaw)
|
||||||
break
|
break
|
||||||
|
|
||||||
case Environment.BROWSER:
|
case Environment.BROWSER:
|
||||||
dataRaw = player
|
dataRaw = player as string
|
||||||
browser.resolveEmitDestination(dataRaw)
|
browser.resolveEmitDestination(dataRaw)
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -48,29 +52,61 @@ class Rpc extends Wrapper {
|
|||||||
this.state_[eventName] = cb
|
this.state_[eventName] = cb
|
||||||
}
|
}
|
||||||
|
|
||||||
public unregister(
|
public unregister(eventName: string): void {
|
||||||
eventName: string
|
|
||||||
): void {
|
|
||||||
Utils.errorUnknownEnvironment(this.environment_)
|
Utils.errorUnknownEnvironment(this.environment_)
|
||||||
|
|
||||||
delete this.state_[eventName]
|
delete this.state_[eventName]
|
||||||
}
|
}
|
||||||
|
|
||||||
public callClient(eventName: string, args: unknown[]) {
|
public callClient(
|
||||||
|
playerOrEventName: Player,
|
||||||
|
eventNameOrArgs: string | unknown[],
|
||||||
|
args?: unknown[],
|
||||||
|
) {
|
||||||
Utils.errorUnknownEnvironment(this.environment_)
|
Utils.errorUnknownEnvironment(this.environment_)
|
||||||
|
|
||||||
|
// client
|
||||||
|
if (this.environment_ === Environment.CLIENT) {
|
||||||
|
this.call(playerOrEventName as string, args as unknown[])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// server
|
||||||
|
if (this.environment_ === Environment.SERVER) {
|
||||||
const state: RPCState = {
|
const state: RPCState = {
|
||||||
uuid: Utils.generateUUID(),
|
uuid: Utils.generateUUID(),
|
||||||
eventName,
|
eventName: eventNameOrArgs as string,
|
||||||
calledTo: Environment.CLIENT,
|
calledTo: Environment.CLIENT,
|
||||||
calledFrom: this.environment_,
|
calledFrom: this.environment_,
|
||||||
knownError: undefined,
|
knownError: undefined,
|
||||||
data: args,
|
data: args as unknown[],
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataRaw = Utils.prepareTransfer(state)
|
const dataRaw = Utils.prepareTransfer(state)
|
||||||
|
|
||||||
mp.events.call(Events.EVENT_LISTENER, dataRaw)
|
;(playerOrEventName as PlayerServer).call(
|
||||||
|
Events.LOCAL_EVENT_LISTENER,
|
||||||
|
[dataRaw],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// browser
|
||||||
|
if (this.environment_ === Environment.BROWSER) {
|
||||||
|
const state: RPCState = {
|
||||||
|
uuid: Utils.generateUUID(),
|
||||||
|
eventName: playerOrEventName as string,
|
||||||
|
calledTo: Environment.CLIENT,
|
||||||
|
calledFrom: this.environment_,
|
||||||
|
knownError: undefined,
|
||||||
|
data: eventNameOrArgs as unknown[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRaw = Utils.prepareTransfer(state)
|
||||||
|
|
||||||
|
mp.trigger(Events.LOCAL_EVENT_LISTENER, dataRaw)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public callServer(eventName: string, args: unknown[]) {
|
public callServer(eventName: string, args: unknown[]) {
|
||||||
@ -85,9 +121,13 @@ class Rpc extends Wrapper {
|
|||||||
data: args,
|
data: args,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.calledFrom === Environment.SERVER) {
|
||||||
|
this.callSelf(state)
|
||||||
|
} else {
|
||||||
const dataRaw = Utils.prepareTransfer(state)
|
const dataRaw = Utils.prepareTransfer(state)
|
||||||
|
|
||||||
mp.events.call(Events.EVENT_LISTENER, dataRaw)
|
mp.events.call(Events.LOCAL_EVENT_LISTENER, dataRaw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public callBrowser(eventName: string, args: unknown[]) {
|
public callBrowser(eventName: string, args: unknown[]) {
|
||||||
@ -102,9 +142,13 @@ class Rpc extends Wrapper {
|
|||||||
data: args,
|
data: args,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.calledFrom === Environment.BROWSER) {
|
||||||
|
this.callSelf(state)
|
||||||
|
} else {
|
||||||
const dataRaw = Utils.prepareTransfer(state)
|
const dataRaw = Utils.prepareTransfer(state)
|
||||||
|
|
||||||
mp.events.call(Events.EVENT_LISTENER, dataRaw)
|
mp.events.call(Events.LOCAL_EVENT_LISTENER, dataRaw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public call(eventName: string, args: unknown[]) {
|
public call(eventName: string, args: unknown[]) {
|
||||||
@ -119,11 +163,17 @@ class Rpc extends Wrapper {
|
|||||||
data: args,
|
data: args,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.callSelf(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private callSelf(state: RPCState) {
|
||||||
state = this.verifyEvent_(state)
|
state = this.verifyEvent_(state)
|
||||||
if (state.knownError) {
|
if (state.knownError) {
|
||||||
this.triggerError_(state, state.knownError)
|
this.triggerError_(state, state.knownError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.state_[state.eventName](...state.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
import { Wrapper } from './wrapper'
|
import { Wrapper } from './wrapper'
|
||||||
import { Environment, Errors, Events, Utils } from './utils'
|
import type { Player, PlayerServer } from './utils'
|
||||||
|
import { Environment, Events, Utils } from './utils'
|
||||||
|
|
||||||
class Server extends Wrapper {
|
class Server extends Wrapper {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
|
mp.events.add(
|
||||||
|
Events.SERVER_EVENT_LISTENER,
|
||||||
|
async (player: PlayerServer, dataRaw: string) => {
|
||||||
|
this.emit(player, dataRaw)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveEmitDestination(player: any, dataRaw: string) {
|
public resolveEmitDestination(player: Player, dataRaw: string) {
|
||||||
if (!dataRaw) throw new Error(Errors.NO_DATA)
|
|
||||||
|
|
||||||
let state = Utils.prepareExecution(dataRaw)
|
let state = Utils.prepareExecution(dataRaw)
|
||||||
|
|
||||||
switch (state.calledTo) {
|
switch (state.calledTo) {
|
||||||
@ -17,16 +23,16 @@ class Server extends Wrapper {
|
|||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.emitClient(player, dataRaw)
|
this.emitClient(player as PlayerServer, dataRaw)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitClient(player: any, dataRaw: string) {
|
private emitClient(player: PlayerServer, dataRaw: string) {
|
||||||
player.call(Events.EVENT_LISTENER, dataRaw)
|
player.call(Events.LOCAL_EVENT_LISTENER, [dataRaw])
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(player: any, dataRaw: string) {
|
private emit(player: Player, dataRaw: string) {
|
||||||
let state = Utils.prepareExecution(dataRaw)
|
let state = Utils.prepareExecution(dataRaw)
|
||||||
|
|
||||||
state = this.verifyEvent_(state)
|
state = this.verifyEvent_(state)
|
||||||
@ -36,7 +42,7 @@ class Server extends Wrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { eventName, data } = Utils.prepareExecution(dataRaw)
|
const { eventName, data } = Utils.prepareExecution(dataRaw)
|
||||||
this.state_[eventName](...data)
|
this.state_[eventName](player, ...data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,9 @@ export enum Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum Events {
|
export enum Events {
|
||||||
EVENT_LISTENER = '__rpc:listener',
|
LOCAL_EVENT_LISTENER = '__rpc:listener',
|
||||||
|
CLIENT_EVENT_LISTENER = '__rpc:clientListener',
|
||||||
|
SERVER_EVENT_LISTENER = '__rpc:serverListener',
|
||||||
EVENT_RESPONSE = '__rpc:response',
|
EVENT_RESPONSE = '__rpc:response',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,15 +19,6 @@ export enum Errors {
|
|||||||
NO_BROWSER = 'You need to initialize browser first',
|
NO_BROWSER = 'You need to initialize browser first',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RPCState = {
|
|
||||||
eventName: string
|
|
||||||
uuid: string
|
|
||||||
knownError?: string
|
|
||||||
data?: any
|
|
||||||
calledFrom: Environment
|
|
||||||
calledTo: Environment
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Utils {
|
export class Utils {
|
||||||
public static getEnvironment(): Environment {
|
public static getEnvironment(): Environment {
|
||||||
if ('joaat' in mp) return Environment.SERVER
|
if ('joaat' in mp) return Environment.SERVER
|
||||||
@ -71,3 +64,17 @@ export class Utils {
|
|||||||
throw new Error(Errors.UNKNOWN_ENVIRONMENT)
|
throw new Error(Errors.UNKNOWN_ENVIRONMENT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RPCState = {
|
||||||
|
eventName: string
|
||||||
|
uuid: string
|
||||||
|
knownError?: string
|
||||||
|
data?: any
|
||||||
|
calledFrom: Environment
|
||||||
|
calledTo: Environment
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {}
|
||||||
|
export interface PlayerServer extends Player {
|
||||||
|
call(eventName: string, args: unknown[]): void
|
||||||
|
}
|
||||||
|
@ -3,15 +3,7 @@ import { Environment, Errors, RPCState, Utils } from './utils'
|
|||||||
export class Wrapper {
|
export class Wrapper {
|
||||||
protected environment_ = Utils.getEnvironment()
|
protected environment_ = Utils.getEnvironment()
|
||||||
protected state_ =
|
protected state_ =
|
||||||
this.environment_ === Environment.BROWSER
|
this.environment_ === Environment.BROWSER ? window : global
|
||||||
? (window.rpcEvents = {} as Record<
|
|
||||||
string,
|
|
||||||
(...args: any[]) => unknown
|
|
||||||
>)
|
|
||||||
: (global.rpcEvents = {} as Record<
|
|
||||||
string,
|
|
||||||
(...args: any[]) => unknown
|
|
||||||
>)
|
|
||||||
|
|
||||||
protected verifyEvent_(data: string | RPCState): RPCState {
|
protected verifyEvent_(data: string | RPCState): RPCState {
|
||||||
let rpcData =
|
let rpcData =
|
||||||
|
@ -1,18 +1,11 @@
|
|||||||
import { fw } from 'rage-fw-server'
|
import { rpc } from 'rpc'
|
||||||
|
|
||||||
fw.event.register('playerJoin', async ([player]) => {
|
rpc.register('playerJoin', async player => {
|
||||||
fw.system.log.info(`Connected: ${player.socialClub}`)
|
console.log(`Connected: ${player.socialClub}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
fw.event.register('customServerEvent', async ([player, msg]) => {
|
rpc.register('customServerEvent', (player, data) => {
|
||||||
fw.system.log.info(player.socialClub + ' ' + msg)
|
console.log(player, data)
|
||||||
|
|
||||||
const resFromCef = await fw.player.triggerBrowser(
|
rpc.callClient(player, 'customClientEvent', ['server to client'])
|
||||||
player,
|
|
||||||
'customCefEvent',
|
|
||||||
['from server'],
|
|
||||||
)
|
|
||||||
fw.system.log.info(player.socialClub + ' ' + resFromCef)
|
|
||||||
|
|
||||||
return 'from server'
|
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "ESNext"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"types": [
|
"types": [
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
declare module 'rage-fw-shared-types' {
|
declare module 'rage-fw-shared-types' {
|
||||||
export interface RageFW_ICustomClientEvent {}
|
export interface RageFW_ICustomClientEvent {
|
||||||
|
customClientEvent(greetings: string): void
|
||||||
|
}
|
||||||
|
|
||||||
export interface RageFW_ICustomServerEvent {
|
export interface RageFW_ICustomServerEvent {
|
||||||
customServerEvent(greetings: string): string
|
customServerEvent(greetings: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RageFW_ICustomCefEvent {
|
export interface RageFW_ICustomCefEvent {
|
||||||
customCefEvent(greetings: string): string
|
customCefEvent(greetings: string): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user