mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-08-29 11:28:24 +02:00
fix(packages/angular): move child rendering logic to template from effect (#4519)
* fix(packages/angular): move child rendering logic to template from effect * test(packages/angular): add unit tests on SSR hydration
This commit is contained in:
@@ -54,6 +54,7 @@
|
||||
"@angular/core": "^21.2.17",
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/platform-server": "^21.2.17",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@lucide/build-icons": "workspace:*",
|
||||
"@lucide/helpers": "workspace:*",
|
||||
|
||||
@@ -22,7 +22,12 @@ describe('LucideDynamicIcon', () => {
|
||||
let component: LucideDynamicIcon;
|
||||
let fixture: ComponentFixture<LucideDynamicIcon>;
|
||||
let icon: WritableSignal<LucideIconInput | null | undefined>;
|
||||
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
|
||||
const expectSvgClasses = (classes: string[]) => {
|
||||
for (const cssClass of classes) {
|
||||
expect(fixture.nativeElement.classList.contains(cssClass)).toBe(true);
|
||||
}
|
||||
};
|
||||
const getRenderedChildren = () => Array.from(fixture.nativeElement.children) as Element[];
|
||||
const testIcon: LucideIconData = {
|
||||
name: 'demo',
|
||||
node: [['polyline', { points: '1 1 22 22' }]],
|
||||
@@ -30,11 +35,23 @@ describe('LucideDynamicIcon', () => {
|
||||
const testIcon2: LucideIconData = {
|
||||
name: 'demo-other',
|
||||
node: [
|
||||
['circle', { cx: 12, cy: 12, r: 8 }],
|
||||
['circle', { cx: 12, cy: 12, r: 8, fill: 'currentColor' }],
|
||||
['polyline', { points: '1 1 22 22' }],
|
||||
],
|
||||
aliases: ['demo-2'],
|
||||
};
|
||||
const supportedShapesIcon: LucideIconData = {
|
||||
name: 'supported-shapes',
|
||||
node: [
|
||||
['path', { d: 'm1 1 2 2', fill: 'currentColor', key: 'path-key' }],
|
||||
['line', { x1: 1, x2: 2, y1: 3, y2: 4, key: 'line-key' }],
|
||||
['polygon', { points: '1 1 2 2 3 1', key: 'polygon-key' }],
|
||||
['polyline', { points: '1 1 2 2 3 1', key: 'polyline-key' }],
|
||||
['circle', { cx: 12, cy: 12, r: 8, fill: 'currentColor', key: 'circle-key' }],
|
||||
['ellipse', { cx: 12, cy: 12, rx: 8, ry: 4, key: 'ellipse-key' }],
|
||||
['rect', { x: 1, y: 2, width: 3, height: 4, rx: 5, ry: 6, key: 'rect-key' }],
|
||||
],
|
||||
};
|
||||
function createComponent() {
|
||||
return TestBed.createComponent(LucideDynamicIcon, {
|
||||
inferTagName: true,
|
||||
@@ -58,15 +75,33 @@ describe('LucideDynamicIcon', () => {
|
||||
it('should render children', () => {
|
||||
icon.set(testIcon2);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><circle cx="12" cy="12" r="8"></circle><polyline points="1 1 22 22"></polyline><!--ng-container-->',
|
||||
const children = getRenderedChildren();
|
||||
expect(children.map((child) => child.tagName.toLowerCase())).toEqual(['circle', 'polyline']);
|
||||
expect(children[0].outerHTML).toBe(
|
||||
'<circle cx="12" cy="12" r="8" fill="currentColor"></circle>',
|
||||
);
|
||||
expect(children[1].outerHTML).toBe('<polyline points="1 1 22 22"></polyline>');
|
||||
});
|
||||
|
||||
it('should render supported SVG shapes and attributes', () => {
|
||||
icon.set(supportedShapesIcon);
|
||||
fixture.detectChanges();
|
||||
const children = getRenderedChildren();
|
||||
expect(children.map((child) => child.outerHTML)).toEqual([
|
||||
'<path d="m1 1 2 2" fill="currentColor"></path>',
|
||||
'<line x1="1" x2="2" y1="3" y2="4"></line>',
|
||||
'<polygon points="1 1 2 2 3 1"></polygon>',
|
||||
'<polyline points="1 1 2 2 3 1"></polyline>',
|
||||
'<circle cx="12" cy="12" r="8" fill="currentColor"></circle>',
|
||||
'<ellipse cx="12" cy="12" rx="8" ry="4"></ellipse>',
|
||||
'<rect x="1" y="2" width="3" height="4" rx="5" ry="6"></rect>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove children on change', () => {
|
||||
icon.set(null);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toBe('<!--container--><!--ng-container-->');
|
||||
expect(getRenderedChildren()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('iconInput', () => {
|
||||
@@ -74,9 +109,9 @@ describe('LucideDynamicIcon', () => {
|
||||
icon.set(testIcon);
|
||||
fixture.detectChanges();
|
||||
expect(component['icon']()).toBe(testIcon);
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><polyline points="1 1 22 22"></polyline><!--ng-container-->',
|
||||
);
|
||||
expect(getRenderedChildren().map((child) => child.outerHTML)).toEqual([
|
||||
'<polyline points="1 1 22 22"></polyline>',
|
||||
]);
|
||||
});
|
||||
it('should support LucideIcon input', () => {
|
||||
icon.set(LucideActivity);
|
||||
@@ -97,23 +132,24 @@ describe('LucideDynamicIcon', () => {
|
||||
describe('class', () => {
|
||||
it('should add all classes', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-demo');
|
||||
expectSvgClasses(['lucide', 'lucide-demo']);
|
||||
});
|
||||
it('should add backwards compatible classes from aliases', () => {
|
||||
icon.set(testIcon2);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-demo-other lucide-demo-2');
|
||||
expectSvgClasses(['lucide', 'lucide-demo-other', 'lucide-demo-2']);
|
||||
});
|
||||
it('should add class icon if available', () => {
|
||||
icon.set(LucideActivity);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-activity');
|
||||
expectSvgClasses(['lucide', 'lucide-activity']);
|
||||
});
|
||||
it('should remove class on change', () => {
|
||||
icon.set(null);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide');
|
||||
expectSvgClasses(['lucide']);
|
||||
expect(fixture.nativeElement.classList.contains('lucide-demo')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
107
packages/angular/src/lucide-hydration.spec.ts
Normal file
107
packages/angular/src/lucide-hydration.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ApplicationRef, Component, destroyPlatform, Provider, Type } from '@angular/core';
|
||||
import { bootstrapApplication, provideClientHydration } from '@angular/platform-browser';
|
||||
import { provideServerRendering, renderApplication } from '@angular/platform-server';
|
||||
import { LucideDynamicIcon } from './lucide-dynamic-icon';
|
||||
import { provideLucideIcons } from './lucide-icons';
|
||||
import { LucideBadgeAlert } from './icons/badge-alert';
|
||||
|
||||
@Component({
|
||||
// eslint-disable-next-line @angular-eslint/component-selector
|
||||
selector: 'lucide-static-hydration-root',
|
||||
template: `<svg lucideBadgeAlert [absoluteStrokeWidth]="true"></svg>`,
|
||||
imports: [LucideBadgeAlert],
|
||||
})
|
||||
class StaticHydrationRootComponent {}
|
||||
|
||||
@Component({
|
||||
// eslint-disable-next-line @angular-eslint/component-selector
|
||||
selector: 'lucide-dynamic-hydration-root',
|
||||
template: `<svg [lucideIcon]="'badge-alert'"></svg>`,
|
||||
imports: [LucideDynamicIcon],
|
||||
})
|
||||
class DynamicHydrationRootComponent {}
|
||||
|
||||
describe('Lucide hydration', () => {
|
||||
let appRef: ApplicationRef | undefined;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
appRef?.destroy();
|
||||
appRef = undefined;
|
||||
document.body.innerHTML = '';
|
||||
delete (globalThis as { ngServerMode?: boolean }).ngServerMode;
|
||||
destroyPlatform();
|
||||
});
|
||||
|
||||
async function renderAndHydrate(
|
||||
rootComponent: Type<unknown>,
|
||||
selector: string,
|
||||
providers: Provider[] = [],
|
||||
) {
|
||||
delete (globalThis as { ngServerMode?: boolean }).ngServerMode;
|
||||
const appConfig = {
|
||||
providers: [provideClientHydration(), ...providers],
|
||||
};
|
||||
const serverAppConfig = {
|
||||
providers: [provideServerRendering(), provideClientHydration(), ...providers],
|
||||
};
|
||||
const html = await renderApplication(
|
||||
(context) => bootstrapApplication(rootComponent, serverAppConfig, context),
|
||||
{
|
||||
document: `<!doctype html><html><head></head><body><${selector}></${selector}></body></html>`,
|
||||
url: 'http://localhost/',
|
||||
allowedHosts: ['localhost'],
|
||||
},
|
||||
);
|
||||
(globalThis as { ngServerMode?: boolean }).ngServerMode = false;
|
||||
const serverDocument = new DOMParser().parseFromString(html, 'text/html');
|
||||
expectBadgeAlertShapeNodes(serverDocument.querySelector('svg') as SVGSVGElement);
|
||||
document.head.innerHTML = serverDocument.head.innerHTML;
|
||||
document.body.innerHTML = serverDocument.body.innerHTML;
|
||||
|
||||
appRef = await bootstrapApplication(rootComponent, appConfig);
|
||||
await appRef.whenStable();
|
||||
|
||||
const hasMissingHydrationWarning = warnSpy.mock.calls.some((call: unknown[]) =>
|
||||
String(call[0]).includes('NG0505'),
|
||||
);
|
||||
expect(hasMissingHydrationWarning).toBe(false);
|
||||
|
||||
return document.querySelector('svg') as SVGSVGElement;
|
||||
}
|
||||
|
||||
function expectBadgeAlertShapeNodes(svg: SVGSVGElement) {
|
||||
expect(Array.from(svg.children).map((child) => child.tagName.toLowerCase())).toEqual([
|
||||
'path',
|
||||
'line',
|
||||
'line',
|
||||
]);
|
||||
}
|
||||
|
||||
it('should hydrate static icon children without duplicating SSR nodes', async () => {
|
||||
const svg = await renderAndHydrate(
|
||||
StaticHydrationRootComponent,
|
||||
'lucide-static-hydration-root',
|
||||
);
|
||||
|
||||
expectBadgeAlertShapeNodes(svg);
|
||||
for (const child of Array.from(svg.children)) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
|
||||
it('should hydrate dynamic icon children without duplicating SSR nodes', async () => {
|
||||
const svg = await renderAndHydrate(
|
||||
DynamicHydrationRootComponent,
|
||||
'lucide-dynamic-hydration-root',
|
||||
[provideLucideIcons(LucideBadgeAlert)],
|
||||
);
|
||||
|
||||
expectBadgeAlertShapeNodes(svg);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,12 @@ export class LucideCircleCheck extends LucideIconBase {
|
||||
protected override readonly icon = signal(LucideCircleCheck.icon);
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<svg lucideCircleCheck class="custom-icon"></svg>`,
|
||||
imports: [LucideCircleCheck],
|
||||
})
|
||||
class ClassHostComponent {}
|
||||
|
||||
describe('LucideIconBase', () => {
|
||||
let component: LucideCircleCheck;
|
||||
let fixture: ComponentFixture<LucideCircleCheck>;
|
||||
@@ -42,6 +48,12 @@ describe('LucideIconBase', () => {
|
||||
let strokeWidth: WritableSignal<string | number | undefined>;
|
||||
let absoluteStrokeWidth: WritableSignal<boolean | undefined>;
|
||||
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
|
||||
const expectSvgClasses = (classes: string[], svg: SVGElement = fixture.nativeElement) => {
|
||||
for (const cssClass of classes) {
|
||||
expect(svg.classList.contains(cssClass)).toBe(true);
|
||||
}
|
||||
};
|
||||
const getRenderedChildren = () => Array.from(fixture.nativeElement.children) as Element[];
|
||||
function createComponent() {
|
||||
return TestBed.createComponent(LucideCircleCheck, {
|
||||
inferTagName: true,
|
||||
@@ -71,15 +83,25 @@ describe('LucideIconBase', () => {
|
||||
|
||||
it('should render children', () => {
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path><!--ng-container-->',
|
||||
);
|
||||
const children = getRenderedChildren();
|
||||
expect(children.map((child) => child.tagName.toLowerCase())).toEqual(['circle', 'path']);
|
||||
expect(children[0].outerHTML).toBe('<circle cx="12" cy="12" r="10"></circle>');
|
||||
expect(children[1].outerHTML).toBe('<path d="m9 12 2 2 4-4"></path>');
|
||||
});
|
||||
|
||||
describe('class', () => {
|
||||
it('should add all classes', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-circle-check lucide-check-circle-2');
|
||||
expectSvgClasses(['lucide', 'lucide-circle-check', 'lucide-check-circle-2']);
|
||||
});
|
||||
it('should preserve user classes', () => {
|
||||
const hostFixture = TestBed.createComponent(ClassHostComponent);
|
||||
hostFixture.detectChanges();
|
||||
const svg = hostFixture.nativeElement.querySelector('svg');
|
||||
expectSvgClasses(
|
||||
['custom-icon', 'lucide', 'lucide-circle-check', 'lucide-check-circle-2'],
|
||||
svg,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,13 +164,15 @@ describe('LucideIconBase', () => {
|
||||
});
|
||||
it('should not set vector-effect on children', () => {
|
||||
absoluteStrokeWidth.set(false);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
fixture.detectChanges();
|
||||
for (const child of getRenderedChildren()) {
|
||||
expect(child.getAttribute('vector-effect')).toBeNull();
|
||||
}
|
||||
});
|
||||
it('should set vector-effect on children', () => {
|
||||
absoluteStrokeWidth.set(true);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
fixture.detectChanges();
|
||||
for (const child of getRenderedChildren()) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
@@ -228,13 +252,15 @@ describe('LucideIconBase', () => {
|
||||
});
|
||||
describe('absoluteStrokeWidth', () => {
|
||||
it('should use absoluteStrokeWidth from config', () => {
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
fixture.detectChanges();
|
||||
for (const child of getRenderedChildren()) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
it('should override absoluteStrokeWidth', () => {
|
||||
absoluteStrokeWidth.set(false);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
fixture.detectChanges();
|
||||
for (const child of getRenderedChildren()) {
|
||||
expect(child.getAttribute('vector-effect')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
Renderer2,
|
||||
Signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Component, computed, inject, input, Signal } from '@angular/core';
|
||||
import { LUCIDE_CONFIG } from './lucide-config';
|
||||
import { LucideIconData, Nullable } from './types';
|
||||
import defaultAttributes from './default-attributes';
|
||||
@@ -23,6 +14,7 @@ import { lucideIconTemplate } from './lucide-icon-template';
|
||||
host: {
|
||||
...defaultAttributes,
|
||||
class: 'lucide',
|
||||
'[class]': 'iconClasses()',
|
||||
'[attr.width]': 'size()',
|
||||
'[attr.height]': 'size()',
|
||||
'[attr.stroke]': 'color()',
|
||||
@@ -32,10 +24,28 @@ import { lucideIconTemplate } from './lucide-icon-template';
|
||||
})
|
||||
export abstract class LucideIconBase {
|
||||
protected abstract readonly icon: Signal<Nullable<LucideIconData>>;
|
||||
protected readonly iconNodes = computed<LucideIconData['node']>(() => {
|
||||
const node = this.icon()?.node ?? [];
|
||||
if (!this.absoluteStrokeWidth()) {
|
||||
return node;
|
||||
}
|
||||
return node.map<LucideIconData['node'][number]>(([name, attrs]) => [
|
||||
name,
|
||||
{
|
||||
'vector-effect': 'non-scaling-stroke',
|
||||
...attrs,
|
||||
},
|
||||
]);
|
||||
});
|
||||
protected readonly iconClasses = computed(() => {
|
||||
const icon = this.icon();
|
||||
if (!icon) {
|
||||
return '';
|
||||
}
|
||||
const { name, aliases = [] } = icon;
|
||||
return [name, ...aliases].map((item) => `lucide-${item}`).join(' ');
|
||||
});
|
||||
protected readonly iconConfig = inject(LUCIDE_CONFIG);
|
||||
protected readonly elRef = inject(ElementRef);
|
||||
protected readonly renderer = inject(Renderer2);
|
||||
protected readonly contentRef = viewChild.required<ElementRef>('contentRef');
|
||||
/**
|
||||
* An optional accessible label for the icon.
|
||||
* - If provided, it will add the title as an [`<svg:title>` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/title).
|
||||
@@ -76,43 +86,4 @@ export abstract class LucideIconBase {
|
||||
readonly absoluteStrokeWidth = input(this.iconConfig.absoluteStrokeWidth, {
|
||||
transform: (value: Nullable<boolean>) => value ?? this.iconConfig.absoluteStrokeWidth,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const icon = this.icon();
|
||||
if (icon) {
|
||||
const absoluteStrokeWidth = this.absoluteStrokeWidth();
|
||||
const { name, node, aliases = [] } = icon;
|
||||
const classes = [name, ...aliases].map((item) => `lucide-${item}`);
|
||||
for (const cssClass of classes) {
|
||||
this.renderer.addClass(this.elRef.nativeElement, cssClass);
|
||||
}
|
||||
const contentRef = this.contentRef();
|
||||
const refChild = contentRef.nativeElement;
|
||||
const elements = node.map(([name, attrs]) => {
|
||||
const element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg');
|
||||
if (absoluteStrokeWidth) {
|
||||
this.renderer.setAttribute(element, 'vector-effect', 'non-scaling-stroke');
|
||||
}
|
||||
Object.entries(attrs).forEach(([name, value]) =>
|
||||
this.renderer.setAttribute(
|
||||
element,
|
||||
name,
|
||||
typeof value === 'number' ? value.toString(10) : value,
|
||||
),
|
||||
);
|
||||
this.renderer.insertBefore(this.elRef.nativeElement, element, refChild);
|
||||
return element;
|
||||
});
|
||||
onCleanup(() => {
|
||||
elements.forEach((element) =>
|
||||
this.renderer.removeChild(this.elRef.nativeElement, element),
|
||||
);
|
||||
for (const cssClass of classes) {
|
||||
this.renderer.removeClass(this.elRef.nativeElement, cssClass);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,66 @@ export const lucideIconTemplate = `@if (title(); as titleValue) {
|
||||
<title>{{ titleValue }}</title>
|
||||
}
|
||||
<ng-content select="title"></ng-content>
|
||||
<ng-container #contentRef></ng-container>
|
||||
@for (child of iconNodes(); track child[1]['key'] ?? $index) {
|
||||
@let attrs = child[1];
|
||||
@switch (child[0]) {
|
||||
@case ('path') {
|
||||
<svg:path
|
||||
[attr.d]="attrs['d']"
|
||||
[attr.fill]="attrs['fill']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('line') {
|
||||
<svg:line
|
||||
[attr.x1]="attrs['x1']"
|
||||
[attr.x2]="attrs['x2']"
|
||||
[attr.y1]="attrs['y1']"
|
||||
[attr.y2]="attrs['y2']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('polygon') {
|
||||
<svg:polygon
|
||||
[attr.points]="attrs['points']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('polyline') {
|
||||
<svg:polyline
|
||||
[attr.points]="attrs['points']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('circle') {
|
||||
<svg:circle
|
||||
[attr.cx]="attrs['cx']"
|
||||
[attr.cy]="attrs['cy']"
|
||||
[attr.r]="attrs['r']"
|
||||
[attr.fill]="attrs['fill']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('ellipse') {
|
||||
<svg:ellipse
|
||||
[attr.cx]="attrs['cx']"
|
||||
[attr.cy]="attrs['cy']"
|
||||
[attr.rx]="attrs['rx']"
|
||||
[attr.ry]="attrs['ry']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('rect') {
|
||||
<svg:rect
|
||||
[attr.x]="attrs['x']"
|
||||
[attr.y]="attrs['y']"
|
||||
[attr.width]="attrs['width']"
|
||||
[attr.height]="attrs['height']"
|
||||
[attr.rx]="attrs['rx']"
|
||||
[attr.ry]="attrs['ry']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
<ng-content />`;
|
||||
|
||||
Reference in New Issue
Block a user