mirror of
https://github.com/liriliri/eruda.git
synced 2026-03-20 09:38:37 +08:00
refactor(network): use chobitsu
This commit is contained in:
@@ -99,12 +99,6 @@ elements.set(document.body);
|
|||||||
|
|
||||||
Display requests.
|
Display requests.
|
||||||
|
|
||||||
### Config
|
|
||||||
|
|
||||||
|Name |Type |Desc |
|
|
||||||
|-------------|-------|--------------------|
|
|
||||||
|overrideFetch|boolean|Catch Fetch Requests|
|
|
||||||
|
|
||||||
### clear
|
### clear
|
||||||
|
|
||||||
Clear requests.
|
Clear requests.
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { getType, lenToUtf8Bytes } from './util'
|
|
||||||
import {
|
|
||||||
Emitter,
|
|
||||||
fullUrl,
|
|
||||||
uniqId,
|
|
||||||
isStr,
|
|
||||||
getFileName,
|
|
||||||
now,
|
|
||||||
toNum,
|
|
||||||
fileSize,
|
|
||||||
isEmpty
|
|
||||||
} from '../lib/util'
|
|
||||||
|
|
||||||
export default class FetchRequest extends Emitter {
|
|
||||||
constructor(url, options = {}) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
if (url instanceof window.Request) url = url.url
|
|
||||||
|
|
||||||
this._url = fullUrl(url)
|
|
||||||
this._id = uniqId('request')
|
|
||||||
this._options = options
|
|
||||||
this._reqHeaders = options.headers || {}
|
|
||||||
this._method = options.method || 'GET'
|
|
||||||
}
|
|
||||||
send(fetchResult) {
|
|
||||||
const options = this._options
|
|
||||||
|
|
||||||
const data = isStr(options.body) ? options.body : ''
|
|
||||||
|
|
||||||
this._fetch = fetchResult
|
|
||||||
this.emit('send', this._id, {
|
|
||||||
name: getFileName(this._url),
|
|
||||||
url: this._url,
|
|
||||||
data,
|
|
||||||
method: this._method
|
|
||||||
})
|
|
||||||
|
|
||||||
fetchResult.then(res => {
|
|
||||||
res = res.clone()
|
|
||||||
|
|
||||||
const type = getType(res.headers.get('Content-Type'))
|
|
||||||
res.text().then(resTxt => {
|
|
||||||
const data = {
|
|
||||||
type: type.type,
|
|
||||||
subType: type.subType,
|
|
||||||
time: now(),
|
|
||||||
size: getSize(res, resTxt),
|
|
||||||
resTxt: resTxt,
|
|
||||||
resHeaders: getHeaders(res),
|
|
||||||
status: res.status,
|
|
||||||
done: true
|
|
||||||
}
|
|
||||||
if (!isEmpty(this._reqHeaders)) {
|
|
||||||
data.reqHeaders = this._reqHeaders
|
|
||||||
}
|
|
||||||
this.emit('update', this._id, data)
|
|
||||||
})
|
|
||||||
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSize(res, resTxt) {
|
|
||||||
let size = 0
|
|
||||||
|
|
||||||
const contentLen = res.headers.get('Content-length')
|
|
||||||
|
|
||||||
if (contentLen) {
|
|
||||||
size = toNum(contentLen)
|
|
||||||
} else {
|
|
||||||
size = lenToUtf8Bytes(resTxt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${fileSize(size)}B`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHeaders(res) {
|
|
||||||
const ret = {}
|
|
||||||
|
|
||||||
res.headers.forEach((val, key) => (ret[key] = val))
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,9 @@
|
|||||||
import Tool from '../DevTools/Tool'
|
import Tool from '../DevTools/Tool'
|
||||||
import XhrRequest from './XhrRequest'
|
import { getFileName, isEmpty, $, ms, trim, each, last } from '../lib/util'
|
||||||
import FetchRequest from './FetchRequest'
|
|
||||||
import Settings from '../Settings/Settings'
|
|
||||||
import {
|
|
||||||
isNative,
|
|
||||||
defaults,
|
|
||||||
now,
|
|
||||||
extend,
|
|
||||||
isEmpty,
|
|
||||||
$,
|
|
||||||
ms,
|
|
||||||
trim,
|
|
||||||
each
|
|
||||||
} from '../lib/util'
|
|
||||||
import evalCss from '../lib/evalCss'
|
import evalCss from '../lib/evalCss'
|
||||||
|
import chobitsu from 'chobitsu'
|
||||||
|
|
||||||
|
chobitsu.domain('Network').enable()
|
||||||
|
|
||||||
export default class Network extends Tool {
|
export default class Network extends Tool {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -26,17 +16,13 @@ export default class Network extends Tool {
|
|||||||
this._tpl = require('./Network.hbs')
|
this._tpl = require('./Network.hbs')
|
||||||
this._detailTpl = require('./detail.hbs')
|
this._detailTpl = require('./detail.hbs')
|
||||||
this._requestsTpl = require('./requests.hbs')
|
this._requestsTpl = require('./requests.hbs')
|
||||||
this._datailData = {}
|
this._detailData = {}
|
||||||
this._isFetchSupported = false
|
|
||||||
if (window.fetch) this._isFetchSupported = isNative(window.fetch)
|
|
||||||
}
|
}
|
||||||
init($el, container) {
|
init($el, container) {
|
||||||
super.init($el)
|
super.init($el)
|
||||||
|
|
||||||
this._container = container
|
this._container = container
|
||||||
this._bindEvent()
|
this._bindEvent()
|
||||||
this._initCfg()
|
|
||||||
this.overrideXhr()
|
|
||||||
this._appendTpl()
|
this._appendTpl()
|
||||||
}
|
}
|
||||||
show() {
|
show() {
|
||||||
@@ -48,82 +34,6 @@ export default class Network extends Tool {
|
|||||||
this._requests = {}
|
this._requests = {}
|
||||||
this._render()
|
this._render()
|
||||||
}
|
}
|
||||||
overrideXhr() {
|
|
||||||
const winXhrProto = window.XMLHttpRequest.prototype
|
|
||||||
|
|
||||||
const origSend = (this._origSend = winXhrProto.send)
|
|
||||||
const origOpen = (this._origOpen = winXhrProto.open)
|
|
||||||
const origSetRequestHeader = (this._origSetRequestHeader =
|
|
||||||
winXhrProto.setRequestHeader)
|
|
||||||
|
|
||||||
const self = this
|
|
||||||
|
|
||||||
winXhrProto.open = function(method, url) {
|
|
||||||
const xhr = this
|
|
||||||
|
|
||||||
const req = (xhr.erudaRequest = new XhrRequest(xhr, method, url))
|
|
||||||
|
|
||||||
req.on('send', (id, data) => self._addReq(id, data))
|
|
||||||
req.on('update', (id, data) => self._updateReq(id, data))
|
|
||||||
|
|
||||||
xhr.addEventListener('readystatechange', function() {
|
|
||||||
switch (xhr.readyState) {
|
|
||||||
case 2:
|
|
||||||
return req.handleHeadersReceived()
|
|
||||||
case 4:
|
|
||||||
return req.handleDone()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
origOpen.apply(this, arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
winXhrProto.send = function(data) {
|
|
||||||
const req = this.erudaRequest
|
|
||||||
if (req) req.handleSend(data)
|
|
||||||
|
|
||||||
origSend.apply(this, arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
winXhrProto.setRequestHeader = function(key, val) {
|
|
||||||
const req = this.erudaRequest
|
|
||||||
if (req) req.handleReqHeadersSet(key, val)
|
|
||||||
|
|
||||||
origSetRequestHeader.apply(this, arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
restoreXhr() {
|
|
||||||
const winXhrProto = window.XMLHttpRequest.prototype
|
|
||||||
|
|
||||||
if (this._origOpen) winXhrProto.open = this._origOpen
|
|
||||||
if (this._origSend) winXhrProto.send = this._origSend
|
|
||||||
if (this._origSetRequestHeader) {
|
|
||||||
winXhrProto.setRequestHeader = this._origSetRequestHeader
|
|
||||||
}
|
|
||||||
}
|
|
||||||
overrideFetch() {
|
|
||||||
if (!this._isFetchSupported) return
|
|
||||||
|
|
||||||
const origFetch = (this._origFetch = window.fetch)
|
|
||||||
|
|
||||||
const self = this
|
|
||||||
|
|
||||||
window.fetch = function(...args) {
|
|
||||||
const req = new FetchRequest(...args)
|
|
||||||
req.on('send', (id, data) => self._addReq(id, data))
|
|
||||||
req.on('update', (id, data) => self._updateReq(id, data))
|
|
||||||
|
|
||||||
const fetchResult = origFetch(...args)
|
|
||||||
req.send(fetchResult)
|
|
||||||
|
|
||||||
return fetchResult
|
|
||||||
}
|
|
||||||
}
|
|
||||||
restoreFetch() {
|
|
||||||
if (!this._isFetchSupported) return
|
|
||||||
|
|
||||||
if (this._origFetch) window.fetch = this._origFetch
|
|
||||||
}
|
|
||||||
requests() {
|
requests() {
|
||||||
const ret = []
|
const ret = []
|
||||||
each(this._requests, request => {
|
each(this._requests, request => {
|
||||||
@@ -131,38 +41,75 @@ export default class Network extends Tool {
|
|||||||
})
|
})
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
_addReq(id, data) {
|
_reqWillBeSent = params => {
|
||||||
defaults(data, {
|
this._requests[params.requestId] = {
|
||||||
name: '',
|
name: getFileName(params.request.url),
|
||||||
url: '',
|
url: params.request.url,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
type: 'unknown',
|
type: 'unknown',
|
||||||
subType: 'unknown',
|
subType: 'unknown',
|
||||||
size: 0,
|
size: 0,
|
||||||
data: '',
|
data: params.request.postData,
|
||||||
method: 'GET',
|
method: params.request.method,
|
||||||
startTime: now(),
|
startTime: params.timestamp * 1000,
|
||||||
time: 0,
|
time: 0,
|
||||||
resTxt: '',
|
resTxt: '',
|
||||||
done: false
|
done: false,
|
||||||
})
|
reqHeaders: params.request.headers || {},
|
||||||
|
resHeaders: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_resReceivedExtraInfo = params => {
|
||||||
|
const target = this._requests[params.requestId]
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this._requests[id] = data
|
target.resHeaders = params.headers
|
||||||
|
|
||||||
|
this._updateType(target)
|
||||||
|
this._render()
|
||||||
|
}
|
||||||
|
_updateType(target) {
|
||||||
|
const contentType = target.resHeaders['content-type'] || ''
|
||||||
|
const { type, subType } = getType(contentType)
|
||||||
|
target.type = type
|
||||||
|
target.subType = subType
|
||||||
|
}
|
||||||
|
_resReceived = params => {
|
||||||
|
const target = this._requests[params.requestId]
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response } = params
|
||||||
|
const { status, headers } = response
|
||||||
|
target.status = status
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
target.hasErr = true
|
||||||
|
}
|
||||||
|
if (headers) {
|
||||||
|
target.resHeaders = headers
|
||||||
|
this._updateType(target)
|
||||||
|
}
|
||||||
|
|
||||||
this._render()
|
this._render()
|
||||||
}
|
}
|
||||||
_updateReq(id, data) {
|
_loadingFinished = params => {
|
||||||
const target = this._requests[id]
|
const target = this._requests[params.requestId]
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!target) return
|
const time = params.timestamp * 1000
|
||||||
|
target.time = time - target.startTime
|
||||||
extend(target, data)
|
|
||||||
|
|
||||||
target.time = target.time - target.startTime
|
|
||||||
target.displayTime = ms(target.time)
|
target.displayTime = ms(target.time)
|
||||||
|
|
||||||
if (target.done && (target.status < 200 || target >= 300))
|
target.size = params.encodedDataLength
|
||||||
target.hasErr = true
|
target.done = true
|
||||||
|
target.resTxt = chobitsu.domain('Network').getResponseBody({
|
||||||
|
requestId: params.requestId
|
||||||
|
}).body
|
||||||
|
|
||||||
this._render()
|
this._render()
|
||||||
}
|
}
|
||||||
@@ -211,59 +158,46 @@ export default class Network extends Tool {
|
|||||||
|
|
||||||
container.showTool('sources')
|
container.showTool('sources')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const network = chobitsu.domain('Network')
|
||||||
|
network.on('requestWillBeSent', this._reqWillBeSent)
|
||||||
|
network.on('responseReceivedExtraInfo', this._resReceivedExtraInfo)
|
||||||
|
network.on('responseReceived', this._resReceived)
|
||||||
|
network.on('loadingFinished', this._loadingFinished)
|
||||||
}
|
}
|
||||||
destroy() {
|
destroy() {
|
||||||
super.destroy()
|
super.destroy()
|
||||||
|
|
||||||
evalCss.remove(this._style)
|
evalCss.remove(this._style)
|
||||||
this.restoreXhr()
|
|
||||||
this.restoreFetch()
|
const network = chobitsu.domain('Network')
|
||||||
this._rmCfg()
|
network.off('requestWillBeSent', this._reqWillBeSent)
|
||||||
|
network.off('responseReceivedExtraInfo', this._resReceivedExtraInfo)
|
||||||
|
network.off('responseReceived', this._resReceived)
|
||||||
|
network.off('loadingFinished', this._loadingFinished)
|
||||||
}
|
}
|
||||||
_showDetail(data) {
|
_showDetail(data) {
|
||||||
if (data.resTxt && trim(data.resTxt) === '') delete data.resTxt
|
if (data.resTxt && trim(data.resTxt) === '') {
|
||||||
if (isEmpty(data.resHeaders)) delete data.resHeaders
|
delete data.resTxt
|
||||||
|
}
|
||||||
|
if (isEmpty(data.resHeaders)) {
|
||||||
|
delete data.resHeaders
|
||||||
|
}
|
||||||
|
if (isEmpty(data.reqHeaders)) {
|
||||||
|
delete data.reqHeaders
|
||||||
|
}
|
||||||
this._$detail.html(this._detailTpl(data)).show()
|
this._$detail.html(this._detailTpl(data)).show()
|
||||||
this._detailData = data
|
this._detailData = data
|
||||||
}
|
}
|
||||||
_hideDetail() {
|
_hideDetail() {
|
||||||
this._$detail.hide()
|
this._$detail.hide()
|
||||||
}
|
}
|
||||||
_rmCfg() {
|
|
||||||
const cfg = this.config
|
|
||||||
|
|
||||||
const settings = this._container.get('settings')
|
|
||||||
|
|
||||||
if (!settings) return
|
|
||||||
|
|
||||||
settings.remove(cfg, 'overrideFetch').remove('Network')
|
|
||||||
}
|
|
||||||
_appendTpl() {
|
_appendTpl() {
|
||||||
const $el = this._$el
|
const $el = this._$el
|
||||||
$el.html(this._tpl())
|
$el.html(this._tpl())
|
||||||
this._$detail = $el.find('.eruda-detail')
|
this._$detail = $el.find('.eruda-detail')
|
||||||
this._$requests = $el.find('.eruda-requests')
|
this._$requests = $el.find('.eruda-requests')
|
||||||
}
|
}
|
||||||
_initCfg() {
|
|
||||||
const cfg = (this.config = Settings.createCfg('network', {
|
|
||||||
overrideFetch: true
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (cfg.get('overrideFetch')) this.overrideFetch()
|
|
||||||
|
|
||||||
cfg.on('change', (key, val) => {
|
|
||||||
switch (key) {
|
|
||||||
case 'overrideFetch':
|
|
||||||
return val ? this.overrideFetch() : this.restoreFetch()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const settings = this._container.get('settings')
|
|
||||||
settings
|
|
||||||
.text('Network')
|
|
||||||
.switch(cfg, 'overrideFetch', 'Catch Fetch Requests')
|
|
||||||
.separator()
|
|
||||||
}
|
|
||||||
_render() {
|
_render() {
|
||||||
if (!this.active) return
|
if (!this.active) return
|
||||||
|
|
||||||
@@ -279,3 +213,14 @@ export default class Network extends Tool {
|
|||||||
this._$requests.html(html)
|
this._$requests.html(html)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getType(contentType) {
|
||||||
|
if (!contentType) return 'unknown'
|
||||||
|
|
||||||
|
const type = contentType.split(';')[0].split('/')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: type[0],
|
||||||
|
subType: last(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { getType, lenToUtf8Bytes, readBlobAsText } from './util'
|
|
||||||
import {
|
|
||||||
Emitter,
|
|
||||||
fullUrl,
|
|
||||||
uniqId,
|
|
||||||
isStr,
|
|
||||||
getFileName,
|
|
||||||
now,
|
|
||||||
each,
|
|
||||||
trim,
|
|
||||||
isCrossOrig,
|
|
||||||
toNum,
|
|
||||||
fileSize,
|
|
||||||
isEmpty
|
|
||||||
} from '../lib/util'
|
|
||||||
|
|
||||||
export default class XhrRequest extends Emitter {
|
|
||||||
constructor(xhr, method, url) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this._xhr = xhr
|
|
||||||
this._reqHeaders = {}
|
|
||||||
this._method = method
|
|
||||||
this._url = fullUrl(url)
|
|
||||||
this._id = uniqId('request')
|
|
||||||
}
|
|
||||||
handleSend(data) {
|
|
||||||
if (!isStr(data)) data = ''
|
|
||||||
|
|
||||||
data = {
|
|
||||||
name: getFileName(this._url),
|
|
||||||
url: this._url,
|
|
||||||
data,
|
|
||||||
method: this._method
|
|
||||||
}
|
|
||||||
if (!isEmpty(this._reqHeaders)) {
|
|
||||||
data.reqHeaders = this._reqHeaders
|
|
||||||
}
|
|
||||||
this.emit('send', this._id, data)
|
|
||||||
}
|
|
||||||
handleReqHeadersSet(key, val) {
|
|
||||||
if (key && val) {
|
|
||||||
this._reqHeaders[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleHeadersReceived() {
|
|
||||||
const xhr = this._xhr
|
|
||||||
|
|
||||||
const type = getType(xhr.getResponseHeader('Content-Type'))
|
|
||||||
this.emit('update', this._id, {
|
|
||||||
type: type.type,
|
|
||||||
subType: type.subType,
|
|
||||||
size: getSize(xhr, true, this._url),
|
|
||||||
time: now(),
|
|
||||||
resHeaders: getHeaders(xhr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
handleDone() {
|
|
||||||
const xhr = this._xhr
|
|
||||||
const resType = xhr.responseType
|
|
||||||
let resTxt = ''
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
this.emit('update', this._id, {
|
|
||||||
status: xhr.status,
|
|
||||||
done: true,
|
|
||||||
size: getSize(xhr, false, this._url),
|
|
||||||
time: now(),
|
|
||||||
resTxt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = getType(xhr.getResponseHeader('Content-Type'))
|
|
||||||
if (
|
|
||||||
resType === 'blob' &&
|
|
||||||
(type.type === 'text' ||
|
|
||||||
type.subType === 'javascript' ||
|
|
||||||
type.subType === 'json')
|
|
||||||
) {
|
|
||||||
readBlobAsText(xhr.response, (err, result) => {
|
|
||||||
if (result) resTxt = result
|
|
||||||
update()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
if (resType === '' || resType === 'text') resTxt = xhr.responseText
|
|
||||||
if (resType === 'json') resTxt = JSON.stringify(xhr.response)
|
|
||||||
|
|
||||||
update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHeaders(xhr) {
|
|
||||||
const raw = xhr.getAllResponseHeaders()
|
|
||||||
const lines = raw.split('\n')
|
|
||||||
|
|
||||||
const ret = {}
|
|
||||||
|
|
||||||
each(lines, line => {
|
|
||||||
line = trim(line)
|
|
||||||
|
|
||||||
if (line === '') return
|
|
||||||
|
|
||||||
const [key, val] = line.split(':', 2)
|
|
||||||
|
|
||||||
ret[key] = trim(val)
|
|
||||||
})
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSize(xhr, headersOnly, url) {
|
|
||||||
let size = 0
|
|
||||||
|
|
||||||
function getStrSize() {
|
|
||||||
if (!headersOnly) {
|
|
||||||
const resType = xhr.responseType
|
|
||||||
let resTxt = ''
|
|
||||||
|
|
||||||
if (resType === '' || resType === 'text') resTxt = xhr.responseText
|
|
||||||
if (resTxt) size = lenToUtf8Bytes(resTxt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCrossOrig(url)) {
|
|
||||||
getStrSize()
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
size = toNum(xhr.getResponseHeader('Content-Length'))
|
|
||||||
} catch (e) {
|
|
||||||
getStrSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (size === 0) getStrSize()
|
|
||||||
|
|
||||||
return `${fileSize(size)}B`
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { last } from '../lib/util'
|
|
||||||
|
|
||||||
export function getType(contentType) {
|
|
||||||
if (!contentType) return 'unknown'
|
|
||||||
|
|
||||||
const type = contentType.split(';')[0].split('/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: type[0],
|
|
||||||
subType: last(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lenToUtf8Bytes(str) {
|
|
||||||
const m = encodeURIComponent(str).match(/%[89ABab]/g)
|
|
||||||
|
|
||||||
return str.length + (m ? m.length : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readBlobAsText(blob, callback) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
callback(null, reader.result)
|
|
||||||
}
|
|
||||||
reader.onerror = err => {
|
|
||||||
callback(err)
|
|
||||||
}
|
|
||||||
reader.readAsText(blob)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user