Draw routes as city-code polylines and force-open intermediate tooltips
- routesToPolylines + intermediateCityIds pure helpers with unit coverage. - IMapPolyline reshaped from points to cityIds for Angular-parity drawing. - MapCanvas resolves coords via markerIndex, filters invisible cities on every zoom/toggle change, and runs a second tooltip pass that keeps intermediate-city tooltips open regardless of zoom/highlight rules.
This commit is contained in:
@@ -48,15 +48,23 @@ interface MockMap {
|
||||
fireZoomend: () => void;
|
||||
}
|
||||
|
||||
interface MockPolyline {
|
||||
latlngs: unknown;
|
||||
opts: unknown;
|
||||
addTo: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const createdMarkers: MockMarker[] = [];
|
||||
const createdLayerGroups: MockLayerGroup[] = [];
|
||||
const createdMaps: MockMap[] = [];
|
||||
const createdPolylines: MockPolyline[] = [];
|
||||
let layerGroupCounter = 0;
|
||||
|
||||
function resetLeafletMockState() {
|
||||
createdMarkers.length = 0;
|
||||
createdLayerGroups.length = 0;
|
||||
createdMaps.length = 0;
|
||||
createdPolylines.length = 0;
|
||||
layerGroupCounter = 0;
|
||||
}
|
||||
|
||||
@@ -130,7 +138,15 @@ vi.mock("leaflet", () => {
|
||||
m._addedLayers.delete(l);
|
||||
return m;
|
||||
}),
|
||||
hasLayer: vi.fn((l: MockLayerGroup) => m._addedLayers.has(l)),
|
||||
hasLayer: vi.fn((target: unknown) => {
|
||||
if (target && typeof target === "object" && "_markers" in (target as Record<string, unknown>)) {
|
||||
return m._addedLayers.has(target as MockLayerGroup);
|
||||
}
|
||||
const owning = createdLayerGroups.find((lg) =>
|
||||
lg._markers.includes(target as MockMarker),
|
||||
);
|
||||
return owning ? m._addedLayers.has(owning) : false;
|
||||
}),
|
||||
getZoom: vi.fn(() => m._zoom),
|
||||
setZoom: (z: number) => {
|
||||
m._zoom = z;
|
||||
@@ -155,8 +171,17 @@ vi.mock("leaflet", () => {
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
function polyline() {
|
||||
return { addTo: vi.fn() };
|
||||
function polyline(latlngs: unknown, opts: unknown) {
|
||||
const p: MockPolyline = {
|
||||
latlngs,
|
||||
opts,
|
||||
addTo: vi.fn((target: { addLayer?: (l: unknown) => unknown }) => {
|
||||
target.addLayer?.(p);
|
||||
return p;
|
||||
}),
|
||||
};
|
||||
createdPolylines.push(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function popup() {
|
||||
@@ -365,3 +390,180 @@ describe("MapCanvas — tooltip visibility rules", () => {
|
||||
expect(b.openTooltip).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C.3 polyline + intermediate tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { IMapPolyline } from "../types.js";
|
||||
|
||||
function cm(
|
||||
id: string,
|
||||
countryType: "ru" | "other" = "ru",
|
||||
overrides: Partial<IMapMarker> = {},
|
||||
): IMapMarker {
|
||||
return {
|
||||
id,
|
||||
lat: 55,
|
||||
lng: 37,
|
||||
style: "blue-small",
|
||||
label: id,
|
||||
tooltipPermanent: true,
|
||||
zoomLevel: 2,
|
||||
countryType,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pl(id: string, cityIds: string[], style: "direct" | "connecting" = "direct"): IMapPolyline {
|
||||
return { id, cityIds, style };
|
||||
}
|
||||
|
||||
describe("MapCanvas — polylines (C.3)", () => {
|
||||
beforeEach(() => resetLeafletMockState());
|
||||
|
||||
it("resolves coords from markers and draws a polyline A→B", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A"), cm("B")]}
|
||||
polylines={[pl("line", ["A", "B"])]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(6);
|
||||
map.fireZoomend();
|
||||
|
||||
expect(createdPolylines.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("does not draw a polyline when fewer than 2 cities are visible", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A")]}
|
||||
polylines={[pl("line", ["A", "B"])]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(6);
|
||||
map.fireZoomend();
|
||||
|
||||
expect(createdPolylines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("skips intermediate cities whose marker is not on the map", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A"), cm("B", "other"), cm("C")]}
|
||||
polylines={[pl("tri", ["A", "B", "C"])]}
|
||||
domestic={true}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
// Reset so we count only polylines drawn by the next sync cycle.
|
||||
createdPolylines.length = 0;
|
||||
map.setZoom(6);
|
||||
map.fireZoomend();
|
||||
|
||||
expect(createdPolylines.length).toBe(1);
|
||||
});
|
||||
|
||||
it("applies connecting style for non-direct routes", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A"), cm("B")]}
|
||||
polylines={[pl("dashed", ["A", "B"], "connecting")]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(6);
|
||||
map.fireZoomend();
|
||||
|
||||
const styled = createdPolylines[0]!;
|
||||
const opts = styled.opts as { dashArray?: string } | undefined;
|
||||
expect(opts?.dashArray).toBe("4 14");
|
||||
});
|
||||
|
||||
it("rebuilds polylines on zoomend", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A"), cm("B")]}
|
||||
polylines={[pl("line", ["A", "B"])]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(6);
|
||||
map.fireZoomend();
|
||||
const before = createdPolylines.length;
|
||||
|
||||
map.setZoom(4);
|
||||
map.fireZoomend();
|
||||
|
||||
expect(createdPolylines.length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("silently skips polylines with unknown city codes", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A")]}
|
||||
polylines={[pl("line", ["A", "NOPE"])]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(6);
|
||||
map.fireZoomend();
|
||||
|
||||
expect(createdPolylines.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapCanvas — intermediateIds (C.3)", () => {
|
||||
beforeEach(() => resetLeafletMockState());
|
||||
|
||||
it("force-opens intermediate marker tooltips even at zoom <= 3", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[
|
||||
cm("A", "ru", { highlighted: true }),
|
||||
cm("B", "ru", { highlighted: true }),
|
||||
cm("X", "ru"),
|
||||
]}
|
||||
polylines={[]}
|
||||
intermediateIds={["X"]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(3);
|
||||
map.fireZoomend();
|
||||
|
||||
const X = createdMarkers.find((m) => m.options.title === "X")!;
|
||||
expect(X.openTooltip).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("force-opens intermediate tooltips with >=2 highlighted markers", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[
|
||||
cm("A", "ru", { highlighted: true }),
|
||||
cm("B", "ru", { highlighted: true }),
|
||||
cm("X", "ru"),
|
||||
]}
|
||||
polylines={[]}
|
||||
intermediateIds={["X"]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
const map = createdMaps[0]!;
|
||||
map.setZoom(5);
|
||||
map.fireZoomend();
|
||||
|
||||
const X = createdMarkers.find((m) => m.options.title === "X")!;
|
||||
expect(X.openTooltip).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface MapCanvasProps {
|
||||
domestic?: boolean;
|
||||
/** When true, hide countryType="ru" markers. */
|
||||
international?: boolean;
|
||||
/** Marker IDs whose tooltips must be force-opened (intermediate cities on multi-hop routes). */
|
||||
intermediateIds?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -164,6 +166,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
className,
|
||||
domestic = false,
|
||||
international = false,
|
||||
intermediateIds,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
@@ -179,6 +182,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
const highlightLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const markerIndexRef = useRef<Map<string, L.Marker>>(new Map());
|
||||
const highlightedIdsRef = useRef<Set<string>>(new Set());
|
||||
const intermediateIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Track latest toggles so the zoomend handler sees current values
|
||||
const domesticRef = useRef(domestic);
|
||||
@@ -231,6 +235,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
const highlighted = highlightedIdsRef.current;
|
||||
const closeNonHighlighted = currentZoom <= 3 || highlighted.size >= 2;
|
||||
|
||||
// Pass 1: zoom/highlight rules.
|
||||
markerIndexRef.current.forEach((marker, id) => {
|
||||
if (closeNonHighlighted && !highlighted.has(id)) {
|
||||
marker.closeTooltip();
|
||||
@@ -238,6 +243,41 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
marker.openTooltip();
|
||||
}
|
||||
});
|
||||
|
||||
// Pass 2: force-open intermediate route tooltips (Angular parity).
|
||||
for (const id of intermediateIdsRef.current) {
|
||||
const m = markerIndexRef.current.get(id);
|
||||
if (m) m.openTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function syncPolylines(): void {
|
||||
const map = mapRef.current;
|
||||
const layer = polylinesLayerRef.current;
|
||||
if (!map || !layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
for (const pl of polylines) {
|
||||
const styleOpts = POLYLINE_STYLES[pl.style];
|
||||
|
||||
const visible: L.Marker[] = [];
|
||||
for (const cityId of pl.cityIds) {
|
||||
const m = markerIndexRef.current.get(cityId);
|
||||
if (m && map.hasLayer(m)) visible.push(m);
|
||||
}
|
||||
if (visible.length < 2) continue;
|
||||
|
||||
const segments: L.LatLng[] = [];
|
||||
for (let i = 0; i < visible.length - 1; i++) {
|
||||
const from = visible[i]!.getLatLng();
|
||||
const to = visible[i + 1]!.getLatLng();
|
||||
const arc = buildGreatCircleArc(from, to);
|
||||
segments.push(...(i === 0 ? arc : arc.slice(1)));
|
||||
}
|
||||
|
||||
L.polyline(segments, styleOpts).addTo(layer);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialize map ---
|
||||
@@ -281,6 +321,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
// Rerun visibility + tooltip rules on every zoom change.
|
||||
map.on("zoomend", () => {
|
||||
syncVisibility();
|
||||
syncPolylines();
|
||||
syncTooltips();
|
||||
});
|
||||
|
||||
@@ -296,6 +337,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
zoomLayersRef.current = null;
|
||||
markerIndexRef.current = new Map();
|
||||
highlightedIdsRef.current = new Set();
|
||||
intermediateIdsRef.current = new Set();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -365,30 +407,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
|
||||
// --- Sync polylines ---
|
||||
useEffect(() => {
|
||||
const layer = polylinesLayerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
for (const pl of polylines) {
|
||||
const style = POLYLINE_STYLES[pl.style];
|
||||
|
||||
// Build great-circle arcs between consecutive points
|
||||
const arcPoints: L.LatLng[] = [];
|
||||
for (let i = 0; i < pl.points.length - 1; i++) {
|
||||
const ptFrom = pl.points[i];
|
||||
const ptTo = pl.points[i + 1];
|
||||
if (!ptFrom || !ptTo) continue;
|
||||
const from = L.latLng(ptFrom.lat, ptFrom.lng);
|
||||
const to = L.latLng(ptTo.lat, ptTo.lng);
|
||||
const arc = buildGreatCircleArc(from, to);
|
||||
arcPoints.push(...(i === 0 ? arc : arc.slice(1)));
|
||||
}
|
||||
|
||||
if (arcPoints.length >= 2) {
|
||||
L.polyline(arcPoints, style).addTo(layer);
|
||||
}
|
||||
}
|
||||
syncPolylines();
|
||||
}, [polylines]);
|
||||
|
||||
// --- Sync popups ---
|
||||
@@ -416,9 +435,16 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
// --- Re-sync visibility + tooltips when toggles change ---
|
||||
useEffect(() => {
|
||||
syncVisibility();
|
||||
syncPolylines();
|
||||
syncTooltips();
|
||||
}, [domestic, international]);
|
||||
|
||||
// --- Re-sync tooltips when intermediateIds change ---
|
||||
useEffect(() => {
|
||||
intermediateIdsRef.current = new Set(intermediateIds ?? []);
|
||||
syncTooltips();
|
||||
}, [intermediateIds]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
routesToPolylines,
|
||||
intermediateCityIds,
|
||||
} from "./routesToPolylines.js";
|
||||
import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";
|
||||
|
||||
function filter(
|
||||
overrides: Partial<IFlightsMapFilterState> = {},
|
||||
): IFlightsMapFilterState {
|
||||
return {
|
||||
connections: false,
|
||||
domestic: false,
|
||||
international: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("routesToPolylines — empty input", () => {
|
||||
it("returns [] for no routes", () => {
|
||||
expect(routesToPolylines([], filter({ departure: "A" }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routesToPolylines — spider mode (departure only)", () => {
|
||||
it("produces one polyline per unique last-hop destination", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["A", "B"], isDirect: true },
|
||||
{ route: ["A", "C"], isDirect: true },
|
||||
{ route: ["A", "B"], isDirect: true },
|
||||
];
|
||||
|
||||
const pls = routesToPolylines(routes, filter({ departure: "A" }));
|
||||
|
||||
expect(pls).toHaveLength(2);
|
||||
expect(pls.every((p) => p.cityIds[0] === "A")).toBe(true);
|
||||
expect(pls.every((p) => p.cityIds.length === 2)).toBe(true);
|
||||
expect(pls.every((p) => p.style === "direct")).toBe(true);
|
||||
const dests = pls.map((p) => p.cityIds[1]).sort();
|
||||
expect(dests).toEqual(["B", "C"]);
|
||||
});
|
||||
|
||||
it("drops routes whose last hop equals departure", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["A", "A"], isDirect: true },
|
||||
{ route: ["A", "B"], isDirect: true },
|
||||
];
|
||||
const pls = routesToPolylines(routes, filter({ departure: "A" }));
|
||||
expect(pls).toHaveLength(1);
|
||||
expect(pls[0]!.cityIds).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("skips single-city routes (len < 2)", () => {
|
||||
const routes: IFlightRoute[] = [{ route: ["A"], isDirect: true }];
|
||||
const pls = routesToPolylines(routes, filter({ departure: "A" }));
|
||||
expect(pls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routesToPolylines — route mode (departure + arrival)", () => {
|
||||
it("direct route gets style=\"direct\"", () => {
|
||||
const routes: IFlightRoute[] = [{ route: ["A", "B"], isDirect: true }];
|
||||
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }));
|
||||
expect(pls).toHaveLength(1);
|
||||
expect(pls[0]!.style).toBe("direct");
|
||||
expect(pls[0]!.cityIds).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("non-direct route gets style=\"connecting\" (hop count irrelevant)", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["A", "X", "B"], isDirect: false },
|
||||
{ route: ["A", "B"], isDirect: false },
|
||||
];
|
||||
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }));
|
||||
expect(pls.every((p) => p.style === "connecting")).toBe(true);
|
||||
});
|
||||
|
||||
it("assigns unique IDs across multiple routes", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["A", "B"], isDirect: true },
|
||||
{ route: ["A", "X", "B"], isDirect: false },
|
||||
{ route: ["A", "B"], isDirect: false },
|
||||
];
|
||||
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }));
|
||||
const ids = new Set(pls.map((p) => p.id));
|
||||
expect(ids.size).toBe(pls.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intermediateCityIds", () => {
|
||||
it("returns [] for no routes", () => {
|
||||
expect(intermediateCityIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores 2-city routes", () => {
|
||||
expect(intermediateCityIds([{ route: ["A", "B"], isDirect: true }])).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts the single inner city from a 3-city route", () => {
|
||||
expect(
|
||||
intermediateCityIds([{ route: ["A", "X", "B"], isDirect: false }]),
|
||||
).toEqual(["X"]);
|
||||
});
|
||||
|
||||
it("extracts both inner cities from a 4-city route", () => {
|
||||
expect(
|
||||
intermediateCityIds([{ route: ["A", "X", "Y", "B"], isDirect: false }]).sort(),
|
||||
).toEqual(["X", "Y"]);
|
||||
});
|
||||
|
||||
it("dedups intermediates across multiple routes", () => {
|
||||
const ids = intermediateCityIds([
|
||||
{ route: ["A", "X", "B"], isDirect: false },
|
||||
{ route: ["C", "X", "D"], isDirect: false },
|
||||
]);
|
||||
expect(ids).toEqual(["X"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Transform API-returned routes into polylines the map can render.
|
||||
*
|
||||
* Two modes, mirroring Angular `fetchAndDraw`:
|
||||
* - Spider (departure only): one straight polyline from departure to each
|
||||
* unique destination across all routes.
|
||||
* - Route (departure + arrival): one polyline per route following its city
|
||||
* sequence; direct routes solid, connecting routes dashed.
|
||||
*
|
||||
* `intermediateCityIds` returns marker IDs whose tooltips must be force-opened
|
||||
* along multi-hop routes, matching Angular `updateIntermediateTooltip`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IFlightRoute,
|
||||
IFlightsMapFilterState,
|
||||
IMapPolyline,
|
||||
} from "./types.js";
|
||||
|
||||
export function routesToPolylines(
|
||||
routes: IFlightRoute[],
|
||||
filterState: Pick<IFlightsMapFilterState, "departure" | "arrival">,
|
||||
): IMapPolyline[] {
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
const hasDeparture = Boolean(filterState.departure);
|
||||
const hasArrival = Boolean(filterState.arrival);
|
||||
const isSpiderMode = hasDeparture && !hasArrival;
|
||||
|
||||
if (isSpiderMode) {
|
||||
const fromCode = filterState.departure!;
|
||||
const destCodes = new Set<string>();
|
||||
for (const r of routes) {
|
||||
if (r.route.length > 1) {
|
||||
const dest = r.route[r.route.length - 1]!;
|
||||
if (dest !== fromCode) destCodes.add(dest);
|
||||
}
|
||||
}
|
||||
return [...destCodes].map((dest) => ({
|
||||
id: `spider-${fromCode}-${dest}`,
|
||||
cityIds: [fromCode, dest],
|
||||
style: "direct",
|
||||
}));
|
||||
}
|
||||
|
||||
return routes.map((r, i) => ({
|
||||
id: `route-${i}-${r.route.join("-")}`,
|
||||
cityIds: r.route,
|
||||
style: r.isDirect ? "direct" : "connecting",
|
||||
}));
|
||||
}
|
||||
|
||||
export function intermediateCityIds(routes: IFlightRoute[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const r of routes) {
|
||||
if (r.route.length <= 2) continue;
|
||||
for (let i = 1; i < r.route.length - 1; i++) ids.add(r.route[i]!);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
@@ -101,10 +101,14 @@ export type PolylineStyle = "direct" | "connecting";
|
||||
|
||||
/**
|
||||
* A polyline to render on the map.
|
||||
*
|
||||
* Cities are referenced by code; MapCanvas resolves coordinates via its
|
||||
* internal markerIndex and filters out cities whose marker is not currently
|
||||
* on the map (matches Angular `drawPolyline` behavior).
|
||||
*/
|
||||
export interface IMapPolyline {
|
||||
id: string;
|
||||
points: Array<{ lat: number; lng: number }>;
|
||||
cityIds: string[];
|
||||
style: PolylineStyle;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user