mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
Merge branch 'main' into ue
This commit is contained in:
commit
173c476fb4
12 files changed
+240
-110
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# BlitzKit 1.30.3
|
||||
|
||||
Thank you to [GonnaHetzMe](https://github.com/karelpak1) for your contributions this update.
|
||||
|
||||
## Features
|
||||
|
||||
- [GonnaHetzMe] Added tooltips to the module selector in Tankopedia
|
||||
|
||||
## Fixes
|
||||
|
||||
- Added aggressive re-rendering for the Guess mini-game's score readout to make inspect-element harder
|
||||
- Removed tank visual and collision model caching
|
||||
- Guesser once again breaks streaks if not all tiers are selected
|
||||
- BlitzKit now properly unloads tank models from memory when visually hidden in Guesser
|
||||
- [GonnaHetzMe] Tanks with complex transparency like the Herdbreaker once again handle opacity correctly
|
||||
- [GonnaHetzMe] Pieces of tank models that have transparency now handle culling properly (they're visible from both sides)
|
||||
- [GonnaHetzMe] Tanks with "cardboard" textures have been fixed, thanks to better PVR file parsing
|
||||
- [GonnaHetzMe] Tank icons are now scaled correctly so small tanks are now properly zoomed out in contrast to bigger ones
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clamp, isEqual, times } from 'lodash-es';
|
||||
import { ReadStream } from './buffer';
|
||||
import { clamp, isEqual, times } from "lodash-es";
|
||||
import { ReadStream } from "./buffer";
|
||||
|
||||
export enum PvrFlags {
|
||||
NoFlag = 0,
|
||||
@@ -96,7 +96,7 @@ export class PvrReadStream extends ReadStream {
|
||||
const header = this.header();
|
||||
this.metadata();
|
||||
|
||||
if (typeof header.pixelFormat === 'number') {
|
||||
if (typeof header.pixelFormat === "number") {
|
||||
switch (header.pixelFormat) {
|
||||
default:
|
||||
throw new TypeError(
|
||||
@@ -142,12 +142,12 @@ export class PvrReadStream extends ReadStream {
|
||||
|
||||
times(pixelCount, (index) => {
|
||||
const buffer = this.consumeUint8Array(2);
|
||||
const bufferIndex = index * 4;
|
||||
const bufferIndex = index * 3;
|
||||
const r = ((buffer[1] & 0b11111000) >>> 3) / 31;
|
||||
const g =
|
||||
((buffer[1] & 0b111) << 3) |
|
||||
(((buffer[0] & 0b11100000) >>> 5) / 31);
|
||||
const b = buffer[0] & 0b11111;
|
||||
(((buffer[1] & 0b111) << 3) | ((buffer[0] & 0b11100000) >>> 5)) /
|
||||
63;
|
||||
const b = (buffer[0] & 0b11111) / 31;
|
||||
|
||||
data[bufferIndex] = Math.round(clamp(r * 255, 0, 255));
|
||||
data[bufferIndex + 1] = Math.round(clamp(g * 255, 0, 255));
|
||||
@@ -186,7 +186,7 @@ export class PvrReadStream extends ReadStream {
|
||||
metadataSize: this.uint32(),
|
||||
};
|
||||
|
||||
if (header.version !== 'PVR\x03') throw new TypeError('Endian mismatch');
|
||||
if (header.version !== "PVR\x03") throw new TypeError("Endian mismatch");
|
||||
|
||||
return header;
|
||||
}
|
||||
@@ -205,19 +205,19 @@ export class PvrReadStream extends ReadStream {
|
||||
resolveBitRatePixelFormat(
|
||||
pixelFormat: Exclude<ReturnType<typeof this.pixelFormat>, number>,
|
||||
) {
|
||||
if (pixelFormat.order === 'rgba') {
|
||||
if (pixelFormat.order === "rgba") {
|
||||
if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) {
|
||||
return ResolvedBitRatePixelFormat.R4G4B4A4;
|
||||
} else
|
||||
throw new TypeError(
|
||||
`Unhandled rgba bit rate ${pixelFormat.bitRates.join(', ')}`,
|
||||
`Unhandled rgba bit rate ${pixelFormat.bitRates.join(", ")}`,
|
||||
);
|
||||
} else if (pixelFormat.order === 'rgb\0') {
|
||||
} else if (pixelFormat.order === "rgb\0") {
|
||||
if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) {
|
||||
return ResolvedBitRatePixelFormat.R5G6B5A0;
|
||||
} else
|
||||
throw new TypeError(
|
||||
`Unhandled rgb bit rate ${pixelFormat.bitRates.join(', ')}`,
|
||||
`Unhandled rgb bit rate ${pixelFormat.bitRates.join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
throw new TypeError(`Unhandled pixel order ${pixelFormat.order}`);
|
||||
|
||||
@@ -15,6 +15,9 @@ type DataNode = {
|
||||
qualityGroup?: string;
|
||||
fxName?: string;
|
||||
textures?: Textures;
|
||||
customCullMode?: number;
|
||||
enabledPresets?: EnabledPresets;
|
||||
properties?: Properties;
|
||||
} & (
|
||||
| ({
|
||||
configCount: number;
|
||||
@@ -24,7 +27,7 @@ type DataNode = {
|
||||
}
|
||||
);
|
||||
|
||||
interface ConfigArchive {
|
||||
export interface ConfigArchive {
|
||||
configName: string;
|
||||
fxName: string;
|
||||
properties: Properties;
|
||||
|
||||
@@ -1018,8 +1018,8 @@
|
||||
"title": "Характеристики",
|
||||
"compare": {
|
||||
"label": "Сравнивать с",
|
||||
"class": "Уровень Тип",
|
||||
"tier": "Все уровни",
|
||||
"class": "{tier} {class}",
|
||||
"tier": "Все {tier} уровня",
|
||||
"all": "Все танки"
|
||||
},
|
||||
"values": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NATION_IDS } from "@blitzkit/core";
|
||||
import sharp from "sharp";
|
||||
import type { Vector3Tuple } from "three";
|
||||
import { parsePackedSpriteRect } from "../core/blitz/parsePackedSpriteRect";
|
||||
import { AssetUploader } from "../core/github/assetUploader";
|
||||
import { vfs } from "./constants";
|
||||
import type { VehicleDefinitionList } from "./definitions";
|
||||
@@ -37,6 +38,45 @@ export interface TankParameters {
|
||||
};
|
||||
}
|
||||
|
||||
async function extractPackedTankIcon(packedBuffer: Buffer, context: string) {
|
||||
const texture = sharp(packedBuffer);
|
||||
const spriteRect = parsePackedSpriteRect(packedBuffer);
|
||||
|
||||
if (!spriteRect) {
|
||||
throw new Error(`Missing RIFF sprite bounds for ${context}`);
|
||||
}
|
||||
|
||||
const [left, top, width, height] = spriteRect;
|
||||
const bounds = [left, top, width, height];
|
||||
|
||||
if (!bounds.every(Number.isInteger)) {
|
||||
throw new Error(
|
||||
`Invalid RIFF sprite bounds for ${context}: ${bounds.join(" ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = await texture.metadata();
|
||||
|
||||
if (metadata.width === undefined || metadata.height === undefined) {
|
||||
throw new Error(`Failed to read image dimensions for ${context}`);
|
||||
}
|
||||
|
||||
if (
|
||||
left < 0 ||
|
||||
top < 0 ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
left + width > metadata.width ||
|
||||
top + height > metadata.height
|
||||
) {
|
||||
throw new Error(
|
||||
`Out-of-bounds RIFF sprite for ${context}: ${bounds.join(" ")} in ${metadata.width}x${metadata.height}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await texture.extract({ left, top, width, height }).toBuffer();
|
||||
}
|
||||
|
||||
export async function tankIcons() {
|
||||
console.log("Building tank icons...");
|
||||
|
||||
@@ -67,12 +107,14 @@ export async function tankIcons() {
|
||||
const bigPath = `Data/${parameters.resourcesPath.bigIconPath
|
||||
.replace(/~res:\//, "")
|
||||
.replace(/\..+/, "")}.packed.webp`;
|
||||
const big = await sharp(await vfs.file(bigPath))
|
||||
.trim()
|
||||
.toBuffer();
|
||||
const small = await sharp(await vfs.file(smallPath))
|
||||
.trim()
|
||||
.toBuffer();
|
||||
const big = await extractPackedTankIcon(
|
||||
await vfs.file(bigPath),
|
||||
`tank ${tankKey} big`,
|
||||
);
|
||||
const small = await extractPackedTankIcon(
|
||||
await vfs.file(smallPath),
|
||||
`tank ${tankKey} small`,
|
||||
);
|
||||
|
||||
if (big) {
|
||||
await uploader.add({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
bufferToBigInt,
|
||||
ConfigArchive,
|
||||
Hierarchy,
|
||||
Sc2ReadStream,
|
||||
ScgReadStream,
|
||||
@@ -75,6 +76,42 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
|
||||
.setImage(baseColor),
|
||||
);
|
||||
|
||||
let defaultConfigArchive: ConfigArchive | undefined = undefined;
|
||||
|
||||
if (node.configCount) {
|
||||
const names = times(
|
||||
node.configCount,
|
||||
(index) => node[`configArchive_${index}`],
|
||||
);
|
||||
|
||||
defaultConfigArchive = names.find(
|
||||
(archive) => archive.configName === "Default",
|
||||
);
|
||||
defaultConfigArchive ??= node.configArchive_0;
|
||||
}
|
||||
|
||||
const customCullMode =
|
||||
node.customCullMode ?? defaultConfigArchive?.customCullMode;
|
||||
|
||||
if (customCullMode === 0) {
|
||||
material.setDoubleSided(true);
|
||||
} else if (customCullMode !== undefined) {
|
||||
material.setDoubleSided(false);
|
||||
}
|
||||
|
||||
if (
|
||||
node.enabledPresets?.AlphaTest &&
|
||||
node.properties?.alphatestThreshold
|
||||
) {
|
||||
const view = new DataView(node.properties.alphatestThreshold);
|
||||
const alphaCutoff =
|
||||
view.byteLength < 4
|
||||
? 0.5
|
||||
: view.getFloat32(view.byteLength - 4, true);
|
||||
|
||||
material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff);
|
||||
}
|
||||
|
||||
if (node.configCount) {
|
||||
for (
|
||||
let configIndex = 0;
|
||||
@@ -85,14 +122,17 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
|
||||
|
||||
if (
|
||||
archive.configName !== "Default" ||
|
||||
!archive.enabledPresets?.AlphaTest
|
||||
!archive.enabledPresets?.AlphaTest ||
|
||||
!archive.properties?.alphatestThreshold
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const view = new DataView(archive.properties.alphatestThreshold);
|
||||
const _ = view.getUint8(0); // only god knows what these flags are
|
||||
const alphaCutoff = view.getFloat32(1, true);
|
||||
const alphaCutoff =
|
||||
view.byteLength < 4
|
||||
? 0.5
|
||||
: view.getFloat32(view.byteLength - 4, true);
|
||||
|
||||
material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff);
|
||||
}
|
||||
@@ -171,8 +211,8 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
|
||||
|
||||
case "TransformComponent": {
|
||||
const localTranslation = component["tc.localTranslation"];
|
||||
// The game resets top-level node translation to [0, 0, 0] on load.
|
||||
// Child nodes must keep their authored local offsets.
|
||||
// The game resets top-level node translation to [0, 0, 0] on load.
|
||||
// Child nodes must keep their authored local offsets.
|
||||
node.setTranslation(
|
||||
parent instanceof Scene ? [0, 0, 0] : localTranslation,
|
||||
);
|
||||
@@ -284,4 +324,4 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
|
||||
await document.transform(prune({ keepAttributes: true }), dedup());
|
||||
|
||||
return document;
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,14 @@ export function Guesser() {
|
||||
const [results, setResults] = useState<TankDefinition[] | null>(null);
|
||||
const [selected, setSelected] = useState<TankDefinition | null>(null);
|
||||
const tiers = Guess.use((state) => state.tiers);
|
||||
const status = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const requestSearch = useCallback(() => {
|
||||
setSelected(null);
|
||||
setSearching(true);
|
||||
search();
|
||||
}, []);
|
||||
|
||||
const search = useCallback(
|
||||
debounce(() => {
|
||||
if (!input.current) return;
|
||||
@@ -92,6 +94,20 @@ export function Guesser() {
|
||||
if (guessState !== GuessState.NotGuessed) setSelected(null);
|
||||
}, [guessState]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!status.current) return;
|
||||
|
||||
status.current.innerText = literals(strings.website.tools.guess.stats, {
|
||||
correct: correctGuesses,
|
||||
total: totalGuesses,
|
||||
streak: streak,
|
||||
});
|
||||
}, 1000 / 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [correctGuesses, totalGuesses, streak]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
@@ -158,13 +174,7 @@ export function Guesser() {
|
||||
</Box>
|
||||
|
||||
<Flex justify="center" style={{ userSelect: "none" }}>
|
||||
<Text>
|
||||
{literals(strings.website.tools.guess.stats, {
|
||||
correct: correctGuesses,
|
||||
total: totalGuesses,
|
||||
streak: streak,
|
||||
})}
|
||||
</Text>
|
||||
<Text ref={status} />
|
||||
</Flex>
|
||||
|
||||
<Flex gap={{ initial: "2", sm: "3" }}>
|
||||
@@ -312,7 +322,7 @@ export function Guesser() {
|
||||
? GuessState.Correct
|
||||
: GuessState.Incorrect;
|
||||
draft.totalGuesses++;
|
||||
draft.correctGuesses += correct ? 1 : 0;
|
||||
draft.correctGuesses += correct && draft.tiers.length === 10 ? 1 : 0;
|
||||
draft.streak = correct ? draft.streak + 1 : 0;
|
||||
});
|
||||
} else {
|
||||
|
||||
-31
@@ -70,37 +70,6 @@ export function Equalizer() {
|
||||
<Switch checked={equalize} />
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Text style={{ cursor: "pointer" }} size="2" color="gray">
|
||||
<Flex align="center" gap="2">
|
||||
Brought to you by{" "}
|
||||
<img
|
||||
src="/assets/images/third-party/wotb-news.png"
|
||||
style={{
|
||||
width: "1rem",
|
||||
height: "1rem",
|
||||
objectFit: "cover",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>{" "}
|
||||
WoTB News <InfoCircledIcon />
|
||||
</Flex>
|
||||
</Text>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Text>
|
||||
This feature was brought to you early, ahead of the release of the
|
||||
game mode, thanks to{" "}
|
||||
<Link href="https://discord.gg/WHdER7ZPAD" target="_blank">
|
||||
WoTB News
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
+12
-2
@@ -18,6 +18,7 @@ import {
|
||||
Heading,
|
||||
IconButton,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@radix-ui/themes";
|
||||
import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
@@ -55,12 +56,14 @@ function ModuleButton({
|
||||
selected,
|
||||
unlock,
|
||||
top,
|
||||
tooltip,
|
||||
onClick,
|
||||
}: {
|
||||
unlock: Unlock;
|
||||
tier: number;
|
||||
selected: boolean;
|
||||
top: boolean;
|
||||
tooltip?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
|
||||
@@ -134,7 +137,7 @@ function ModuleButton({
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return isTank ? (
|
||||
const node = isTank ? (
|
||||
<LinkI18n
|
||||
locale={locale}
|
||||
href={`/tanks/${tankDefinitions.tanks[unlock.id].slug}`}
|
||||
@@ -144,6 +147,10 @@ function ModuleButton({
|
||||
) : (
|
||||
button
|
||||
);
|
||||
|
||||
if (!tooltip) return node;
|
||||
|
||||
return <Tooltip content={tooltip}>{node}</Tooltip>;
|
||||
}
|
||||
|
||||
const tankDefinitions = await awaitableTankDefinitions;
|
||||
@@ -167,7 +174,7 @@ export function Modules() {
|
||||
const topGun = topTurret.guns.at(-1)!;
|
||||
const topEngine = tank.engines.at(-1)!;
|
||||
const topTrack = tank.tracks.at(-1)!;
|
||||
const { strings } = useLocale();
|
||||
const { strings, unwrap } = useLocale();
|
||||
|
||||
function setByUnlock(unlock: Unlock) {
|
||||
Duel.mutate((draft) => {
|
||||
@@ -249,6 +256,8 @@ export function Modules() {
|
||||
|
||||
if (module === undefined) return null;
|
||||
|
||||
const tooltip = unwrap(module.name!);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={unlock.id}
|
||||
@@ -301,6 +310,7 @@ export function Modules() {
|
||||
<ModuleButton
|
||||
unlock={unlock}
|
||||
tier={extractModule(module).tier}
|
||||
tooltip={tooltip}
|
||||
selected={
|
||||
(unlock.type === ModuleType.MODULE_TYPE_TURRET
|
||||
? turret.id
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { asset } from '@blitzkit/core';
|
||||
import { type ObjectMap, useLoader } from '@react-three/fiber';
|
||||
import { type GLTF, GLTFLoader } from 'three-stdlib';
|
||||
|
||||
const cache: Record<
|
||||
number,
|
||||
{ gltf: GLTF & ObjectMap; hasDynamicArmor: boolean }
|
||||
> = {};
|
||||
import { asset } from "@blitzkit/core";
|
||||
import { useLoader } from "@react-three/fiber";
|
||||
import { GLTFLoader } from "three-stdlib";
|
||||
import { useDispose } from "./useDispose";
|
||||
|
||||
export function useArmor(id: number) {
|
||||
const gltf = useLoader(GLTFLoader, asset(`3d/tanks/armor/${id}.glb`));
|
||||
const path = asset(`3d/tanks/armor/${id}.glb`);
|
||||
const gltf = useLoader(GLTFLoader, path);
|
||||
|
||||
if (!cache[id]) {
|
||||
cache[id] = {
|
||||
gltf,
|
||||
hasDynamicArmor: Object.values(gltf.nodes).some(
|
||||
(node) =>
|
||||
node.name.includes('state_00') || node.name.includes('state_01'),
|
||||
),
|
||||
};
|
||||
}
|
||||
return cache[id];
|
||||
useDispose(gltf, path);
|
||||
|
||||
return {
|
||||
gltf,
|
||||
hasDynamicArmor: Object.values(gltf.nodes).some(
|
||||
(node) =>
|
||||
node.name.includes("state_00") || node.name.includes("state_01"),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useLoader, type ObjectMap } from "@react-three/fiber";
|
||||
import { useEffect } from "react";
|
||||
import { Cache, Group, Mesh, MeshStandardMaterial, Object3D } from "three";
|
||||
import { GLTFLoader, type GLTF } from "three-stdlib";
|
||||
|
||||
export function useDispose(gltf: GLTF & ObjectMap, path: string) {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
gltf.scene.traverse((object) => {
|
||||
if (object instanceof Mesh) {
|
||||
if (object.material instanceof MeshStandardMaterial) {
|
||||
const maps = [
|
||||
object.material.map,
|
||||
object.material.aoMap,
|
||||
object.material.envMap,
|
||||
object.material.bumpMap,
|
||||
object.material.alphaMap,
|
||||
object.material.lightMap,
|
||||
object.material.normalMap,
|
||||
object.material.emissiveMap,
|
||||
object.material.roughnessMap,
|
||||
object.material.metalnessMap,
|
||||
object.material.displacementMap,
|
||||
];
|
||||
|
||||
for (const map of maps) {
|
||||
if (!map) continue;
|
||||
|
||||
map.source.data.close();
|
||||
map.dispose();
|
||||
}
|
||||
|
||||
object.material.dispose();
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`Unexpected material type: ${object.material.type}`,
|
||||
);
|
||||
}
|
||||
} else if (object instanceof Group || object instanceof Object3D) {
|
||||
// traverse will get children
|
||||
} else {
|
||||
throw new TypeError(`Unexpected object type: ${object}`);
|
||||
}
|
||||
});
|
||||
|
||||
useLoader.clear(GLTFLoader, path);
|
||||
Cache.remove(path);
|
||||
};
|
||||
}, [gltf, path]);
|
||||
}
|
||||
@@ -1,30 +1,22 @@
|
||||
import { asset } from "@blitzkit/core";
|
||||
import { type ObjectMap, useLoader } from "@react-three/fiber";
|
||||
import { useLoader } from "@react-three/fiber";
|
||||
import { Mesh, MeshStandardMaterial } from "three";
|
||||
import { type GLTF, GLTFLoader } from "three-stdlib";
|
||||
|
||||
const cache: Record<
|
||||
number,
|
||||
{
|
||||
gltf: GLTF & ObjectMap;
|
||||
hasPbr: boolean;
|
||||
}
|
||||
> = {};
|
||||
import { GLTFLoader } from "three-stdlib";
|
||||
import { useDispose } from "./useDispose";
|
||||
|
||||
export function useModel(id: number) {
|
||||
const gltf = useLoader(GLTFLoader, asset(`3d/tanks/models/${id}.glb`));
|
||||
const path = asset(`3d/tanks/models/${id}.glb`);
|
||||
const gltf = useLoader(GLTFLoader, path);
|
||||
|
||||
if (!cache[id]) {
|
||||
cache[id] = {
|
||||
gltf,
|
||||
hasPbr: Object.values(gltf.nodes).some(
|
||||
(node) =>
|
||||
node instanceof Mesh &&
|
||||
node.material instanceof MeshStandardMaterial &&
|
||||
node.material.roughnessMap !== null
|
||||
),
|
||||
};
|
||||
}
|
||||
useDispose(gltf, path);
|
||||
|
||||
return cache[id];
|
||||
return {
|
||||
gltf,
|
||||
hasPbr: Object.values(gltf.nodes).some(
|
||||
(node) =>
|
||||
node instanceof Mesh &&
|
||||
node.material instanceof MeshStandardMaterial &&
|
||||
node.material.roughnessMap !== null,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user