Cache addHoverClass as it is quite expensive (#643)
* Add cache and cache purging Needed for https://github.com/rrweb-io/rrweb-snapshot/pull/85 * Add cache and cache purging Needed for https://github.com/rrweb-io/rrweb-snapshot/pull/85 * Cache addHoverClass as it is quite expensive https://github.com/rrweb-io/rrweb-snapshot/pull/85 * Make cache non-optional * Make cache required on addHoverClass
This commit is contained in:
@@ -6,7 +6,11 @@ import snapshot, {
|
|||||||
needMaskingText,
|
needMaskingText,
|
||||||
IGNORED_NODE,
|
IGNORED_NODE,
|
||||||
} from './snapshot';
|
} from './snapshot';
|
||||||
import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild';
|
import rebuild, {
|
||||||
|
buildNodeWithSN,
|
||||||
|
addHoverClass,
|
||||||
|
createCache,
|
||||||
|
} from './rebuild';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ export {
|
|||||||
rebuild,
|
rebuild,
|
||||||
buildNodeWithSN,
|
buildNodeWithSN,
|
||||||
addHoverClass,
|
addHoverClass,
|
||||||
|
createCache,
|
||||||
transformAttribute,
|
transformAttribute,
|
||||||
visitSnapshot,
|
visitSnapshot,
|
||||||
cleanupSnapshot,
|
cleanupSnapshot,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
elementNode,
|
elementNode,
|
||||||
idNodeMap,
|
idNodeMap,
|
||||||
INode,
|
INode,
|
||||||
|
BuildCache,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { isElement } from './utils';
|
import { isElement } from './utils';
|
||||||
|
|
||||||
@@ -64,7 +65,10 @@ function escapeRegExp(str: string) {
|
|||||||
|
|
||||||
const HOVER_SELECTOR = /([^\\]):hover/;
|
const HOVER_SELECTOR = /([^\\]):hover/;
|
||||||
const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g');
|
const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g');
|
||||||
export function addHoverClass(cssText: string): string {
|
export function addHoverClass(cssText: string, cache: BuildCache): string {
|
||||||
|
const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
|
||||||
|
if (cachedStyle) return cachedStyle;
|
||||||
|
|
||||||
const ast = parse(cssText, {
|
const ast = parse(cssText, {
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
@@ -99,10 +103,19 @@ export function addHoverClass(cssText: string): string {
|
|||||||
'g',
|
'g',
|
||||||
);
|
);
|
||||||
|
|
||||||
return cssText.replace(selectorMatcher, (selector) => {
|
const result = cssText.replace(selectorMatcher, (selector) => {
|
||||||
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
|
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
|
||||||
return `${selector}, ${newSelector}`;
|
return `${selector}, ${newSelector}`;
|
||||||
});
|
});
|
||||||
|
cache?.stylesWithHoverClass.set(cssText, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCache(): BuildCache {
|
||||||
|
const stylesWithHoverClass: Map<string, string> = new Map();
|
||||||
|
return {
|
||||||
|
stylesWithHoverClass,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNode(
|
function buildNode(
|
||||||
@@ -110,9 +123,10 @@ function buildNode(
|
|||||||
options: {
|
options: {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
hackCss: boolean;
|
hackCss: boolean;
|
||||||
|
cache: BuildCache;
|
||||||
},
|
},
|
||||||
): Node | null {
|
): Node | null {
|
||||||
const { doc, hackCss } = options;
|
const { doc, hackCss, cache } = options;
|
||||||
switch (n.type) {
|
switch (n.type) {
|
||||||
case NodeType.Document:
|
case NodeType.Document:
|
||||||
return doc.implementation.createDocument(null, '', null);
|
return doc.implementation.createDocument(null, '', null);
|
||||||
@@ -143,7 +157,7 @@ function buildNode(
|
|||||||
const isRemoteOrDynamicCss =
|
const isRemoteOrDynamicCss =
|
||||||
tagName === 'style' && name === '_cssText';
|
tagName === 'style' && name === '_cssText';
|
||||||
if (isRemoteOrDynamicCss && hackCss) {
|
if (isRemoteOrDynamicCss && hackCss) {
|
||||||
value = addHoverClass(value);
|
value = addHoverClass(value, cache);
|
||||||
}
|
}
|
||||||
if (isTextarea || isRemoteOrDynamicCss) {
|
if (isTextarea || isRemoteOrDynamicCss) {
|
||||||
const child = doc.createTextNode(value);
|
const child = doc.createTextNode(value);
|
||||||
@@ -256,7 +270,9 @@ function buildNode(
|
|||||||
return node;
|
return node;
|
||||||
case NodeType.Text:
|
case NodeType.Text:
|
||||||
return doc.createTextNode(
|
return doc.createTextNode(
|
||||||
n.isStyle && hackCss ? addHoverClass(n.textContent) : n.textContent,
|
n.isStyle && hackCss
|
||||||
|
? addHoverClass(n.textContent, cache)
|
||||||
|
: n.textContent,
|
||||||
);
|
);
|
||||||
case NodeType.CDATA:
|
case NodeType.CDATA:
|
||||||
return doc.createCDATASection(n.textContent);
|
return doc.createCDATASection(n.textContent);
|
||||||
@@ -275,10 +291,18 @@ export function buildNodeWithSN(
|
|||||||
skipChild?: boolean;
|
skipChild?: boolean;
|
||||||
hackCss: boolean;
|
hackCss: boolean;
|
||||||
afterAppend?: (n: INode) => unknown;
|
afterAppend?: (n: INode) => unknown;
|
||||||
|
cache: BuildCache;
|
||||||
},
|
},
|
||||||
): INode | null {
|
): INode | null {
|
||||||
const { doc, map, skipChild = false, hackCss = true, afterAppend } = options;
|
const {
|
||||||
let node = buildNode(n, { doc, hackCss });
|
doc,
|
||||||
|
map,
|
||||||
|
skipChild = false,
|
||||||
|
hackCss = true,
|
||||||
|
afterAppend,
|
||||||
|
cache,
|
||||||
|
} = options;
|
||||||
|
let node = buildNode(n, { doc, hackCss, cache });
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -310,6 +334,7 @@ export function buildNodeWithSN(
|
|||||||
skipChild: false,
|
skipChild: false,
|
||||||
hackCss,
|
hackCss,
|
||||||
afterAppend,
|
afterAppend,
|
||||||
|
cache,
|
||||||
});
|
});
|
||||||
if (!childNode) {
|
if (!childNode) {
|
||||||
console.warn('Failed to rebuild', childN);
|
console.warn('Failed to rebuild', childN);
|
||||||
@@ -369,9 +394,10 @@ function rebuild(
|
|||||||
onVisit?: (node: INode) => unknown;
|
onVisit?: (node: INode) => unknown;
|
||||||
hackCss?: boolean;
|
hackCss?: boolean;
|
||||||
afterAppend?: (n: INode) => unknown;
|
afterAppend?: (n: INode) => unknown;
|
||||||
|
cache: BuildCache;
|
||||||
},
|
},
|
||||||
): [Node | null, idNodeMap] {
|
): [Node | null, idNodeMap] {
|
||||||
const { doc, onVisit, hackCss = true, afterAppend } = options;
|
const { doc, onVisit, hackCss = true, afterAppend, cache } = options;
|
||||||
const idNodeMap: idNodeMap = {};
|
const idNodeMap: idNodeMap = {};
|
||||||
const node = buildNodeWithSN(n, {
|
const node = buildNodeWithSN(n, {
|
||||||
doc,
|
doc,
|
||||||
@@ -379,6 +405,7 @@ function rebuild(
|
|||||||
skipChild: false,
|
skipChild: false,
|
||||||
hackCss,
|
hackCss,
|
||||||
afterAppend,
|
afterAppend,
|
||||||
|
cache,
|
||||||
});
|
});
|
||||||
visit(idNodeMap, (visitedNode) => {
|
visit(idNodeMap, (visitedNode) => {
|
||||||
if (onVisit) {
|
if (onVisit) {
|
||||||
|
|||||||
@@ -111,3 +111,7 @@ export type MaskTextFn = (text: string) => string;
|
|||||||
export type MaskInputFn = (text: string) => string;
|
export type MaskInputFn = (text: string) => string;
|
||||||
|
|
||||||
export type KeepIframeSrcFn = (src: string) => boolean;
|
export type KeepIframeSrcFn = (src: string) => boolean;
|
||||||
|
|
||||||
|
export type BuildCache = {
|
||||||
|
stylesWithHoverClass: Map<string, string>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,53 +1,67 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import 'mocha';
|
import { Suite } from 'mocha';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import { addHoverClass } from '../src/rebuild';
|
import { addHoverClass, createCache } from '../src/rebuild';
|
||||||
|
import { BuildCache } from '../src/types';
|
||||||
|
|
||||||
|
function getDuration(hrtime: [number, number]) {
|
||||||
|
const [seconds, nanoseconds] = hrtime;
|
||||||
|
return seconds * 1000 + nanoseconds / 1000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISuite extends Suite {
|
||||||
|
cache: BuildCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('add hover class to hover selector related rules', function (this: ISuite) {
|
||||||
|
beforeEach(() => {
|
||||||
|
this.cache = createCache();
|
||||||
|
});
|
||||||
|
|
||||||
describe('add hover class to hover selector related rules', () => {
|
|
||||||
it('will do nothing to css text without :hover', () => {
|
it('will do nothing to css text without :hover', () => {
|
||||||
const cssText = 'body { color: white }';
|
const cssText = 'body { color: white }';
|
||||||
expect(addHoverClass(cssText)).to.equal(cssText);
|
expect(addHoverClass(cssText, this.cache)).to.equal(cssText);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add hover class to css text', () => {
|
it('can add hover class to css text', () => {
|
||||||
const cssText = '.a:hover { color: white }';
|
const cssText = '.a:hover { color: white }';
|
||||||
expect(addHoverClass(cssText)).to.equal(
|
expect(addHoverClass(cssText, this.cache)).to.equal(
|
||||||
'.a:hover, .a.\\:hover { color: white }',
|
'.a:hover, .a.\\:hover { color: white }',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add hover class when there is multi selector', () => {
|
it('can add hover class when there is multi selector', () => {
|
||||||
const cssText = '.a, .b:hover, .c { color: white }';
|
const cssText = '.a, .b:hover, .c { color: white }';
|
||||||
expect(addHoverClass(cssText)).to.equal(
|
expect(addHoverClass(cssText, this.cache)).to.equal(
|
||||||
'.a, .b:hover, .b.\\:hover, .c { color: white }',
|
'.a, .b:hover, .b.\\:hover, .c { color: white }',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add hover class when there is a multi selector with the same prefix', () => {
|
it('can add hover class when there is a multi selector with the same prefix', () => {
|
||||||
const cssText = '.a:hover, .a:hover::after { color: white }';
|
const cssText = '.a:hover, .a:hover::after { color: white }';
|
||||||
expect(addHoverClass(cssText)).to.equal(
|
expect(addHoverClass(cssText, this.cache)).to.equal(
|
||||||
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
|
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add hover class when :hover is not the end of selector', () => {
|
it('can add hover class when :hover is not the end of selector', () => {
|
||||||
const cssText = 'div:hover::after { color: white }';
|
const cssText = 'div:hover::after { color: white }';
|
||||||
expect(addHoverClass(cssText)).to.equal(
|
expect(addHoverClass(cssText, this.cache)).to.equal(
|
||||||
'div:hover::after, div.\\:hover::after { color: white }',
|
'div:hover::after, div.\\:hover::after { color: white }',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add hover class when the selector has multi :hover', () => {
|
it('can add hover class when the selector has multi :hover', () => {
|
||||||
const cssText = 'a:hover b:hover { color: white }';
|
const cssText = 'a:hover b:hover { color: white }';
|
||||||
expect(addHoverClass(cssText)).to.equal(
|
expect(addHoverClass(cssText, this.cache)).to.equal(
|
||||||
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
|
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will ignore :hover in css value', () => {
|
it('will ignore :hover in css value', () => {
|
||||||
const cssText = '.a::after { content: ":hover" }';
|
const cssText = '.a::after { content: ":hover" }';
|
||||||
expect(addHoverClass(cssText)).to.equal(cssText);
|
expect(addHoverClass(cssText, this.cache)).to.equal(cssText);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('benchmark', () => {
|
it('benchmark', () => {
|
||||||
@@ -56,9 +70,28 @@ describe('add hover class to hover selector related rules', () => {
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const start = process.hrtime();
|
const start = process.hrtime();
|
||||||
addHoverClass(cssText);
|
addHoverClass(cssText, this.cache);
|
||||||
const end = process.hrtime(start);
|
const end = process.hrtime(start);
|
||||||
const duration = end[0] * 1_000 + end[1] / 1_000_000;
|
const duration = getDuration(end);
|
||||||
expect(duration).to.below(100);
|
expect(duration).to.below(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be a lot faster to add a hover class to a previously processed css string', () => {
|
||||||
|
const factor = 100;
|
||||||
|
|
||||||
|
let cssText = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, './css/benchmark.css'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = process.hrtime();
|
||||||
|
addHoverClass(cssText, this.cache);
|
||||||
|
const end = process.hrtime(start);
|
||||||
|
|
||||||
|
const cachedStart = process.hrtime();
|
||||||
|
addHoverClass(cssText, this.cache);
|
||||||
|
const cachedEnd = process.hrtime(cachedStart);
|
||||||
|
|
||||||
|
expect(getDuration(cachedEnd) * factor).to.below(getDuration(end));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
4
packages/rrweb-snapshot/typings/index.d.ts
vendored
4
packages/rrweb-snapshot/typings/index.d.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot';
|
import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot';
|
||||||
import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild';
|
import rebuild, { buildNodeWithSN, addHoverClass, createCache } from './rebuild';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, };
|
export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, createCache, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, };
|
||||||
|
|||||||
7
packages/rrweb-snapshot/typings/rebuild.d.ts
vendored
7
packages/rrweb-snapshot/typings/rebuild.d.ts
vendored
@@ -1,16 +1,19 @@
|
|||||||
import { serializedNodeWithId, idNodeMap, INode } from './types';
|
import { serializedNodeWithId, idNodeMap, INode, BuildCache } from './types';
|
||||||
export declare function addHoverClass(cssText: string): string;
|
export declare function addHoverClass(cssText: string, cache: BuildCache): string;
|
||||||
|
export declare function createCache(): BuildCache;
|
||||||
export declare function buildNodeWithSN(n: serializedNodeWithId, options: {
|
export declare function buildNodeWithSN(n: serializedNodeWithId, options: {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
map: idNodeMap;
|
map: idNodeMap;
|
||||||
skipChild?: boolean;
|
skipChild?: boolean;
|
||||||
hackCss: boolean;
|
hackCss: boolean;
|
||||||
afterAppend?: (n: INode) => unknown;
|
afterAppend?: (n: INode) => unknown;
|
||||||
|
cache: BuildCache;
|
||||||
}): INode | null;
|
}): INode | null;
|
||||||
declare function rebuild(n: serializedNodeWithId, options: {
|
declare function rebuild(n: serializedNodeWithId, options: {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
onVisit?: (node: INode) => unknown;
|
onVisit?: (node: INode) => unknown;
|
||||||
hackCss?: boolean;
|
hackCss?: boolean;
|
||||||
afterAppend?: (n: INode) => unknown;
|
afterAppend?: (n: INode) => unknown;
|
||||||
|
cache: BuildCache;
|
||||||
}): [Node | null, idNodeMap];
|
}): [Node | null, idNodeMap];
|
||||||
export default rebuild;
|
export default rebuild;
|
||||||
|
|||||||
3
packages/rrweb-snapshot/typings/types.d.ts
vendored
3
packages/rrweb-snapshot/typings/types.d.ts
vendored
@@ -90,3 +90,6 @@ export declare type SlimDOMOptions = Partial<{
|
|||||||
export declare type MaskTextFn = (text: string) => string;
|
export declare type MaskTextFn = (text: string) => string;
|
||||||
export declare type MaskInputFn = (text: string) => string;
|
export declare type MaskInputFn = (text: string) => string;
|
||||||
export declare type KeepIframeSrcFn = (src: string) => boolean;
|
export declare type KeepIframeSrcFn = (src: string) => boolean;
|
||||||
|
export declare type BuildCache = {
|
||||||
|
stylesWithHoverClass: Map<string, string>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot';
|
import {
|
||||||
|
rebuild,
|
||||||
|
buildNodeWithSN,
|
||||||
|
INode,
|
||||||
|
NodeType,
|
||||||
|
BuildCache,
|
||||||
|
createCache,
|
||||||
|
} from 'rrweb-snapshot';
|
||||||
import * as mittProxy from 'mitt';
|
import * as mittProxy from 'mitt';
|
||||||
import { polyfill as smoothscrollPolyfill } from './smoothscroll';
|
import { polyfill as smoothscrollPolyfill } from './smoothscroll';
|
||||||
import { Timer } from './timer';
|
import { Timer } from './timer';
|
||||||
@@ -97,6 +104,9 @@ export class Replayer {
|
|||||||
// Hold the list of CSSRules for in-memory state restoration
|
// Hold the list of CSSRules for in-memory state restoration
|
||||||
private virtualStyleRulesMap!: VirtualStyleRulesMap;
|
private virtualStyleRulesMap!: VirtualStyleRulesMap;
|
||||||
|
|
||||||
|
// The replayer uses the cache to speed up replay and scrubbing.
|
||||||
|
private cache: BuildCache = createCache();
|
||||||
|
|
||||||
private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();
|
private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();
|
||||||
|
|
||||||
private mirror: Mirror = createMirror();
|
private mirror: Mirror = createMirror();
|
||||||
@@ -376,6 +386,14 @@ export class Replayer {
|
|||||||
this.iframe.style.pointerEvents = 'none';
|
this.iframe.style.pointerEvents = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties the replayer's cache and reclaims memory.
|
||||||
|
* The replayer will use this cache to speed up the playback.
|
||||||
|
*/
|
||||||
|
public resetCache() {
|
||||||
|
this.cache = createCache();
|
||||||
|
}
|
||||||
|
|
||||||
private setupDom() {
|
private setupDom() {
|
||||||
this.wrapper = document.createElement('div');
|
this.wrapper = document.createElement('div');
|
||||||
this.wrapper.classList.add('replayer-wrapper');
|
this.wrapper.classList.add('replayer-wrapper');
|
||||||
@@ -566,6 +584,7 @@ export class Replayer {
|
|||||||
afterAppend: (builtNode) => {
|
afterAppend: (builtNode) => {
|
||||||
this.collectIframeAndAttachDocument(collected, builtNode);
|
this.collectIframeAndAttachDocument(collected, builtNode);
|
||||||
},
|
},
|
||||||
|
cache: this.cache,
|
||||||
})[1];
|
})[1];
|
||||||
for (const { mutationInQueue, builtNode } of collected) {
|
for (const { mutationInQueue, builtNode } of collected) {
|
||||||
this.attachDocumentToIframe(mutationInQueue, builtNode);
|
this.attachDocumentToIframe(mutationInQueue, builtNode);
|
||||||
@@ -639,6 +658,7 @@ export class Replayer {
|
|||||||
afterAppend: (builtNode) => {
|
afterAppend: (builtNode) => {
|
||||||
this.collectIframeAndAttachDocument(collected, builtNode);
|
this.collectIframeAndAttachDocument(collected, builtNode);
|
||||||
},
|
},
|
||||||
|
cache: this.cache,
|
||||||
});
|
});
|
||||||
for (const { mutationInQueue, builtNode } of collected) {
|
for (const { mutationInQueue, builtNode } of collected) {
|
||||||
this.attachDocumentToIframe(mutationInQueue, builtNode);
|
this.attachDocumentToIframe(mutationInQueue, builtNode);
|
||||||
@@ -1217,6 +1237,7 @@ export class Replayer {
|
|||||||
map: this.mirror.map,
|
map: this.mirror.map,
|
||||||
skipChild: true,
|
skipChild: true,
|
||||||
hackCss: true,
|
hackCss: true,
|
||||||
|
cache: this.cache,
|
||||||
}) as INode;
|
}) as INode;
|
||||||
|
|
||||||
// legacy data, we should not have -1 siblings any more
|
// legacy data, we should not have -1 siblings any more
|
||||||
|
|||||||
Reference in New Issue
Block a user