mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Fading handshake (#2474)
## Description: Add dynamic alliance icon with time-based fill and extension request indicator - Implement bottom-up green fill on alliance icon proportional to remaining time - Use AllianceIconFaded.svg as base layer with green overlay clipped from top - Add 20-82.40% clip range to account for icon vertical offset ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced <img width="1132" height="631" alt="Screenshot 2025-11-18 205205" src="https://github.com/user-attachments/assets/4af71ddc-f847-4460-9046-167275efc773" /> <img width="1387" height="792" alt="Screenshot 2025-11-18 205532" src="https://github.com/user-attachments/assets/9dd0e018-323f-4de1-bae8-2633c09fe867" /> ## Please put your Discord username so you can be contacted if a bug or regression is found: hauke4707 --------- Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="834"
|
||||
height="834"
|
||||
shape-rendering="geometricPrecision"
|
||||
image-rendering="optimizeQuality"
|
||||
fill-rule="evenodd"
|
||||
version="1.1"
|
||||
id="svg6"
|
||||
sodipodi:docname="AllianceIconFaded.svg"
|
||||
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs6" />
|
||||
<sodipodi:namedview
|
||||
id="namedview6"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.5"
|
||||
inkscape:cx="417"
|
||||
inkscape:cy="257"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg6" />
|
||||
<path
|
||||
d="M-.5 397.5v-5l136-239.5c1.667-.667 3.333-.667 5 0 20.752 12.458 41.252 25.291 61.5 38.5.638 1.109 1.138 2.275 1.5 3.5l-135 240.5c-3.0617 2.497-6.0617 2.33-9-.5l-60-37.5zm834-5v5c-21.238 14.207-43.071 27.541-65.5 40-1.285-.45-2.452-1.117-3.5-2L629.5 195c.583-2.499 1.916-4.499 4-6l59-36c1.667-.667 3.333-.667 5 0l136 239.5z"
|
||||
fill="#278f06"
|
||||
id="path1"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
d="M432.5 217.5c22.065-.582 44.065.251 66 2.5l85 17c5.667.667 11.333.667 17 0 9.847-1.549 19.514-3.549 29-6L734 417.5c-10.863 11.197-22.029 22.03-33.5 32.5-7.415 5.46-15.248 10.293-23.5 14.5-79.554-66.724-165.721-123.891-258.5-171.5-4.198-2.514-8.698-4.347-13.5-5.5-14.009 3.961-27.842 8.461-41.5 13.5-13.712 20.712-31.045 37.712-52 51-13.542 7.51-28.042 11.677-43.5 12.5-3.583-.558-7.083-1.391-10.5-2.5-2.082-2.409-3.082-5.243-3-8.5.561-6.244 2.061-12.244 4.5-18 21.319-34.477 44.486-67.644 69.5-99.5 11.022-5.673 22.688-9.34 35-11 23.037-3.569 46.037-6.069 69-7.5zm-231 15c10.445 5.724 21.112 11.224 32 16.5 18.501 4.528 37.168 8.195 56 11-17.45 23.055-33.283 47.222-47.5 72.5-11.16 34.914 1.34 50.747 37.5 47.5 17.364-2.453 33.364-8.453 48-18 18.377-12.71 33.71-28.376 46-47 10.749-4.255 21.749-7.255 33-9 66.843 32.897 129.843 71.564 189 116 24.877 18.276 49.21 37.276 73 57 17.316 16.374 17.316 32.708 0 49-10.221 5.794-20.888 6.794-32 3L502 451.5c-9.201 3.025-10.701 8.191-4.5 15.5L622 542.5c3.242 24.405-7.258 37.905-31.5 40.5-4.333.667-8.667.667-13 0l-109-63c-8.528-.156-11.695 4.011-9.5 12.5l107 63c1.107 12.35-2.726 22.85-11.5 31.5-3.448 1.927-7.115 3.261-11 4-8.72.839-17.387.505-26-1l-85-41c-9.548-.747-12.715 3.42-9.5 12.5l2.5 2.5 82 40c-16.901 24.862-40.068 33.362-69.5 25.5-18.921-3.688-37.421-8.855-55.5-15.5 16.937-19.691 18.437-40.524 4.5-62.5-10.469-13.324-23.969-19.657-40.5-19 1.841-25.828-8.825-44.328-32-55.5-5.764-2.214-11.597-3.047-17.5-2.5 2.293-30.025-10.873-49.525-39.5-58.5-15.974-2.677-29.141 2.157-39.5 14.5-2.973 4.474-6.14 8.807-9.5 13-13.651-30.999-36.318-40.832-68-29.5l-11 9c-10.856-14.036-22.19-27.702-34-41l106-189.5z"
|
||||
fill="#278f04"
|
||||
id="path2"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#28900b"
|
||||
d="M158.5 464.5c17.008.509 28.175 8.842 33.5 25 1.31 7.371.31 14.371-3 21-7.473 10.107-15.306 19.94-23.5 29.5-18.101 6.072-31.601.572-40.5-16.5-3.601-8.846-3.267-17.512 1-26 7.473-10.107 15.306-19.94 23.5-29.5 3.071-1.296 6.071-2.462 9-3.5z"
|
||||
id="path3"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#289009"
|
||||
d="M246.5 471.5c24.731 2.84 35.564 16.507 32.5 41l-58 81c-10.712 9.718-22.212 10.552-34.5 2.5-12.299-9.422-16.465-21.588-12.5-36.5 19.728-28.396 40.228-56.229 61.5-83.5 3.553-2.121 7.22-3.621 11-4.5z"
|
||||
id="path4"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#28900b"
|
||||
d="M293.5 530.5c18.901-.264 30.734 8.736 35.5 27 1.405 7.217.071 13.884-4 20L279.5 639c-19.61 8.459-34.11 2.959-43.5-16.5-3.226-7.013-3.226-14.013 0-21l47-65c3.101-2.852 6.601-4.852 10.5-6z"
|
||||
id="path5"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#28900c"
|
||||
d="M344.5 588.5c23.569 1.069 35.402 13.402 35.5 37-.856 4.044-2.522 7.711-5 11-8.91 11.488-17.577 23.155-26 35-9.872 10.173-21.039 11.673-33.5 4.5-13.736-9.632-18.236-22.465-13.5-38.5 11.116-15.451 22.616-30.617 34.5-45.5 2.809-1.093 5.476-2.26 8-3.5z"
|
||||
id="path6"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,4 +1,5 @@
|
||||
import allianceIcon from "../../../resources/images/AllianceIcon.svg";
|
||||
import allianceIconFaded from "../../../resources/images/AllianceIconFaded.svg";
|
||||
import allianceRequestBlackIcon from "../../../resources/images/AllianceRequestBlackIcon.svg";
|
||||
import allianceRequestWhiteIcon from "../../../resources/images/AllianceRequestWhiteIcon.svg";
|
||||
import crownIcon from "../../../resources/images/CrownIcon.svg";
|
||||
@@ -7,6 +8,7 @@ import embargoBlackIcon from "../../../resources/images/EmbargoBlackIcon.svg";
|
||||
import embargoWhiteIcon from "../../../resources/images/EmbargoWhiteIcon.svg";
|
||||
import nukeRedIcon from "../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../resources/images/NukeIconWhite.svg";
|
||||
import questionMarkIcon from "../../../resources/images/QuestionMarkIcon.svg";
|
||||
import targetIcon from "../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../resources/images/TraitorIcon.svg";
|
||||
import { AllPlayers, nukeTypes } from "../../core/game/Game";
|
||||
@@ -152,3 +154,69 @@ export function getPlayerIcons(
|
||||
|
||||
return icons;
|
||||
}
|
||||
|
||||
export function createAllianceProgressIcon(
|
||||
size: number,
|
||||
fraction: number,
|
||||
hasExtensionRequest: boolean,
|
||||
darkMode: boolean,
|
||||
): HTMLDivElement {
|
||||
// Wrapper
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.setAttribute("data-icon", "alliance");
|
||||
wrapper.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.width = `${size}px`;
|
||||
wrapper.style.height = `${size}px`;
|
||||
wrapper.style.display = "inline-block";
|
||||
|
||||
// Base faded icon (full)
|
||||
const base = document.createElement("img");
|
||||
base.src = allianceIconFaded;
|
||||
base.style.width = `${size}px`;
|
||||
base.style.height = `${size}px`;
|
||||
base.style.display = "block";
|
||||
base.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(base);
|
||||
|
||||
// Overlay container for green portion, clipped from the top via clip-path
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "alliance-progress-overlay";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
|
||||
const colored = document.createElement("img");
|
||||
colored.src = allianceIcon; // green icon
|
||||
colored.style.width = `${size}px`;
|
||||
colored.style.height = `${size}px`;
|
||||
colored.style.display = "block";
|
||||
colored.setAttribute("dark-mode", darkMode.toString());
|
||||
overlay.appendChild(colored);
|
||||
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
// Question mark overlay (shown when there's a pending extension request)
|
||||
const questionMark = document.createElement("img");
|
||||
questionMark.className = "alliance-question-mark";
|
||||
questionMark.src = questionMarkIcon;
|
||||
questionMark.style.position = "absolute";
|
||||
questionMark.style.left = "0";
|
||||
questionMark.style.top = "0";
|
||||
questionMark.style.width = `${size}px`;
|
||||
questionMark.style.height = `${size}px`;
|
||||
questionMark.style.display = hasExtensionRequest ? "block" : "none";
|
||||
questionMark.style.pointerEvents = "none";
|
||||
questionMark.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(questionMark);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
export function computeAllianceClipPath(fraction: number): string {
|
||||
const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
|
||||
return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
|
||||
import {
|
||||
computeAllianceClipPath,
|
||||
createAllianceProgressIcon,
|
||||
getFirstPlacePlayer,
|
||||
getPlayerIcons,
|
||||
PlayerIconId,
|
||||
@@ -51,6 +53,8 @@ export class NameLayer implements Layer {
|
||||
) {
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
@@ -399,6 +403,69 @@ export class NameLayer implements Layer {
|
||||
emojiDiv.textContent = icon.text;
|
||||
emojiDiv.style.fontSize = `${iconSize}px`;
|
||||
} else if (icon.kind === "image" && icon.src) {
|
||||
// Special handling for alliance icon with progress indicator
|
||||
if (icon.id === "alliance") {
|
||||
let allianceWrapper = render.icons.get(icon.id) as
|
||||
| HTMLDivElement
|
||||
| undefined;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const allianceView = myPlayer
|
||||
?.alliances()
|
||||
.find((a) => a.other === render.player.id());
|
||||
|
||||
let fraction = 0;
|
||||
let hasExtensionRequest = false;
|
||||
if (allianceView) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
allianceView.expiresAt - this.game.ticks(),
|
||||
);
|
||||
const duration = Math.max(1, this.game.config().allianceDuration());
|
||||
fraction = Math.max(0, Math.min(1, remaining / duration));
|
||||
hasExtensionRequest = allianceView.hasExtensionRequest;
|
||||
}
|
||||
|
||||
if (!allianceWrapper) {
|
||||
allianceWrapper = createAllianceProgressIcon(
|
||||
iconSize,
|
||||
fraction,
|
||||
hasExtensionRequest,
|
||||
this.userSettings.darkMode(),
|
||||
);
|
||||
iconsDiv.appendChild(allianceWrapper);
|
||||
render.icons.set(icon.id, allianceWrapper);
|
||||
} else {
|
||||
// Update existing alliance icon
|
||||
allianceWrapper.style.width = `${iconSize}px`;
|
||||
allianceWrapper.style.height = `${iconSize}px`;
|
||||
|
||||
const overlay = allianceWrapper.querySelector(
|
||||
".alliance-progress-overlay",
|
||||
) as HTMLDivElement | null;
|
||||
if (overlay) {
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
}
|
||||
|
||||
const questionMark = allianceWrapper.querySelector(
|
||||
".alliance-question-mark",
|
||||
) as HTMLImageElement | null;
|
||||
if (questionMark) {
|
||||
questionMark.style.display = hasExtensionRequest
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
// Update inner image sizes
|
||||
const imgs = allianceWrapper.getElementsByTagName("img");
|
||||
for (const img of imgs) {
|
||||
img.style.width = `${iconSize}px`;
|
||||
img.style.height = `${iconSize}px`;
|
||||
}
|
||||
}
|
||||
continue; // Skip regular image handling
|
||||
}
|
||||
|
||||
let imgElement = render.icons.get(icon.id) as
|
||||
| HTMLImageElement
|
||||
| undefined;
|
||||
|
||||
@@ -179,6 +179,7 @@ export interface AllianceView {
|
||||
other: PlayerID;
|
||||
createdAt: Tick;
|
||||
expiresAt: Tick;
|
||||
hasExtensionRequest: boolean;
|
||||
}
|
||||
|
||||
export interface AllianceRequestUpdate {
|
||||
|
||||
@@ -171,6 +171,10 @@ export class PlayerImpl implements Player {
|
||||
other: a.other(this).id(),
|
||||
createdAt: a.createdAt(),
|
||||
expiresAt: a.expiresAt(),
|
||||
hasExtensionRequest:
|
||||
a.expiresAt() <=
|
||||
this.mg.ticks() +
|
||||
this.mg.config().allianceExtensionPromptOffset(),
|
||||
}) satisfies AllianceView,
|
||||
),
|
||||
hasSpawned: this.hasSpawned(),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons";
|
||||
|
||||
describe("PlayerIcons", () => {
|
||||
describe("computeAllianceClipPath", () => {
|
||||
test("returns full visibility (20% top cut) when alliance time is at 100%", () => {
|
||||
const result = computeAllianceClipPath(1.0);
|
||||
// topCut = 20 + (1 - 1.0) * 80 * 0.78 = 20 + 0 = 20.00
|
||||
expect(result).toBe("inset(20.00% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns maximum cut (82.40% top cut) when alliance time is at 0%", () => {
|
||||
const result = computeAllianceClipPath(0.0);
|
||||
// topCut = 20 + (1 - 0.0) * 80 * 0.78 = 20 + 62.4 = 82.40
|
||||
expect(result).toBe("inset(82.40% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns 51.20% top cut when alliance time is at 50%", () => {
|
||||
const result = computeAllianceClipPath(0.5);
|
||||
// topCut = 20 + (1 - 0.5) * 80 * 0.78 = 20 + 31.2 = 51.20
|
||||
expect(result).toBe("inset(51.20% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns 27.80% top cut when alliance time is at 87.5%", () => {
|
||||
const result = computeAllianceClipPath(0.875);
|
||||
// topCut = 20 + (1 - 0.875) * 80 * 0.78 = 20 + 7.8 = 27.80
|
||||
expect(result).toBe("inset(27.80% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns 74.60% top cut when alliance time is at 12.5%", () => {
|
||||
const result = computeAllianceClipPath(0.125);
|
||||
// topCut = 20 + (1 - 0.125) * 80 * 0.78 = 20 + 54.6 = 74.60
|
||||
expect(result).toBe("inset(74.60% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("includes -2px horizontal overscan to prevent subpixel gaps", () => {
|
||||
const result = computeAllianceClipPath(0.5);
|
||||
expect(result).toContain("-2px");
|
||||
expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// Mock BuildMenu to avoid importing lit and other ESM-heavy deps in this unit test
|
||||
jest.mock(
|
||||
"../src/client/graphics/layers/BuildMenu",
|
||||
() => ({
|
||||
BuildMenu: class {},
|
||||
flattenedBuildTable: [],
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
// Mock Utils to avoid touching DOM (document) during tests
|
||||
jest.mock("../src/client/Utils", () => ({
|
||||
translateText: (k: string) => k,
|
||||
getSvgAspectRatio: async () => 1,
|
||||
}));
|
||||
|
||||
import {
|
||||
COLORS,
|
||||
rootMenuElement,
|
||||
type MenuElementParams,
|
||||
} from "../src/client/graphics/layers/RadialMenuElements";
|
||||
|
||||
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
|
||||
const makePlayer = (id: string) =>
|
||||
({
|
||||
id: () => id,
|
||||
isAlliedWith: (other: any) =>
|
||||
other && typeof other.id === "function" && other.id() !== id
|
||||
? true
|
||||
: true,
|
||||
}) as unknown as import("../src/core/game/GameView").PlayerView;
|
||||
|
||||
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
const myPlayer = (opts?.myPlayer as any) ?? makePlayer("p1");
|
||||
const selected = (opts?.selected as any) ?? makePlayer("p2");
|
||||
return {
|
||||
myPlayer,
|
||||
selected,
|
||||
tile: {} as any,
|
||||
playerActions: {
|
||||
canAttack: true,
|
||||
interaction: {
|
||||
canBreakAlliance: true,
|
||||
canSendAllianceRequest: false,
|
||||
canEmbargo: false,
|
||||
},
|
||||
} as any,
|
||||
game: {
|
||||
inSpawnPhase: () => false,
|
||||
owner: () => ({ isPlayer: () => false }),
|
||||
} as any,
|
||||
buildMenu: {
|
||||
canBuildOrUpgrade: () => false,
|
||||
cost: () => 0,
|
||||
count: () => 0,
|
||||
sendBuildOrUpgrade: () => {},
|
||||
} as any,
|
||||
emojiTable: {} as any,
|
||||
playerActionHandler: {
|
||||
handleBreakAlliance: jest.fn(),
|
||||
handleEmbargo: jest.fn(),
|
||||
handleDonateGold: jest.fn(),
|
||||
handleDonateTroops: jest.fn(),
|
||||
handleTargetPlayer: jest.fn(),
|
||||
} as any,
|
||||
playerPanel: {
|
||||
show: jest.fn(),
|
||||
} as any,
|
||||
chatIntegration: {
|
||||
createQuickChatMenu: jest.fn(() => []),
|
||||
} as any,
|
||||
eventBus: {} as any,
|
||||
closeMenu: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const findAllyBreak = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break");
|
||||
|
||||
describe("RadialMenuElements ally break", () => {
|
||||
test("shows break option with correct color when allied", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
expect(ally).toBeTruthy();
|
||||
expect(ally.name).toBe("break");
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("action calls handleBreakAlliance and closes menu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
|
||||
ally.action!(params);
|
||||
|
||||
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
|
||||
params.myPlayer,
|
||||
params.selected,
|
||||
);
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user